延时队列,顾名思义它是一种带有延迟功能的消息队列。下面我们先谈谈延时队列的使用场景。
背景
我们先看看如下业务场景:
- XXX时间之后给用户发送通知;
- 处于未支付状态的订单,一小时之后自动关闭,等等
类似场景非常之多,这里不一一列举。为了解决以上问题,最简单直接的办法就是定时去扫表。简单场景下,这种方案还是可行的。但是当我们需要发送大批量的通知,或者需要扫描的表数据量很大时,这无疑会加重DB的负担。
总结:定时扫表的办法存在很大的效率问题,此外,存在无法控制时延的问题。
延时队列恰好是为了解决此类问题而生,下面进入本文主题。
单机环境解决方案
单机环境下我们不需要实现自己的延时队列,Java已经为我们提供了现成的解决方案----DelayQueue
DelayQueue简介
Java中的DelayQueue位于java.util.concurrent包下,本质是由PriorityQueue和BlockingQueue实现的无界阻塞优先级队列。DelayQueue设计要点:
- Delayed:一个带有过期时间的元素;
- PriorityQueue:优先级队列,意味着队列里元素需要根据Delayed的时延属性进行排序;
- BlockingQueue:阻塞队列,意味着当我们使用take方法从队列取元素时,如果不存在已经过期的元素,线程会阻塞直到有可用元素。
DelayQueue本质是将元素保存在PriorityQueue中,并根据Delayed的时延属性进行排序。这样即可保证最先过期的元素排在队首,每次从队列里取出来都是最先要过期的元素。
DelayQueue原理
下面结合源码谈谈DelayQueue的实现。
入队
DelayQueue的add、put、offer均可向队列中添加一个元素,add和put本质是调用offer。我们知道,当队列满时,阻塞队列的put是会阻塞的,由于DelayQueue是无界的,put永远不会被阻塞。看源码:
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();
}
}
offer实现还是比较清晰易懂的,首先加锁,然后将offer操作转发给PriorityQueue,由PriorityQueue实现元素添加并排序。如果当前元素是队头元素,将leader置为空,并唤醒第一个阻塞的线程(如果有)。
出队
我们可以调用poll和take从队列中取出队头元素,并从队列中删除该元素。区别在于元素未过期时,poll会返回null,take则会阻塞。下面看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内部是一个自旋操作,只有队列有可用元素可出队才会返回。首先会进行加锁,然后执行以下操作:
- 查询队头元素first(不从队列删除),若为空,说明队列为空,直接进入无限期等待状态;
- 如果队头元素first不为空,判断元素是否过期,已过期则将元素出队,跳到第6步;
- 如果元素未过期,释放first引用,接着判断leader是否为空,不为空说明Leader线程正在等待队头元素,当前线程立即进入无限期等待状态;
- 如果leader为空,将当前线程赋值给leader,然后等待指定delay时间,即等待队头元素到达可以出队的时间,最后在finally块中释放leader元素的引用;
- 重复以上过程,直到有元素可出队;
- 元素出队后,如果leader为空且队列非空,在finally块顺序唤醒等待中的第一个线程。
关键点说明:
当元素未过期,线程进入等待之前,会释放first引用。请注意,这是非常重要的。当多个线程同时在等待队列中时,Leader线程从队列中成功获取元素之后,first已经从队列中移除,这个对象理应被GC回收。如果不释放first引用,其他阻塞中的线程就会有引用指向该元素,GC链可达,导致GC无法回收first指向的内存。如果后续线程长期等待,就会造成内存泄漏。
leader的作用
leader指向正在等待队头元素的线程。这里使用了领导者/追随者Leader-Follower多线程模型的思想进行设计,目的是为了最小化不必要的定时等待。当一个线程成为Leader时,它只需等待下一个延迟,但Follower线程需要无限期地等待。Leader线程从take返回之前,负责发送唤醒通知,等待队列中第一个线程会被唤醒,开始竞争Leader。
当队列为空时,线程A调用take并获得锁,线程A直接进入无限期等待状态,并释放锁。此时若有其他线程请求take,将进入Condition的等待队列。
当队列加入第一个元素时,leader会被置为空。Condition负责唤醒其等待队列的第一个线程A,如果此时没有其他线程来竞争锁。线程A将会如愿获得锁,然后判断元素是否过期。如果未过期,当前线程就会成为Leader线程,然后等待指定delay时间后唤醒,将leader置为空。当线程A成功获取元素,如果延时队列不为空,它负责发送唤醒通知Condition的等待队列的第一个线程,如此循环。
当一个线程调用take并获得锁之后,如果leader不为空,说明Leader线程正在等待队头元素,当前线程将直接进入Condition的等待队列,并等待信号通知。
DelayQueue实战
构建消息体:
import java.util.concurrent.Delayed;
import java.util.concurrent.TimeUnit;
public class CheckNoticeDelay implements Delayed {
private Integer recordId;
private long delayTime;
public CheckNoticeDelay(Integer recordId, long delayTime) {
this.recordId = recordId;
this.delayTime = delayTime;
}
@Override
public long getDelay(TimeUnit unit) {
return unit.convert(this.delayTime - System.currentTimeMillis(), TimeUnit.MILLISECONDS);
}
@Override
public int compareTo(Delayed o) {
if (o == null) {
return 1;
}
if (o == this) {
return 0;
}
long diff = this.getDelay(TimeUnit.MILLISECONDS) - o.getDelay(TimeUnit.MILLISECONDS);
return diff > 0 ? 1 : diff == 0 ? 0 : -1;
}
public long getDelayTime() {
return delayTime;
}
public Integer getRecordId() {
return recordId;
}
}
队列管理CheckNotifyManager:
import java.util.concurrent.DelayQueue;
import org.springframework.util.Assert;
public class CheckNotifyManager {
private DelayQueue<CheckNoticeDelay> pushQueue = new DelayQueue();
public boolean addNotice(CheckNoticeDelay checkNotice) {
Assert.notNull(checkNotice);
return pushQueue.offer(checkNotice);
}
public CheckNoticeDelay takeNotice() throws InterruptedException {
return pushQueue.take();
}
}
构建Task,不断从延时队列取消息并发送push:
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
public class CheckRecordPushTask implements Runnable {
private static Logger logger = LoggerFactory.getLogger(CheckRecordPushTask.class);
private CheckNotifyManager manager;
public CheckRecordPushTask(CheckNotifyManager manager) {
this.manager = manager;
}
@Override
public void run() {
while (true) {
try {
CheckNoticeDelay delay = manager.takeNotice();
if (delay != null) {
logger.info("push msg,recordId:{}", delay.getRecordId());
}
} catch (InterruptedException e) {
logger.error("Thread is interrupted");
}
}
}
}
测试:
@Test
public void testPushNotice() {
CheckNotifyManager manager = new CheckNotifyManager();
manager.addNotice(new CheckNoticeDelay(1, System.currentTimeMillis() + 1 * 1000));//1s
manager.addNotice(new CheckNoticeDelay(2, System.currentTimeMillis() + 6 * 1000));//6s
manager.addNotice(new CheckNoticeDelay(3, System.currentTimeMillis() + 3 * 1000));//3s
CheckRecordPushTask task = new CheckRecordPushTask(manager);
Thread thread = new Thread(task);
thread.start();
try {
Thread.sleep(10 * 1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
thread.interrupt();
}
请注意,由于DelayQueue中的数据是保存在当前JVM堆内存中,一旦服务重启,数据就会丢失。因此,需要考虑数据持久化,服务重启时,需要从DB中load未处理的数据。
DelayQueue使用限制
不可否认,单机环境下,DelayQueue是实现延时需求很理想的方案,精巧好用。但仍然存在以下问题:
- DelayQueue中的数据始终是保存在当前JVM堆内存中,受JVM内存大小的限制,无法动态扩展以满足大容量需求;
- 每次服务重启,都需要从DB中load未处理的数据;
- 无法迁移到分布式环境下,这是硬伤。
分布式环境解决方案
为了解决分布式环境下延时场景的需求,我们采用了redis实现延时队列。
redis实现
场景需求:我们的形象抽查系统负责对用户发起一个抽查,如果一个用户被抽查之后未处理,那么我们需要在抽查即将过期时给用户发送通知。
Redis提供了很多数据结构,其中的zset是一种有序的数据结构,正好适合实现一个自己的延时队列。实现思路如下:
消息体Message:recordId,delayTime
入队:每创建一条抽查记录,构造一个消息Message,以delayTime作为元素的score将消息存入zset。
出队:我们有专门的调度线程负责从zset中获取队头元素。如果元素未过期,将该元素重新存入zset,然后退出本次循环;如果元素已过期,将消息提交到任务线程池异步给用户发送通知,调度线程继续处理下一个元素。
方案优点:
- 容量大小理论上只受redis集群的限制,能够满足大容量需求;
- 应用重启不必考虑load未处理的数据,redis重启自有机制保证数据不丢失,满足持久化需求;
- 满足分布式应用场景。
待优化的点:
- 调度线程由定时参数控制(如1min跑一次),定时参数需要根据场景定制化;
- 通过调度线程定时去扫描zset队列,未免太被动,容易造成资源浪费;
- 只有一个队列,容易造成消息的堆积;
- 当大批量元素集中在某一时刻过期时,容易导致CPU彪高或处理延迟。
队列管理CheckNotifyRedisManager:
import org.springframework.util.Assert;
public class CheckNotifyRedisManager {
private RedisCacheService redisCacheService;
public CheckNotifyRedisManager(RedisCacheService redisCacheService){
this.redisCacheService=redisCacheService;
}
public boolean addNotice(CheckNoticeDelay checkNotice) {
Assert.notNull(checkNotice);
redisCacheService.zput(RedisKeyConstant.DELAY_QUEUE_KEY, notice, notice.getDelayTime());
return Boolean.TRUE;
}
public CheckNoticeDelay takeNotice() {
Object obj = redisCacheService.zpop(RedisKeyConstant.DELAY_QUEUE_KEY);
if (obj != null && obj instanceof CheckNoticeDelay) {
CheckNoticeDelay delay= (CheckNoticeDelay) obj;
if (delay.getDelayTime() > System.currentTimeMillis()) {
addNotice(delay);
return null;
}
return delay;
}
return null;
}
}
task改造:
@Override
public void run() {
while (true) {
try {
CheckNoticeDelay delay = manager.takeNotice();
if (delay == null) {
break;
}
logger.info("push msg,recordId:{}", delay.getRecordId());
} catch (InterruptedException e) {
logger.error("Thread is interrupted");
}
}
}
后续优化
使用Java单线程池newSingleThreadExecutor作为调度线程,负责扫描zset队列,然后分两种情况:
- 队列为空,借助Java对象锁的wait/notify机制,调度线程进入无限等待,当我们向队列加入元素时唤醒;
- 队列不空,队头元素未过期,则调度线程sleep队头元素即将超时的时间。
CheckNotifyRedisManager改造:
public class CheckNotifyRedisManager {
private RedisCacheService redisCacheService;
public CheckNotifyRedisManager(RedisCacheService redisCacheService){
this.redisCacheService=redisCacheService;
}
public boolean addNotice(CheckNoticeDelay checkNotice) {
Assert.notNull(checkNotice);
redisCacheService.zput(RedisKeyConstant.DELAY_QUEUE_KEY, notice, notice.getDelayTime());
synchronized (waitLock){
waitLock.notifyAll();
}
return Boolean.TRUE;
}
public CheckNoticeDelay takeNotice() {
while(true){
Object obj = redisCacheService.zpop(RedisKeyConstant.DELAY_QUEUE_KEY);
if (obj != null && obj instanceof CheckNoticeDelay) {
CheckNoticeDelay delay= (CheckNoticeDelay) obj;
if (delay.getDelayTime() <= System.currentTimeMillis()) {
return (CheckNoticeDelay) obj;
}
pushDelayNotice(delayNotice);
synchronized (waitLock){
waitLock.wait(delayNotice.getDelayTime()-System.currentTimeMillis());
}
}
synchronized (waitLock){
waitLock.wait();
}
}
}
}
这样调度线程可以更加灵活,当延时队列长期为空时不会大致大量无效查询。但该方案有一个极端情况,若某一台服务长期未向队列加入元素,可能导致调度线程一直等待。更可怕的是,如果延时队列已经累积大量的任务,而某台机器由于未得到通知,将迟迟不能运行。当然,负载均衡策略可以避免出现这种极端情况。
使用redis的zset来实现延时队列是分布式环境下比较便捷的一种方案。但不是唯一方案,可选择的方案还是比较多的,如RabbitMQ和Beanstalkd,有兴趣自行研究。
欢迎指出本文有误的地方。