延时队列在业务系统中很常用,常见的付费逻辑中,订单生成后的计时,当订单超时后关闭订单,库存归位等,再比如定时逻辑,在一段时间后后者某个时间点触发某些动作,本文将详细讲述几种延时队列的实现逻辑。
- JUC中java原生的延时队列DelayQueue
- 时间轮TimeWheel算法
- redis实现延时队列
- 消息队列pulsar的延时应用
- RabbitMQ延迟消息
1.1 DelayQueue
说到延时队列DelayQueue,首先应该说到PriorityQueue,在DelayQueue中,包含的元素实际存储在PriorityQueue中,PriorityQueue是一个误解阻塞队列,元素存储于数组中,但实际存储逻辑是一个平衡二叉树,数组的第一个元素是根据比较器得到的最小的元素。在DelayQueue中,要求每个元素必须实现Delayed接口,该接口返回延时时长,同时继承了Comparable接口,需要实现比较逻辑,通过该比较逻辑进行比较排序,使得每次取得的元素为最早过期的元素或者空。
public interface Delayed extends Comparable<Delayed> {
/**
* Returns the remaining delay associated with this object, in the
* given time unit.
*
* @param unit the time unit
* @return the remaining delay; zero or negative values indicate
* that the delay has already elapsed
*/
long getDelay(TimeUnit unit);
}
1.2 DelayQueue实践
首先创建延时元素,实现Delayed接口,在该实现中,定义一个任务的创建序号和延时时长
import java.util.concurrent.Delayed;
import java.util.concurrent.TimeUnit;
public class DelayTask implements Delayed {
long dealAt;
int index;
public DelayTask(long time, int ix) {
dealAt = time;
index = ix;
}
@Override
public long getDelay(TimeUnit unit) {
return unit.convert(dealAt - System.currentTimeMillis(), TimeUnit.MILLISECONDS);
}
@Override
public int compareTo(Delayed o) {
if (getDelay(TimeUnit.MILLISECONDS) > o.getDelay(TimeUnit.MILLISECONDS)) {
return 1;
} else {
return -1;
}
}
}
在主方法中,创建4个任务,第一个任务延时15s,第二个任务延时10s,第三个任务延时5s,第四个任务延时20s,最后查看执行顺序。
public class Main {
public static void main(String[] args) throws InterruptedException {
DelayQueue<DelayTask> tasks = new DelayQueue<>();
long cur = System.currentTimeMillis();
long[] delay = {15000L, 10000L, 5000L, 20000L};
for (int i = 0; i < delay.length; i++) {
DelayTask task = new DelayTask(delay[i] + cur, i);
tasks.add(task);
}
while (!tasks.isEmpty()) {
DelayTask peek = tasks.take();
if (peek != null) {
tasks.poll();
System.out.println("时间间隔:"+ (System.currentTimeMillis() - cur) + "ms");
System.out.println("当前任务序号:" + peek.index);
cur = System.currentTimeMillis();
}
Thread.sleep(1000);
}
}
}
结果如下
时间间隔:5000ms
当前任务序号:2
时间间隔:5000ms
当前任务序号:1
时间间隔:5002ms
当前任务序号:0
时间间隔:4997ms
当前任务序号:3
Process finished with exit code 0
2.1 时间轮TimeWheel算法
想象这样一种情况,业务中要求提交定时任务,一种实现是提交任务后启动一个定时线程,轮训检测该任务,当任务量变的庞大的时候,这种开销是可怕的,令一种实现是将所有任务有些组织,只用一个线程就可以控制所有的定时任务,时间轮算法就是这种实现,netty、kafka、zookeeper中都使用了该算法实现延时处理。
时间轮如图所示,图中0~8代表时间周期,每个时间节点代表一个小的时间间隔,假设时间间隔为1分钟,则图中每个时间节点中将包含在这一分钟内的所有定时任务,存储为一个双向的linkedList,且图中的时钟周期为9分钟。
如图,当当前时间到达时间节点2时,时间节点1中的任务已经全部过期且处理完成,时间节点2对应的定时任务开始过期,开始处理节点2中对应的任务列表。
时间轮算法有其对应的优缺点,优点是我们可以使用一个线程监控很多的定时任务,缺点是时间粒度由节点间隔确定,所有的任务的时间间隔需要以同样的粒度定义,比如时间间隔是1小时,则我们定义定时任务的单位就为小时,无法精确到分钟和秒。
时间轮所能容纳的定时任务的时间是有限制的,由时间轮的周期决定,当超过了时间轮的时间周期,我们的定时任务该如何处理,有以下处理方式:
- 时间轮复用,比如当前时间为2,时间节点0和1的任务已过期,在此时来了一个任务,要求延时为9,此时2+9=11,对应的位置载1处,因此可以直接把任务放在1的链表,等下个时间周期继续处理。
- 将任务按照时间周期分组存放,一个时间周期结束后取出下一个时间周期的任务放入时间轮进行处理。
2.2 时间轮算法实践
我们需要如下接口(我们默认时间粒度为毫秒)
TimerTask定义具体的任务逻辑
public interface TimerTask {
void run() throws Exception;
}
Timeout是对任务的管理逻辑
public interface Timeout {
Timer timer();
TimerTask task();
boolean isExpired();
}
Timer管理所有的定时任务,也就是时间轮的要实现的主要方法
public interface Timer {
Timeout newTimeOut(TimerTask task, long delay, String argv);
}
接下来进行对应的实现
任务实体,简单的输出任务序号
import java.time.LocalDateTime;
public class MyTask implements TimerTask {
int index;
MyTask(int ix) {
index = ix;
}
@Override
public void run() throws Exception {
System.out.println(LocalDateTime.now());
System.out.println("当前任务序号是:" + index);
}
}
对任务进行管理的Timeout实体
public class MyTimeout implements Timeout {
Timer timer;
TimerTask timerTask;
String argv;
long delay;
int state;
long round;
public MyTimeout(Timer timer, TimerTask task, long delay, String argv) {
this.timer = timer;
this.timerTask = task;
this.delay = delay;
this.argv = argv;
state = 0;
}
@Override
public Timer timer() {
return timer;
}
@Override
public TimerTask task() {
return timerTask;
}
@Override
public boolean isExpired() {
return state != 0;
}
}
时间轮的实现,保留对新加任务的添加,时间轮的关闭,以及定时任务线程worker的实现逻辑
package com.example.test.simpleTimeWheel;
import org.springframework.util.CollectionUtils;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Iterator;
import java.util.LinkedList;
import java.util.List;
import java.util.concurrent.CountDownLatch;
public class TimeWheel implements Timer {
long basicTime;
List<Timeout> timeoutList;
CountDownLatch countDownLatch;
Worker woker = new Worker();
int wheelState = 1; // 0 运行 1 停止
int curIndex;
int size;
int mask;
long durationOfSlot;
LinkedList<Timeout>[] slots;
ArrayList<TimerTask> res = new ArrayList<>();
TimeWheel(int size, long durationOfSlot) throws Exception{
if (size < 0 || size > Integer.MAX_VALUE) {
throw new Exception("size out of range");
}
this.size = size;
slots = new LinkedList[size];
this.durationOfSlot = durationOfSlot;
mask = size - 1;
curIndex = 0;
basicTime = 0;
timeoutList = new ArrayList<>();
countDownLatch = new CountDownLatch(1);
}
@Override
public Timeout newTimeOut(TimerTask task, long delay, String argv) {
if(delay <= 0) {
try {
task.run();
} catch (Exception e) {
System.out.println("delay is less than zero, just run");
}
}
if (wheelState == 1) {
wheelState = 0;
Thread thread = new Thread(woker);
thread.start();
}
startTimeWheel();
Timeout timeout = new MyTimeout(this, task, delay, argv);
timeoutList.add(timeout);
return timeout;
}
private void startTimeWheel() {
while (basicTime == 0) {
try {
countDownLatch.await();
} catch (InterruptedException e) {
System.out.println("countdown await exception");
}
}
}
private class Worker implements Runnable {
@Override
public void run() {
if (basicTime == 0) {
System.out.println("时间轮启动......");
basicTime = Instant.now().toEpochMilli();
countDownLatch.countDown();
}
do {
long deadline = durationOfSlot * (curIndex + 1);
for (;;) {
//System.out.println(basicTime+"basc");
long duration = Instant.now().toEpochMilli() - basicTime;
//System.out.println(deadline +" " + duration);
if (duration > deadline) {
//时间到
process(timeoutList);
LinkedList<Timeout> tasks = slots[curIndex];
process(tasks);
break;
}
}
if (curIndex == mask) {
basicTime = System.currentTimeMillis();
}
curIndex = (++curIndex) % size;
checkStop();
} while (wheelState == 0);
}
private void checkStop() {
if (!CollectionUtils.isEmpty(timeoutList)) {
return;
}
for (LinkedList l : slots) {
if (!CollectionUtils.isEmpty(l)) {
return;
}
}
System.out.println("没有定时任务,结束");
wheelState = 1;
}
private void process(List<Timeout> timeoutList) {
//System.out.println("处理新加入的任务");
for (Timeout out : timeoutList) {
MyTimeout mo = (MyTimeout) out;
if (mo.isExpired()) {
continue;
}
long needskipslots = mo.delay / durationOfSlot;
mo.round = (needskipslots - curIndex) / size;
int index = (int)(needskipslots) % size;
int i = (index + curIndex) % size;
LinkedList<Timeout> slot = slots[i];
if (slot == null) {
slot = new LinkedList<>();
}
slot.add(mo);
slots[(index + curIndex) % size] = slot;
}
timeoutList.clear();
}
void process(LinkedList<Timeout> outs) {
//System.out.println("处理到期任务");
if (CollectionUtils.isEmpty(outs)) {
return;
}
Iterator<Timeout> iterator = outs.iterator();
while (iterator.hasNext()) {
Timeout next = iterator.next();
try {
MyTimeout mo = (MyTimeout) next;
if (mo.round > 0) {
mo.round --;
continue;
}
mo.task().run();
res.add(mo.task());
iterator.remove();
System.out.println("-************************************");
} catch (Exception e) {
System.out.println(e.getMessage());
}
}
}
}
}
Main()方法
package com.example.test.simpleTimeWheel;
public class TimeMain {
public static void main(String[] args) throws Exception {
TimeWheel timeWheel = new TimeWheel(10, 1000);
long delayTime[] = {9000, 3000, 5000, 1000, 25000, 12000};
for (int i = 0; i < delayTime.length; i++) {
MyTask task = new MyTask(i);
timeWheel.newTimeOut(task, delayTime[i], "");
}
}
}
生成6个任务,根据延时时长(单位:毫秒),根据代码可知,我们的执行顺序将会是:3-1-2-0-5-4
具体看结果,验证了我们的想法。
Connected to the target VM, address: '127.0.0.1:5120', transport: 'socket'
时间轮启动......
2020-09-12T11:18:22.440
当前任务序号是:3
-************************************
2020-09-12T11:18:24.386
当前任务序号是:1
-************************************
2020-09-12T11:18:26.386
当前任务序号是:2
-************************************
2020-09-12T11:18:30.386
当前任务序号是:0
-************************************
2020-09-12T11:18:33.387
当前任务序号是:5
-************************************
2020-09-12T11:18:46.388
当前任务序号是:4
-************************************
没有定时任务,结束
Disconnected from the target VM, address: '127.0.0.1:5120', transport: 'socket'
3.1 Redis实现延时队列
为了保证每次消费的都是最早到期的数据,我们选择redis的zset数据结构进行存储,zset的每个值包含value和score,我们可以用当前时间+延时时长获取任务需要执行的时间点的时间戳作为score,保证每次拿到最早到期的数据。
加入数据使用命令如下:
ZADD KEY_NAME SCORE1 VALUE1.. SCOREN VALUEN
读取使用命令如下:
ZRANGEBYSCORE key min max [WITHSCORES] [LIMIT offset count]
min可以设置为0,max为当前时间戳,这样每次获取到的结果为到目前为止过期的所有任务。
3.2 Redis延时列Demo
引入依赖
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-pool2</artifactId>
</dependency>
加入配置
# Redis服务器地址
spring.redis.host=127.0.0.1
# Redis服务器连接端口
spring.redis.port=6379
# 链接超时时间 单位 ms(毫秒)
spring.redis.timeout=3000
################ Redis 线程池设置 ##############
# 连接池最大连接数(使用负值表示没有限制) 默认 8
spring.redis.lettuce.pool.max-active=8
# 连接池最大阻塞等待时间(使用负值表示没有限制) 默认 -1
spring.redis.lettuce.pool.max-wait=-1
# 连接池中的最大空闲连接 默认 8
spring.redis.lettuce.pool.max-idle=8
# 连接池中的最小空闲连接 默认 0
spring.redis.lettuce.pool.min-idle=0
配置客户端
@Bean
public RedisTemplate<String, Serializable> redisTemplate(LettuceConnectionFactory connectionFactory) {
RedisTemplate<String, Serializable> redisTemplate = new RedisTemplate<>();
redisTemplate.setKeySerializer(new StringRedisSerializer());
redisTemplate.setValueSerializer(new GenericJackson2JsonRedisSerializer());
redisTemplate.setConnectionFactory(connectionFactory);
return redisTemplate;
}
给redis加入任务
@Service
public class RedisDelay {
@Autowired
RedisTemplate<String, Serializable> redisTemplate;
public static final String KEY = "delaymq";
@PostConstruct
public void init() {
RedisDelayInfoData r1 = new RedisDelayInfoData(1, "第1个任务序号");
redisTemplate.opsForZSet().add(KEY, r1, System.currentTimeMillis() + 5000);
RedisDelayInfoData r2 = new RedisDelayInfoData(2, "第2个任务序号");
redisTemplate.opsForZSet().add(KEY, r2, System.currentTimeMillis() + 2000);
RedisDelayInfoData r3 = new RedisDelayInfoData(3, "第3个任务序号");
redisTemplate.opsForZSet().add(KEY, r3, System.currentTimeMillis() + 3000);
RedisDelayInfoData r4 = new RedisDelayInfoData(4, "第4个任务序号");
redisTemplate.opsForZSet().add(KEY, r4, System.currentTimeMillis() + 5000);
RedisDelayInfoData r5 = new RedisDelayInfoData(5, "第5个任务序号");
redisTemplate.opsForZSet().add(KEY, r5, System.currentTimeMillis() + 10000);
RedisDelayInfoData r6 = new RedisDelayInfoData(6, "第6个任务序号");
redisTemplate.opsForZSet().add(KEY, r6, System.currentTimeMillis() + 7000);
}
}
如上,如果我们设置定时1秒查询redis,消费顺序应该为2-3-1/4-6-5
设置消费线程,用定时任务实现
@Scheduled(cron = "0/1 * * * * *")
public void consumer() {
long cur = System.currentTimeMillis();
Set<Serializable> range = redisTemplate.opsForZSet().rangeByScore(RedisDelay.KEY, 0, cur);
for (Serializable re : range) {
RedisDelayInfoData delayInfoData = (RedisDelayInfoData)re;
System.out.println(String.format("消费任务: %s, %s", delayInfoData.getMessage(), LocalDateTime.now()));
redisTemplate.opsForZSet().remove(RedisDelay.KEY, re);
}
}
最后结果验证如下:
消费任务: 第2个任务序号, 2020-09-12T12:55:05.028
消费任务: 第3个任务序号, 2020-09-12T12:55:06.002
消费任务: 第1个任务序号, 2020-09-12T12:55:08.002
消费任务: 第4个任务序号, 2020-09-12T12:55:08.003
消费任务: 第6个任务序号, 2020-09-12T12:55:10.002
消费任务: 第5个任务序号, 2020-09-12T12:55:13.005
顺序符合预期,时间间隔也正确。
4.1 消息队列pulsar的延时
pulsar消息队列在v2.4.0开始支持延迟消息,但只有在消费者模式为shared的方式下才可生效,具体模式的区别详见官网,此处不做详解。
为说明实现原理,需要列出一个重要的类
org.apache.pulsar.broker.delayed.DelayedDeliveryTracker
该类在内存维持一个包含延迟消息的优先级队列,在延时时间到期后将消息放入指定队列,在使用时需要注意内存使用情况。(https://streamnative.io/blog/release/2019-07-09-apache-pulsar-240)
具体实现源码如下所示:
当收到需要持久化的消息后,如果为延迟消息,进行如下处理,将消息加入优先级队列
public boolean trackDelayedDelivery(long ledgerId, long entryId, MessageMetadata msgMetadata) {
if (!topic.isDelayedDeliveryEnabled()) {
// If broker has the feature disabled, always deliver messages immediately
return false;
}
synchronized (this) {
if (!delayedDeliveryTracker.isPresent()) {
// Initialize the tracker the first time we need to use it
delayedDeliveryTracker = Optional
.of(topic.getBrokerService().getDelayedDeliveryTrackerFactory().newTracker(this));
}
delayedDeliveryTracker.get().resetTickTime(topic.getDelayedDeliveryTickTimeMillis());
return delayedDeliveryTracker.get().addMessage(ledgerId, entryId, msgMetadata.getDeliverAtTime());
}
}
addMessage实现如下
public boolean addMessage(long ledgerId, long entryId, long deliveryAt) {
long now = clock.millis();
if (log.isDebugEnabled()) {
log.debug("[{}] Add message {}:{} -- Delivery in {} ms ", dispatcher.getName(), ledgerId, entryId,
deliveryAt - now);
}
if (deliveryAt < (now + tickTimeMillis)) {
// It's already about time to deliver this message. We add the buffer of
// `tickTimeMillis` because messages can be extracted from the tracker
// slightly before the expiration time. We don't want the messages to
// go back into the delay tracker (for a brief amount of time) when we're
// trying to dispatch to the consumer.
return false;
}
priorityQueue.add(deliveryAt, ledgerId, entryId);
updateTimer();
return true;
}
取出消息,从优先级队列拿出
public Set<PositionImpl> getScheduledMessages(int maxMessages) {
int n = maxMessages;
Set<PositionImpl> positions = new TreeSet<>();
long now = clock.millis();
// Pick all the messages that will be ready within the tick time period.
// This is to avoid keeping rescheduling the timer for each message at
// very short delay
long cutoffTime = now + tickTimeMillis;
while (n > 0 && !priorityQueue.isEmpty()) {
long timestamp = priorityQueue.peekN1();
if (timestamp > cutoffTime) {
break;
}
long ledgerId = priorityQueue.peekN2();
long entryId = priorityQueue.peekN3();
positions.add(new PositionImpl(ledgerId, entryId));
priorityQueue.pop();
--n;
}
if (log.isDebugEnabled()) {
log.debug("[{}] Get scheduled messages - found {}", dispatcher.getName(), positions.size());
}
updateTimer();
return positions;
}
4.2 pulsar优先级队列使用
可以指定延时时长,也可以指定一个具体的消费时间。
producer.newMessage()
.deliverAfter(3L, TimeUnit.Minute)
.value(“Hello Pulsar after 3 minutes!”)
.send();
producer.newMessage()
.deliverAt(new Date(2019, 06, 27, 23, 00, 00).getTime())
.value(“Hello Pulsar at 11pm on 06/27/2019!”)
.send();
5 RabbitMQ消息队列实现延迟消息原理
RabbitMQ是大多数人都知道的一款支持延迟消息的中间件,这里简要说明一下该消息中间件实现延迟消息的原理。
- TTL:RabbitMQ允许用户对消息或者队列设置TTL(time to live),当设置队列的过期时间后,队列中所有的消息会有相同的过期时间,如果设置了消息的过期时间,只对单一消息有效,如果一个消息设置了过期时间,并发送到了一个设置了过期时间的队列,则对应的过期时间取两者的较小值。
- DLX: 全称Dead Letter Exchanges,死信交换机,何为死信,有如下几种情况:1.消费者否定了该消息 2. 消息由于设置TTL到期 3.消息队列超过了长度限制被丢弃。 当消息成为死信之后,可以通过死信交换机转发到一个死信队列。
在使用RabbitMQ延迟队列功能时,我们给消息设置TTL,当消息过期成为死信之后,转发至死信队列,消费者消费死信队列的消息。
各个方案的优缺点
- 使用JUC中的延迟队列
- 优点:简单,无需引入任何的中间件即可完成
- 缺点:可靠性低,需要自己实现持久化逻辑,内存占用问题
- redis实现延迟队列
- 优点:轻量级,可以持久化,可以使用分布式部署的redis
- 缺点:消费延迟由轮训速度决定,当消息过多会影响其他功能对redis的使用
- 时间轮算法实现延迟队列
- 优点:相对于每个任务开启定时,时间轮创造性的用一个线程解决了所有问题
- 缺点:时间轮只是一种算法,其他的问题,比如三高(高可用、高性能、高并发)中的高可用和高性能需要自己实现
- 消息中间件延时队列
- 优点:使用pulsar保证了消息的安全,可以比较容易的实现精确一次消费、至少一次消费、最多一次消费的需求,使用简单
- 缺点:可能有内存问题,而且比较重,RabbitMQ的需要进行额外配置,添加死信消息队列和DLX。但如果有中间件的基础设施,推荐中间件。