延迟队列是这样一种队列:元素有一个过期时间,元素在队列中顺序是按照过期时间排序的,只有到达过期时间的元素才能出队,最先到达过期时间的元素最先出队。
延迟队列可以用到这样的场景:比如有一个任务需要 10 分钟后执行,那么就可以把这个任务存入延迟队列,并且设置过期时间为 10 分钟,队列的另一头有一个消费者去消费,没到达过期时间时消费者是取不到这个任务的,到达过期时间后消费者才能取到这个任务。
像这种多少分钟之后执行的任务特别适合使用延迟队列实现,如果不使用延迟队列,把任务存入数据库定期扫描也能实现,但是会有一个问题:如果扫描时间间隔长了,执行时间不准,如果扫描间隔时间短了,那么对数据库压力比较大。
DelayQueue
DelayQueue 是 JDK 中自带的延迟队列实现,向该队列中提交的任务需要实现 Delayed 接口,这个接口只有一个方法 long getDelay(TimeUnit unit)
这个接口如果返回 0 或负数表示任务已经到达了过期时间,可以执行;否则表示还没有到达过期时间,不能执行。
DelayQueue使用示例
public class DelayedTask implements Delayed {
private String taskName;
private long avaibleTime;
public String getTaskName() {
return taskName;
}
public DelayedTask(String taskName, long delayTime) {
this.taskName = taskName;
this.avaibleTime = System.currentTimeMillis() + delayTime;
}
@Override
public long getDelay(TimeUnit unit) {
return unit.convert(avaibleTime - System.currentTimeMillis(), TimeUnit.MILLISECONDS);
}
@Override
public int compareTo(Delayed o) {
return (int)(this.avaibleTime - ((DelayedTask)o).avaibleTime);
}
}
上面代码中我们定义了 DelayedTask 任务并且实现了 Delayed 接口,在 DelayedTask 中维护了一个成员变量 avaibleTime 存放任务到期时间,在 getDelay 方法实现中用 avaibleTime 减去当前时间戳,如果是一个负数或 0,表示任务已经到达了到期时间,可以执行了。
测试
DelayQueue<DelayedTask> queue = new DelayQueue<>();
new Thread(()->{
try {
//take方法会阻塞,直到有任务到期
DelayedTask task = queue.take();
System.out.println(task.getTaskName());
} catch (InterruptedException e) {
e.printStackTrace();
}
}).start();
queue.put(new DelayedTask("任务", 3000));
三秒种后输出 任务
实现原理
主要属性
//优先队列
private final PriorityQueue q = new PriorityQueue();
//leader线程表示正在等待出队的线程
private Thread leader = null;
//可用条件
private final Condition available = lock.newCondition();
DelayQueue 的底层实现是优先队列
take方法
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 <= 0) ③
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();
}
}
在 take 方法中,首先获取锁,然后查看队首元素
-
如果队首元素为 null,说明队列是空的,调用 available.await() 等待 ②
-
如果队首元素不为 null,调用 getDelay 判断该任务是否到期
- 如果返回负数或 0 ,说明任务已经到期,直接获取队首元素返回 ③
- 如果不是负数或 0,说明任务还没到期。这时判断 leader 是否为 null
- 如果 leader 不为 null,说明当前有一个 leader 在等待队列头元素到期,调用 available.await() 将当前线程挂起 ④
- 如果 leader 为 null,将当前线程设置为 leader ,并调用 available.await(delay) 挂起等待剩余到期时间。当线程被系统唤醒后,将 leader 设置为 null,重新去获取元素,这时队首元素就已经到期了,这个线程会在 ③ 处返回。
-
最后,方法执行返回前,会判断如果 leader 为 null,并且队列中有元素,会调用 avilable.signal 唤醒一个等待的线程去竞争 leader。⑥
如图,现在有三个线程去消费这个队列,队列中的 TaskA 没到执行时间,Thread1 是当前的 Leader 线程,它会等待延迟纳秒数,然后拉取队列头节点消费并且调用 signal() 唤醒 Follower 节点。如下图,Thread3 节点被唤醒,它会先看一下头节点是否到期,如果没到期就等待到它到期。
时间轮算法
如上图,在时间轮算法中,有一个轮,这个轮由 n 个槽组成,有一个指针向钟表一样每隔一个时间间走到下个槽位上,然后这个槽位上的任务就可以执行。假设现在有 8 个槽组成时间轮,指针每秒移动一个槽位,现在指针指向 1 这个槽,假设现在有一个任务要在 5 秒钟之后执行,那么把这个任务就放在 5 + 1 = 6 这个槽上,这样在 5 秒钟后指针就会移动到这个槽,然后这个槽对应的任务就会被执行。
如果有任务要在 20 秒后执行,那么就需要指针多转两圈,将任务放在 (20%8 + 1)= 5 这个槽上。
这个圈数需要记录在槽中的数据结构里面。这个数据结构最重要的是两个指针,一个是触发任务的函数指针,另外一个是触发的总第几圈数。时间轮可以用简单的数组或者是环形链表来实现。
netty 的 HashedWheelTimer 就采用了时间轮算法。
相比 DelayQueue 的数据结构,时间轮在算法复杂度上有一定优势。DelayQueue 由于涉及到排序,需要调整数据的位置,插入和移除的复杂度是 O(lgn),而时间轮在插入和移除的复杂度都是 O(1)。
使用Redis的zset实现延迟队列
消息的生产者使用 zadd quque [当前时间戳 + 延迟时间] [序列化成字符串的消息]
将消息插入消息队列。
开启多个线程不断执行 zrangebyscore quque [0] [当前时间戳] limit 0 1
命令,ZRANGEBYSCORE这个命令表示获取队列中的成员,并按分数从小到大排列,limit 0 1
偏移量为 0,获取一条数据,所以返回的就是时间戳最小的那条。
判断这条数据是否到达了执行时间,如果到达了执行时间,执行 zrem member
,ZREM执行这个命令有两个目的:
1.从延迟队列中删除这条消息防止其它线程再次获取;
2.可能存在多个线程同时获取到这条消息,所以这里靠 zrem 只有返回 > 0,才说明当前线程成功获取到消息,可以消费消息,如果返回 = 0,说明已有其它线程抢先获取到消息了,当前线程不可以消费消息。
优化:
因为我们是用多个线程去同时 zrangebyscore 然后再通过 zrem 是否成功来确定是否争抢到消息,没有抢到消息的线程实际上就白白执行了一次 zrangebyscore,可以使用 Lua 脚本将 zrangebyscore 和 zrem 放到一起在服务端执行。