利用Redis 和 Reactor模式 实现的支持高频重置操作的延时队列


目前业务上需要对设备信号中断进行监控,在信号中断达到一定时间后进行报警,属于延迟任务范畴。但与典型的延迟任务不同的是,需要 支持高频的延迟时间修改操作——当设备上传新的信号时,顺延延迟时间。项目使用的调度框架并不支持这一特性,故研究了常见的延迟任务解决方案,并基于Redis ZSET 和 Reactor模式实现了一支持高频重置操作的延时队列。

1. 概念

有别于定时任务的固定触发周期,延时任务通常是由一个事件触发,并在一定时间后,触发另一个事件。延时任务应用的业务场景很多:

  • 订单超时未支付、未被接单取消
  • 资源到期前提醒
  • 物联网设备的延时指令

2. 延迟任务的常见实现

2.1 扫表

将任务存储于数据库中,利用定时任务或者后台线程进行周期性地全表扫描,查询出满足延迟条件的任务并执行。扫表方式实现简单,可用性较高,无需考虑数据的持久化问题,失败的任务也能够被重试。其缺点主要在于:

  1. 扫表对数据库的压力较大,通常会选择扫描从库;
  2. 扫描的频率不能太高,不适用于实时性要求较高的场景;
  3. 伸缩性较差,依赖于数据库的分库分表。

2.2 MQ

对于支持延迟消息的消息引擎(延时并不是消息引擎的核心功能,例如Kafka就不支持延时消息),消息在一定时间之后,才会对消费者可见。基于该方案实现延迟任务具有吞吐量高、可灵活伸缩等优点,其缺点主要在于:

  1. 不支持延迟时间更新,对于已经存在的任务,无法改变其执行时间;
  2. 延迟时间的精度(RocketMQ支持固定的若干个Level)、配置的粒度(JMQ的延迟配置在主题级别)、实现方式依赖于具体的MQ框架,后期切换的难度较高,不够灵活;

2.3 时间轮

参见:https://blog.csdn.net/u013256816/article/details/80697456

2.4 JDK DelayQueue、ScheduledExecutorService

利用JDK自带的延迟队列、调度框架可以实现简单的单机版的、多线程的延迟任务处理框架。缺点是不适用分布式场景,对可靠性要求较高的场景还需考虑持久化问题。

3. 本文方案

上述常见的延迟任务实现方案中,不能提供在分布式场景下、对延迟任务进行高频的时间重置操作。

Redis的ZSET是一个有序集合数据结构,支持高效的重排序操作,同时提供复杂度较低的按键查询和按范围查询。满足延时队列的常见操作,并支持高频的时间重置操作。

此外,利用Reactor模式分离任务获取与任务执行逻辑(业务逻辑),也提供更好的实时性和性能表现。

3.1 ZSET 简介

Redis 有序集合(sorted set,zset)和集合一样也是string 类型元素的集合,不允许重复的元素。不同的是每个元素都会关联一个 double 类型的分数,通过分数来为集合中的成员进行从小到大的排序。有序集合的成员是唯一的,但分数(score)却可以重复。

ZSET的底层实现是dict 和 skipist,同时兼顾了按键查询和范围查询的时间复杂度,dict用于查询元素对应的分数,skiplist用于根据分数查询元素。。典型的操作时间复杂度如下:

  • ZADD:O(log(N))
  • ZRANGEBYSCORE:O(log(N)+M)
  • ZSCORE:O(1)

3.2 基本实现

Redis ZSET 延时队列的实现方式是:

  1. 将任务的ID作为member、执行时间作为score,ZADD添加进ZSET中;
  2. 用worker扫描ZSET,ZRANGEBYSCIRE取出score小于当前时间的任务并执行。

3.3 伸缩性

考虑到单个ZSET的吞吐量有限同时提高系统的伸缩性,对延迟队列进行了分片:根据任务ID HASH路由到对应的ZSET中。

系统进行伸缩时,对线上的影响越小越好。一般的解决思路是:尽量减少扩容时迁移的数据量,避免对全量数据进行rehash。常见的方案有:

  • 一致性哈希 + 虚拟槽:设置很大的虚拟槽数量,对虚拟槽数进行哈希取余,然后将槽分配给实际的节点。扩容时,只需将现有节点的部分槽迁移到新节点即可。
  • 2^n哈希槽数量:JDK8 HashMap的实现,每次扩容时均翻倍,这样现有元素要么留在原地,要么迁移到 原位置 + 原哈希槽数 位置。详见附录
3.3.1 无数据迁移的伸缩方案

在延时队列的应用场景下,扩容时完全可以做到无数据迁移,只需明确知道扩容前后数据的所处位置即可——防止在扩容前后,延迟时间重置,导致任务重复或提前执行。

初始队列数为2n个,使用翻倍扩容,保证队列数始终为2n。那么,对于同一个任务,扩容后的去处只有两种选择:原位置、原位置 + 原队列数。当 hash & 原队列数数 == 0时,任务留在原位置。

扩容期间,生产端按照新的队列数正常写入。

消费端,从原有的队列取出任务后,首先利用hash & 原队列数数 != 0判断任务是否会生产到新队列 ( 原位置 + 原队列数 ) 处:

  • 若是,则同步判断新队列是否已经存在了该任务新的执行时间:
    • 若存在,则当前任务已经无效,丢弃即可;
    • 若不存在,则说明当前任务没有重置执行时间,正常执行。
  • 若否,该任务始终只位于当前队列,正常执行任务。

消费端,从新的队列取出任务后,同步判断该任务是否还会存在于原有队列中,若是则删除,防止重复执行。因为,不同的队列消费速度不一致,新队列中任务的执行时间即使更晚,也有可能会被先执行。

Redis 的 ZSET支持了 O(1) 的按键查询操作,该方案在扩容期间并不会带来很高的额外负载。

3.2 组件化 高性能

延迟队列的消费端Worker倘若既要从队列中获取任务,同时也要执行任务,那么,这就耦合了延时队列的组件逻辑和业务逻辑,不利于复用,也有可能成为瓶颈,造成队列挤压。

参考Reactor模式,队列扫描Worker只扫描队列、分发任务,不处理具体的业务逻辑。具体实现上,设立Worker负责统一扫描队列,将任务按照类型封装成相应的事件,通过MQ传递,由业务系统消费并具体执行。

在处理队列的并发扫描上,可采用分布式锁的方式,也可采用一对一的方式(一个队列仅由一个Worker负责)。本文采用了后者,扫描Worker由现有调度框架的定时任务实现,每个队列只会由一个定时任务扫描。

4. 本文方案在信号中断监控上的应用

设备信号中断的业务场景特点是:

  1. 设备信号数据量较大,需要高频的修改任务执行时间。
  2. 设备多样性,未来不排除针对不同设备采用不同中断阈值的可能性。

任务生产端,在设备信号MQ的消费职责链中添加信号处理器,用于处理设备信号上传事件。将设备编码作为member、数据采集时间 + 最大中断时间 作为 score,根据设备编码HASH路由,添加到到对应的ZSET中。

最初方案,考虑了利用分片分散负载并预留伸缩的余地,但是没有实现无数据迁移的伸缩方案,伸缩成本很高,在第三版中添加了该特性。

在任务的消费端,对每个ZSET使用一个由调度框架定时任务实现的Worker进行扫描,根据score批量(上限20个)取出满足中断阈值的设备,并执行具体的业务逻辑——生成报警定时任务进行循环推送,在处理成功后,使用ZREMBYSCORE删除元素,实现至少一次的消费语义。在报警定时任务中,利用ZSCORE判断设备信号是否恢复、外部终止条件是否满足,以此判断是否终止定时任务。

后来,有越来越多的业务需要使用该延迟队列,将队列的扫描逻辑和业务逻辑耦合在一块儿,没法做到复用;复杂的业务逻辑,也有可能造成队列积压。在第二版中,参考了Reactor模式,统一使用一组Worker扫描队列,利用MQ分发任务。

整体架构图:

在这里插入图片描述

参考

https://juejin.im/post/5caf45b96fb9a0688b573d6c

附录

1. ZSET

ZSET 通常包含3个关键字操作:
key:ZSET的Key
member:存入ZSET的元素
score:元素的分数,排序的依据

ZADD
ZADD key score member [[score member] [score member] …]
添加一个或者多个元素到 指定的 key 中。如果该 key 中已经有了相同的 member,则更新该 member 的 score 值,并重排序。

ZINCRBY
ZINCRBY key increment member
为指定 key 的 member 的分数值 加 increment,其中 increment 代表数值,increment 可以是 负数,代表减去。如果 key 或者 member 不存在,代表 ZADD 操作。

ZRANGE
ZRANGE key start stop [WITHSCORES]
返回指定 key 的 指定下标的成员, start stop 代表下标区间。返回的结果默认按照分数从小到大排列,如果需要从大到小排列,需要是用 ZREVRANGE 命令;
start 和 stop 都以 0 开始,比如,0 为第一个成员,1 为第二个成员。可以用 -1 表示最后一个成员, -2 表示倒数第二个成员;
WITHSCORES 可以返回相关成员及其分数。

ZRANGEBYSCORE
ZRANGEBYSCORE key min max [WITHSCORES] [LIMIT offset count]
返回指定分数的成员。分数在 [min, max] 之间,返回的成员按照 分数 从小到大排列。

ZRANK
ZRANK key member
返回指定key 的成员排名,按照分数 从小到大排列,其中返回的排名是以 0 开始。

ZREM
ZREM key member [member …]
删除,在移除的过程中,如果 member 不存在,将被忽略。时间复杂度O(M*log(N)),N表示ZSET元素的个数,M表示操作元素的个数。

ZREMRANGEBYSCORE
ZREMRANGEBYSCORE key min max
删除分数在 [min, max] 之间的所有元素。时间复杂度O(log(N)+M)。

ZCARD
ZCARD key
返回 key 的成员个数,key不存在时,返回0。

ZSCORE
ZSCORE key member
返回指定 key 和 member 的 分数值。

2. ScheduledThreadPoolExecutor的实现

继承自ThreadPoolExecutor,使用DelayedWorkQueue作为任务队列,并将用户提交的任务封装成ScheduledFutureTask。

其执行逻辑:

  1. 线程池中线程从DelaydWorkQueue队列中获取到到达执行时间的task;
  2. 执行task
  3. 重新计算task的下次执行时间
  4. 将task重新放入队列
使用方法  
        ScheduledExecutorService service = Executors.newScheduledThreadPool(5);  
        # 周期执行,以任务开始时间计算间隔  
        service.scheduleAtFixedRate(command, initialDelay, period, unit);  
        # 周期执行,以任务结束时间计算间隔  
        service.scheduleWithFixedDelay(command, initialDelay, delay, unit);  
        # 只执行一次  
        service.schedule(callable, delay, unit);  
  • ScheduledFutureTaskScheduleFutureTask封装了用户提交的任务(Runnable对象)、延时(开始执行时间)以及周期信息,继承了FutureTask类。ScheduleFutureTask的run方法包含了task的执行与更新下次执行时间并放入队列的逻辑。
  • DelayedWorkQueue数组实现的小根堆、阻塞队列,插入、移除的时间复杂度为O(lgn),优先级:下次执行时间、相同时比较提交时间。内部使用ReentrantLock保证队列操作的并发安全,使用Condition实现阻塞。其获取逻辑(take、poll方法)实现了延迟执行任务的功能:(首先加锁,然后)
    1. 队列为空,调用Condition对象的await方法使得线程进入Waiting状态;
    2. 队列不为空,但第一个任务的执行时间未到,awaitNanos方法使得线程进入Timed Waiting状态;

3. JDK8 HashMap的扩容方案

因为哈希桶数组长度总是2的n次方,所以与运算相当于取模运算。每次扩容倍数为2,所以元素的位置要么是在原位置,要么是在原位置再移动2次幂的位置。因为,与运算操作数2n-1较n-1只是在高位添了个1(低位全为1),所以相与后的差别仅跟hash值在此位置的取值有关,若为0,则不变;若为1,则增大n(odlCap)。该操作通过e.hash & oldCap == 0实现。

比如:

hash1与hash2是hashCode处理后的值(异或)。a为扩容前,n=16.b为扩容后,n=32。

在这里插入图片描述

元素在重新计算索引后,因为n变为2倍,所以与运算是n-1的mask仅在高位多了1bit,新的index变化为:

在这里插入图片描述

只跟hash在新增bit位上的取值有关,所以,不需要再重新与运算计算索引。

  • 2
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值