背景
项目涉及到了一个自动过单的问题:24小时后无人操作,自动通过什么的。所以,为了实现这个功能,决定采用延时队列。那么,如何实现一个延时队列呢?我去各博客进行了技术调研,整理了一下几种方法,供大家参考。如果有什么更加好的方法,也欢迎评论区讨论。
注意:本文只是常见的技术方案的讨论,大家选中方案以后,可以根据方案名去找开源的实现代码,这里就不提供代码了。
技术方案
基于redis的zset延时队列
- 原理:Redis中的ZSet是一个有序的Set,内部使用HashMap和跳表(SkipList)来保证数据的存储和有序。zset有一个score值,可以在添加数据的时候,使用zadd把score写成未来某个时刻的unix时间戳,然后按照时间大小进行排序,定时去查询redis的zset队列首部,即可查询到最早过期的数据,进行处理。以此完成延时逻辑。
底层:HashMap里放的是成员到score的映射,而跳跃表里存放的是所有的成员,排序依据是HashMap里存的score,使用跳跃表的结构可以获得比较高的查找效率,并且在实现上比较简单。 - 具体使用:消费者使用zrangeWithScores获取优先级最高的(最早开始的的)任务。注意,zrangeWithScores并不是取出来,只是看一下并不删除,类似于Queue的peek方法。程序对最早的这个消息进行验证,是否到达要运行的时间,如果是则执行,然后删除zset中的数据。如果不是,则继续等待。命令详情:
ZADD KEY_NAME SCORE1 VALUE1.. SCOREN VALUEN //加入数据
ZRANGEBYSCORE key min max [WITHSCORES] [LIMIT offset count] //按顺序读取数据
其中,min可以设置为0,max为当前时间戳,这样每次获取到的结果为到目前为止过期的所有任务。min,max代表的是score范围。在范围内的数据才会读取出来。
3. 优点:
- 实现简单:redis已经设计好了数据结构,保证了顺序
- 解耦:把任务、任务发起者、任务执行者的三者分开,逻辑更加清晰,程序强壮性提升,有利于任务发起者和执行者各自迭代,适合多人协作
- 异常恢复:由于使用Redis作为消息通道,消息都存储在Redis中。如果发送程序或者任务处理程序挂了,重启之后,还有重新处理数据的可能性。
- 分布式:如果数据量较大,程序执行时间比较长,我们可以针对任务发起者和任务执行者进行分布式部署。特别注意任务的执行者,也就是Redis的接收方需要考虑分布式锁的问题。
- 缺点:
- 由于zrangeWithScores 和 zrem是先后使用,所以有可能有并发问题,即两个线程或者两个进程都会拿到一样的一样的数据,然后重复执行,最后又都会删除。如果是单机多线程执行,或者分布式环境下,需要使用Redis事务,也可以使用由Redis实现的分布式锁
- 消费延迟由轮训速度决定,当消息过多会影响其他功能对redis的使用
- 参考:有赞延时队列实现方案
基于redis的过期回调实现延时队列
- 原理:Redis的key过期回调事件,也能达到延迟队列的效果,简单来说我们开启监听key是否过期的事件,一旦key过期会触发一个callback事件。
- 实现:修改redis.conf文件开启notify-keyspace-events Ex,然后消费延时任务的消费者,需要开启一个线程监听redis。java是可以通过spring注入Bean RedisMessageListenerContainer即可实现。
- 优点:
- 开发简单:redis自己已经实现了主要的功能
- 主动通知:不需要消费者自己去轮询
- 缺点:
- 返回的数据主要是key,没有value,需要设计好key来
- 多服务的情况下,一个key过期,通知所有开启监听者,通信性能浪费。
定时器轮询遍历数据库记录
- 原理:所有的订单或者所有的命令一般都会存储在数据库中,我们会起一个线程定时去扫数据库或者一个数据库定时Job,找到那些超时的数据,直接更新状态,或者拿出来执行一些操作。
- 优点:开发周期短,
- 缺点:
- 查找和更新对会占用很多时间,轮询频率高的话甚至会影响数据入库。
- 另一方面,对于订单这类数据,我们也许会遇到分库分表,那上述方案就会变得过于复杂,得不偿失。
基于时间轮TimeWheel实现
- 原理:底层为一个环形链表或者一个数组,每个节点表示一个时间,任务会以链表的形势挂在时间轮上面。每x秒转动一次,并且会遍历链表,执行挂在上面的到期任务。
原始时间轮:如下图一个轮子,有8个“槽”,可以代表未来的一个时间。如果以秒为单位,中间的指针每隔一秒钟转动到新的“槽”上面,就好像手表一样。如果当前指针指在1上面,我有一个任务需要4秒以后执行,那么这个执行的线程回调或者消息将会被放在5上。那如果需要在20秒之后执行怎么办,由于这个环形结构槽数只到8,如果要20秒,指针需要多转2圈。位置是在2圈之后的5上面(20 % 8 + 1)。这个圈数需要记录在槽中的数据结构里面。这个数据结构最重要的是两个指针,一个是触发任务的函数指针,另外一个是触发的总第几圈数。时间轮可以用简单的数组或者是环形链表来实现。 - 优点:
- 相比DelayQueue的数据结构,时间轮在算法复杂度上有一定优势。DelayQueue由于涉及到排序,需要调堆,插入和移除的复杂度是O(lgn),而时间轮在插入和移除的复杂度都是O(1)。
- 优点是我们可以使用一个线程监控很多的定时任务
- 缺点:
- 实现较为复杂,时间轮只是一种算法,其他的问题,比如三高(高可用、高性能、高并发)中的高可用和高性能需要自己实现
- 缺点是时间粒度由节点间隔确定,所有的任务的时间间隔需要以同样的粒度定义,比如时间间隔是1小时,则我们定义定时任务的单位就为小时,无法精确到分钟和秒。
基于RabbitMQ 消息队列的延时队列
- 原理:利用RabbitMQ 的TTL(消息存活时间)和DLX(死信转发)模拟出延时队列的效果。RabbitMQ可以针对Queue和Message设置 x-message-tt,来控制消息的生存时间,如果超时,则消息变为dead letter。然后,RabbitMQ的Queue可以配置x-dead-letter-exchange 和x-dead-letter-routing-key(可选)两个参数,如果队列内出现了dead letter,则按照这两个参数重新路由。这时候,利用DLX,当消息在一个队列中变成死信后,它能被重新publish到另一个Exchange。这时候消息就可以重新被消费。
言而言之:利用DLX,当消息在一个队列中变成死信后,它能被重新publish到另一个Exchange。这时候消息就可以重新被消费。 - 优点:使用消息队列保证了消息的安全,可以比较容易的实现精确一次消费、至少一次消费、最多一次消费的需求,使用简单。
- 缺点:
- 可能有内存问题,而且比较重
- RabbitMQ的需要进行额外配置,添加死信消息队列和DLX
基于kafka消息代理实现消息队列
- 原理:自己实现一个延时队列服务,接收到的延时消息都自己用时间轮之类的数据结构维护,当到达一定的时间后,再推送到消息队列中。订阅者即可进行活动。原理图如下:
- 参考博客:go延迟消息队列,有源码
基于Quartz的延时队列
- 原理: quartz是一个企业级的开源的任务调度框架,quartz内部使用TreeSet来保存Trigger。Java中的TreeSet是使用TreeMap实现,TreeMap是一个红黑树实现。红黑树的插入和删除复杂度都是logN。和最小堆相比各有千秋。最小堆插入比红黑树快,删除顶层节点比红黑树慢。
- 优点:
- 有专门的任务调度线程,和任务执行线程池。
- 缺点:quartz功能强大,主要是用来执行周期性的任务,当然也可以用来实现延迟任务。但是如果只是实现一个简单的基于内存的延时任务的话,quartz就稍显庞大。
go的timer定时器实现
- 原理:Go 在1.14版本之前是使用 64 个最小堆,运行时创建的所有计时器都会加入到最小堆中,每个处理器(P)创建的计时器会由对应的最小堆维护。
- 直接timer定时器延时:
//定时器time.Timer
//创建一个1秒后触发定时器
timer1 := time.NewTimer(time.Second * 1);
<-timer1.C;
fmt.Println("timer1 end");
//1秒后运行函数
time.AfterFunc(time.Second * 1, func() {
fmt.Println("wait 1 second");
});
time.Sleep(time.Second * 3);
//打点器time.Ticker
//创建一个打点器,在固定1秒内重复执行
ticker := time.NewTicker(time.Second);
num := 1;
for {
if num > 5 {
//大于5次关闭打点器
ticker.Stop();
break;
}
//否则从打点器中获取chan
select {
case <-ticker.C:
num++;
fmt.Println("1 second...");
}
}
基于DelayQueue的延时队列(java)
- 原理:
实现原理主要是利用了PriorityQueue这个类,内部是一个最小堆,满足一定条件的时候,会返回过期数据,让阻塞等待数据的线程继续执行。以此实现的延时功能。
之所以要用到PriorityQueue,主要是需要排序。也许后插入的消息需要比队列中的其他消息提前触发,那么这个后插入的消息就需要最先被消费者获取,这就需要排序功能。PriorityQueue内部使用最小堆来实现排序队列。队首的,最先被消费者拿到的就是最小的那个。使用最小堆让队列在数据量较大的时候比较有优势。使用最小堆来实现优先级队列主要是因为最小堆在插入和获取时,时间复杂度相对都比较好,都是O(logN)。 - 优点:开发较为简单,有现成的类使用,虽然是单机,但是也可以多线程生产和消费,提高效率。
- 缺点:
- java实现,单机任务
- 可靠性低,需要自己实现持久化逻辑,内存占用问题
- go的timer类,里面的after()延迟函数也是通过类似的方案实现的。
基于ScheduledExecutorService的延时队列
- 原理:
ScheduledExecutorService是JDK自带的一种线程池,它能调度一些命令在一段时间之后执行(延时队列),或者周期性的执行。
ScheduledExecutorService的实现类ScheduledThreadPoolExecutor提供了一种并行处理的模型,简化了线程的调度。DelayedWorkQueue是类似DelayQueue的实现,也是基于最小堆的、线程安全的数据结构,所以会有上例排序后输出的结果。
和直接使用delayQueue相比:一般来说,使用DelayQueue获取消息后触发事件都会实用多线程的方式执行,以保证其他事件能准时进行。而ScheduledThreadPoolExecutor就是对这个过程进行了封装,让大家更加方便的使用。同时在加强了部分功能,比如定时触发命令。 - 优点:单机情况下,其支持多线程,并且对于任务的多线程支持更加方便。
- 缺点:java实现,适用单机任务
自己实现延时队列的一些想法
- 延时队列,其实主要的面对主体是:任务发布者,任务执行者和任务存储区(延时队列)三者。其中任务发布者是延时任务的发布方,主要负责提供延时任务需要的数据。任务存储区其实就是延时任务队列,主要负责的是存储延时任务,还有任务到期后发起提醒,通知任务执行者处理任务。
- 那么面对三者,要实现一个高可用,高性能,高并发的延时队列。我们可以采用怎么样的实现方案呢?
- 首先是任务发布者,我们对其要求:
- 确保任务发送成功:只有得到延时队列写入成功的回复的时候,才进行下一步
- 确保延时任务执行者可以读懂发送到延时队列的消息。(消息的设计)
- 长时间没有延时队列写入成功后的提示,要有补偿机制。幂等性的延时任务,直接重新发送,非幂等性的任务,需要去查询消息写入情况进行判断。如果任务发布失败,则进行重试,否则告警:“消息队列写入任务,却未能正常返回结果。 ”
- 对于延时队列主要进行的任务是:任务的存储,监听和通知:
- 存储可以采用redis的zset,它可以保证时间有序性;也可以本地实现一个时间轮结构,这应该是最快,最准的方法;甚至写入数据库。
- 监听可以采用自己写一个轮询器,轮询redis;如果采用的是时间轮的方式,则设计定时器,每n秒转动一次时间轮,扫描待执行任务即可。
- 通知:任务到期后,可以通过http/rpc发送消息给开启了监听端口的任务执行者,收到了请求后,根据请求里的数据,任务执行者会完成对应的任务。也可以直接将任务信息放入kafka之类的消息队列,让任务执行者去订阅消息队列即可。本地时间轮的话,甚至可以直接本地调用自己的代码就好了。
- 对于任务执行者,主要的工作是:任务的执行,任务的删除(可能有)
- 任务的执行:将消息中传来的数据解析,执行对应任务即可。
- 任务的删除:如果延时队列的轮询者没有进行一个延时任务的删除,则可以放到这里进行删除,确保任务执行完毕。但是在集群多线程环境下,要考虑任务执行的幂等性,锁的上锁时间。
参考