延时队列

延时队列提供一种在指定时间后才能获取该元素的机制,这种机制一般用于如下的场景:

  • 订单的自动取消
  • 体验时间到期
  • 商品评价的提醒

…………

目前主要的延时队列实现包括 JUC 包下的 DelayQueue 以及通过 Redis 机制实现的 RDelayedQueue

DelayQueue

DelayQueue 是 JUC 包下的一个 BlockingQueue 的实现,提供延时处理的机制。该机制的实现是通过 Conditonwaitsignal)实现的

DelayQueue 和一般的 BlockingQueue 的一个很大不同点在于,DelayQueue 中的元素只能是 java.util.concurrent.Delayed 类型的,这是因为需要判断对应元素的到期状态,对应的类签名如下:

public class DelayQueue<E extends Delayed> extends AbstractQueue<E>
    implements BlockingQueue<E> {
}

具体使用

首先,由于 DelayQueue 中的元素只能是 Delayed 类型的,因此在日常使用中可能不得不对需要处理的任务进行一个自行的封装。

假设现在需要定义一个订单支付超时的任务,定义对应的源码如下:

import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.concurrent.Delayed;
import java.util.concurrent.TimeUnit;

/**
 *@author lxh
 */
public class OrderPayTask
        implements Delayed {

    /*
        该任务的一些简要描述
     */
    private final String taskName;

    /*
        当前任务处理的订单信息 id
     */
    private final Long orderId;

    /*
        该任务创建的时间,主要是为了后续排查
     */
    private final long startTime = System.currentTimeMillis();

    /*
        该任务需要延迟多长时间后再执行,这里的单位可以自己定义,但是需要与后续 getDelay
        方法的实现保持一致
     */
    private final long delayTimes;

    public OrderPayTask(String taskName, Long orderId, long endTime) {
        this.taskName = taskName;
        this.orderId = orderId;
        this.delayTimes = endTime;
    }

    /*
        简单地讲,这个方法如果返回的是一个 <= 0 的数,则说明该任务已经到期了,否则,说明该任务未到期
        因此在重写该方法的实现时需要注意单位的匹配
     */
    @Override
    public long getDelay(TimeUnit unit) {
        /*
            这里我们假定输入的 delayTimes 表示的是毫秒数,因此如果任务创建时间加上延迟时间小于当前时间的话,
            说明该任务已经到期了
         */
        return unit.convert((startTime + delayTimes) - System.currentTimeMillis(), TimeUnit.MILLISECONDS);
    }

    /*
        这个方法是 Comparable 接口要求的实现,因为 DelayQueue 的底层是通过 PriorityQueue 存储的元素,只能支持 Comparable 类型的元素
        不过一般情况下我们都可以假设快到期的任务优先级较高(getDelay() 较小),这一般是合理的
        如果有特殊要求的话也可以重写该方法的比对逻辑,提高任务的处理优先级(如 vip 任务)
     */
    @Override
    public int compareTo(Delayed o) {
        if (!(o instanceof OrderPayTask)) {
            throw new IllegalArgumentException("比较的类型必须为: " + OrderPayTask.class.getName());
        }
        OrderPayTask that = (OrderPayTask) o;
        return Long.compare(this.getDelay(TimeUnit.MILLISECONDS), that.getDelay(TimeUnit.MILLISECONDS));
    }

    @Override
    public String toString() {
        return "OrderPayTask{" +
                "taskName='" + taskName + '\'' +
                ", orderId=" + orderId +
                ", startTime=" + new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(new Date(startTime)) +
                ", delayTimes=" + delayTimes +
                '}';
    }
}

后续在封装好的任务像 BlockingQueue 一样进行操作就行了,对应的 demo 代码如下所示:

public void orderPayTaskTest() throws InterruptedException {
    final DelayQueue<OrderPayTask> delayQueue = new DelayQueue<>();
    Thread thread = new Thread(() -> {
        List<OrderPayTask> data = new ArrayList<>();
        for (int i = 0; i < 10; i++) {
            data.add(new OrderPayTask(String.format("task-%d", (i + 1)), (long) i, (i + 1) * 500L));
        }
        data.forEach(delayQueue::offer);
    });
    thread.start();

    while (thread.isAlive() || !delayQueue.isEmpty()) {
        OrderPayTask task = delayQueue.take();
        System.out.println(task);
    }
}

对应的输出如下:

OrderPayTask{taskName='task-1', orderId=0, startTime=2025-04-04 20:01:02, delayTimes=500}
OrderPayTask{taskName='task-2', orderId=1, startTime=2025-04-04 20:01:02, delayTimes=1000}
OrderPayTask{taskName='task-3', orderId=2, startTime=2025-04-04 20:01:02, delayTimes=1500}
OrderPayTask{taskName='task-4', orderId=3, startTime=2025-04-04 20:01:02, delayTimes=2000}
OrderPayTask{taskName='task-5', orderId=4, startTime=2025-04-04 20:01:02, delayTimes=2500}
OrderPayTask{taskName='task-6', orderId=5, startTime=2025-04-04 20:01:02, delayTimes=3000}
OrderPayTask{taskName='task-7', orderId=6, startTime=2025-04-04 20:01:02, delayTimes=3500}
OrderPayTask{taskName='task-8', orderId=7, startTime=2025-04-04 20:01:02, delayTimes=4000}
OrderPayTask{taskName='task-9', orderId=8, startTime=2025-04-04 20:01:02, delayTimes=4500}
OrderPayTask{taskName='task-10', orderId=9, startTime=2025-04-04 20:01:02, delayTimes=5000}

实现原理

BlockingQueue 的一些 API 如下:

方法 抛出异常 返回特定值 阻塞 阻塞特定时间
入队 add(e) offer(e) put(e) offer(e, time, unit)
出队 remove() poll() take() poll(time, unit)
获取队首元素 element() peek() 不支持 不支持
  • 实例化

    DelayQueue 实例化的时候,会初始化一个 PriorityQueueCondition 对象,分别用于存储对应的任务数据和协调生产者和消费者之间的关系

    对应的代码如下:

    public class DelayQueue<E extends Delayed> extends AbstractQueue<E>
        implements BlockingQueue<E> {
    
        private final transient ReentrantLock lock = new ReentrantLock();
        private final PriorityQueue<E> q = new PriorityQueue<E>();
        
        private final Condition available = lock.newCondition();
        
        public DelayQueue() {}
    }
    
  • offer 方法

    入队的方法 addputoffer(e, time, unit) 都是直接调用的 offer(e) 方法,对应的源码如下:

    public boolean offer(E e) {
        final ReentrantLock lock = this.lock;
        lock.lock();
        try {
            q.offer(e); // 优先队列的入队
            if (q.peek() == e) {
                leader = null;
                available.signal(); // 通知消费者可以消费了
            }
            return true;
        } finally {
            lock.unlock();
        }
    }
    
  • poll 方法、poll(e, time, unit) 方法

    poll 方法本身比较简单,因为不涉及对元素的等待操作,直接返回优先队列的首个元素(如果存在首个元素,会检查元素是否已经到期):

    public E poll() {
        final ReentrantLock lock = this.lock;
        lock.lock();
        try {
            E first = q.peek();
            return (first == null || first.getDelay(NANOSECONDS) > 0)
                ? null
                : q.poll();
        } finally {
            lock.unlock();
        }
    }
    

    poll(e, time, unit) 涉及到等待操作,该操作是通过 Conditionawait 方法来实现的:

    public E poll(long timeout, TimeUnit unit) throws InterruptedException {
        long nanos = unit.toNanos(timeout);
        final ReentrantLock lock = this.lock;
        lock.lockInterruptibly();
        try {
            for (;;) {
                E first = q.peek();
                if (first == null) {
                    if (nanos <= 0L)
                        return null;
                    else
                        nanos = available.awaitNanos(nanos);
                } else {
                    // 存在元素,检查是否已经到期了
                    long delay = first.getDelay(NANOSECONDS);
                    if (delay <= 0L) // 上文提到过,getDelay() <= 0 就是到期的
                        return q.poll();
                    if (nanos <= 0L) // 等待时间已经到了
                        return null;
                    first = null; // don't retain ref while waiting
                    // leader 用于保证消费的顺序,防止线程被饿死
                    if (nanos < delay || leader != null)
                        nanos = available.awaitNanos(nanos);
                    else {
                        Thread thisThread = Thread.currentThread();
                        leader = thisThread;
                        try {
                            long timeLeft = available.awaitNanos(delay);
                            nanos -= delay - timeLeft;
                        } finally {
                            if (leader == thisThread)
                                leader = null;
                        }
                    }
                }
            }
        } finally {
            if (leader == null && q.peek() != null)
                available.signal();
            lock.unlock();
        }
    }
    
  • take 方法

    take 方法相当于一个没有时间限制的 poll(e, time, unit)

    public E take() throws InterruptedException {
        final ReentrantLock lock = this.lock;
        lock.lockInterruptibly();
        try {
            for (;;) {
                E first = q.peek();
                if (first == null)
                    available.await();
                else {
                    long delay = first.getDelay(NANOSECONDS);
                    if (delay <= 0L)
                        return q.poll();
                    first = null; // don't retain ref while waiting
                    if (leader != null)
                        available.await();
                    else {
                        Thread thisThread = Thread.currentThread();
                        leader = thisThread;
                        try {
                            available.awaitNanos(delay);
                        } finally {
                            if (leader == thisThread)
                                leader = null;
                        }
                    }
                }
            }
        } finally {
            if (leader == null && q.peek() != null)
                available.signal();
            lock.unlock();
        }
    }
    

RDelayedQueue

在分布式的应用场景下,JDK 自带的 DelayQueue 无法很好地工作,一种基于 Redis 的的 DelayQueue 是一种比较可行的替代方案

具体使用

import com.google.common.base.Stopwatch;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import org.redisson.api.RBlockingDeque;
import org.redisson.api.RDelayedQueue;
import org.redisson.api.RedissonClient;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.test.context.SpringBootTest;
import org.xhliu.springredission.SpringRedissionApplicationTests;

import javax.annotation.Resource;
import java.util.concurrent.TimeUnit;

/**
 *@author lxh
 */
@SpringBootTest(classes = SpringRedissionApplicationTests.class)
public class RedisDelayQueueTest {

    private final static Logger log = LoggerFactory.getLogger(RedisDelayQueueTest.class);

    @Resource
    RedissonClient redissonClient;

    @Test
    public void delayQueueTest() throws InterruptedException {
        Stopwatch stopwatch = Stopwatch.createStarted();
        stopwatch.reset();
        stopwatch.start();

        // 获取一个基于 Redis 的阻塞队列
        RBlockingDeque<String> chatBlockingDeque = redissonClient.getBlockingDeque("chat_deque");
        // 将这个阻塞队列封装成延时队列
        RDelayedQueue<String> delayedQueue = redissonClient.getDelayedQueue(chatBlockingDeque);
        for (int i = 0; i < 5; i++) {
            delayedQueue.offer("测试延时消息_" + (i + 1), (i + 1) * 2, TimeUnit.SECONDS);
        }

        // 特使阻塞是由于 take 方法导致的
        Assertions.assertTrue(stopwatch.elapsed(TimeUnit.MILLISECONDS) <= 2 * 1000);

        // 校验每条消息是否延时生效
        for (int i = 0; i < 5; i++) {
            String msg = chatBlockingDeque.take();
            long delayTimeMills = stopwatch.elapsed(TimeUnit.MILLISECONDS);
            log.info("{} take {} ms", msg, delayTimeMills);
            Assertions.assertTrue(delayTimeMills >= (i + 1) * 2 * 1000);
        }
    }
}

注意,在使用时需要确保没有关联到 delay-queueRedis key,避免由于订阅导致的异常情况

可能的输出结果如下:

2025-04-05 23:02:28.505  INFO 66460 --- [           main] o.x.s.task.RedisDelayQueueTest           : 测试延时消息_1 take 2074 ms
2025-04-05 23:02:30.496  INFO 66460 --- [           main] o.x.s.task.RedisDelayQueueTest           : 测试延时消息_2 take 4065 ms
2025-04-05 23:02:32.501  INFO 66460 --- [           main] o.x.s.task.RedisDelayQueueTest           : 测试延时消息_3 take 6070 ms
2025-04-05 23:02:34.502  INFO 66460 --- [           main] o.x.s.task.RedisDelayQueueTest           : 测试延时消息_4 take 8071 ms
2025-04-05 23:02:36.499  INFO 66460 --- [           main] o.x.s.task.RedisDelayQueueTest           : 测试延时消息_5 take 10068 ms

实现原理

具体的实现是通过 zset 来实现的,zset 中的 score 就是每个元素到期的时间戳,在 RDelayedQueue 中通过定时执行 lua 脚本检查是否存在到期的元素,再将元素加入到延时队列对应的列表中。

  • offer 方法

    由于 offer 方法是通过执行 lua 脚本来控制的,因此重点关注一下 lua 脚本

    -- keys example
    -- [chat_deque, redisson_delay_queue_timeout:{chat_deque}, redisson_delay_queue:{chat_deque}, redisson_delay_queue_channel:{chat_deque}]
    -- args example
    -- [1743733478725, 1870427430521884606, PooledUnsafeDirectByteBuf(ridx: 0, widx: 12, cap: 256)]
    -- 注意:lua 脚本的数组索引是按照 1 开始编号的
    
    -- ARGV[2] 表示的是一个随机数,相当于数据内容的 hashCode;ARGV[3] 表示实际的数据内容;将它们放入一个结构体中
    local value = struct.pack('dLc0', tonumber(ARGV[2]), string.len(ARGV[3]), ARGV[3]);
    -- KEYS[2] 表示是到 zset 对应的 key; ARGV[1] 为预期的到期时间,即 zset 的 socre;value 相当于一个附件
    redis.call('zadd', KEYS[2], ARGV[1], value);
    redis.call('rpush', KEYS[3], value);
    local v = redis.call('zrange', KEYS[2], 0, 0);
    if v[1] == value then
        redis.call('publish', KEYS[4], ARGV[1]);
    end ;
    
  • take 方法

    take 方法是通过 BLPOP 获取列表元素来实现的:

    public RFuture<V> takeAsync() {
        // getRawName() 为获取阻塞队列的对应的 value, 上文中的示例就是 chat_deque
        return commandExecutor.writeAsync(getRawName(), codec, RedisCommands.BLPOP_VALUE, getRawName(), 0);
    }
    
  • RedissonDelayedQueue 的实例化

    RedissonDelayedQueue 实例化时会定期执行一个 lua 脚本,以检查是否有到期的元素,执行的 lua 脚本如下:

    -- keys example
    -- [chat_deque, redisson_delay_queue_timeout:{chat_deque}, redisson_delay_queue:{chat_deque}]
    -- args example
    -- [System.currentTimeMillis(), 100]
    local expiredValues = redis.call('zrangebyscore', KEYS[2], 0, ARGV[1], 'limit', 0, ARGV[2]);
    -- 检查是否有到期的元素
    if #expiredValues > 0 then
        -- 遍历这些到期的元素
        for i, v in ipairs(expiredValues) do
            local randomId, value = struct.unpack('dLc0', v);
            -- 将这个到期的数据元素加入到 chat_deque 的 list 中
            redis.call('rpush', KEYS[1], value);
            redis.call('lrem', KEYS[3], 1, v);
        end ;
        -- 移除 zset 中的该数据元素
        redis.call('zrem', KEYS[2], unpack(expiredValues));
    end ;
    local v = redis.call('zrange', KEYS[2], 0, 0, 'WITHSCORES');
    if v[1] ~= nil then
        return v[2];
    end
    return nil;
    
posted @ 2025-04-06 19:44  FatalFlower  阅读(27)  评论(0)    收藏  举报