目录
背景
购买火车票、淘宝购物....这些场景,在支付的时候,都涉及了订单过期自动取消的场景(30分钟未支付,订单自动取消)。 这就像,创建订单的时候,定了一个30分钟后的闹钟;如果30分钟内支付了,则把闹钟取消;如果超过30分钟未支付,则闹钟触发,取消未支付的订单。 除了支付,在其他一些场景,也会有一次性定时任务的需求,如果一些不太重要的push提醒。 改服务就是一个支持这些定时场景的“闹钟服务”。
可选方案
延时任务常见的方案:Redis Zset,RabbitMq延迟队列、timeWheel、数据库 & 预加载
RabbitMQ的延迟队列
依靠 RabbitMQ 的TTL以及死信队列功能,来实现延迟队列的效果。
把需要延迟的消息,将 TTL 设置为其延迟时间,投递到 RabbitMQ 的普通队列中,一直不去消费它,那么经过 TTL 的时间后,消息就会自动被投递到死信队列,消费者接收到死信队列的消息,即闹钟响铃。
由于我们的业务场景每次创建闹钟的响铃时延都不一样,因此需要对每条消息而非队列设置TTL ,使用这种方式设置的 TTL,消息可能不会按时死亡,因为 RabbitMQ 只会检查第一个消息是否过期。比如这种情况:
第一个消息设置了 20s 的 TTL,第二个消息设置了 10s 的 TTL,那么 RabbitMQ 会等到第一个消息过期之后,才会让第二个消息过期。
要解决这个问题需要安装特殊的插件。除此之外,数据也都储存在RabbitMq服务器,因此不采用。
Redis ZSet
ZSet 中每个元素都有一个对应 Score,ZSet 中所有元素是按照其 Score 进行排序的,可以通过如下方案。
- 入队操作:
ZADD KEY timestamp task
, 我们将需要处理的任务,按其需要延迟处理时间作为 Score 加入到 ZSet 中。Redis 的 ZAdd 的时间复杂度是O(logN)
,N
是 ZSet 中元素个数,因此我们能相对比较高效的进行入队操作。 - 起一个进程定时(比如每隔一秒)通过
ZREANGEBYSCORE
方法查询 ZSet 中 Score 最小的元素,具体操作为:ZRANGEBYSCORE KEY -inf +inf limit 0 1 WITHSCORES
。查询结果有两种情况:
a. 查询出的分数小于等于当前时间戳,说明到这个任务需要执行的时间了,则去异步处理该任务;
b. 查询出的分数大于当前时间戳,由于刚刚的查询操作取出来的是分数最小的元素,所以说明 ZSet 中所有的任务都还没有到需要执行的时间,则休眠一秒后继续查询;同样的,ZRANGEBYSCORE
操作的时间复杂度为O(logN + M)
,其中N
为 ZSet 中元素个数,M
为查询的元素个数,因此定时查询操作也是比较高效的。
缺点是使用Redis作持久化,有数据丢失的可能性。而且这种方案也是单进程的,部署多台服务器也只是利用Zookeeper 选主进行处理,防止leader宕机的情况。
timeWheel
时间轮是一个存储延迟消息的环形队列,其底层采用数组实现,可以高效循环遍历。这个环形队列中的每个元素对应一个延迟任务列表,这个列表是一个双向环形链表,链表中每一项都代表一个需要执行的延迟任务。
workerThread:单线程用于处理所有的定时任务,它会在每个tick执行一个bucket中所有的定时任务,以及一些其他的操作。
wheel:个时间轮,其实就是一个环形数组,数组中的每个元素代表的就是未来的某些时间片段上需要执行的定时任务的集合。 这里需要注意的就是不是某个时间而是某些时间。因为比方说我时间轮上的大小是10,时间间隔是1s,那么我1s和11s的要执行的定时任务都会在index为1的格子上。
tick:工作线程当前运行的tick数,每一个tick代表worker线程当前的一次工作时间
hash:在时间轮上的hash函数。默认是tick%bucket的数量,即将某个时间节点映射到了时间轮上的某个唯一的格子上。
bucket:时间轮上的一个格子,它维护的是一个Timeout的双向链表,保存的是这个哈希到这个格子上的所有Timeout任务。
timeout:代表一个定时任务,其中记录了自己的deadline,运行逻辑以及在bucket中需要呆满的圈数,比方说之前1s和11s的例子, 他们对应的timeout中圈数就应该是0和1。 这样当遍历一个bucket中所有的timeout的时候,只要圈数为0说明就应该被执行,而其他情况就把圈数-1就好。
时间轮算法本身是一种实现延迟队列的巧妙高效的算法,但是对我们的场景而言并非最优选,可能对于单机服务更加合适。
数据库 & 预加载
采用Mysql来存储闹钟信息,表结构如下:
CREATE TABLE `clock` (
`id` bigint(20) unsigned NOT NULL AUTO_INCREMENT COMMENT 'ID',
`application` varchar(200) DEFAULT NULL COMMENT '调用者应用名',
`business_id` varchar(50) DEFAULT NULL COMMENT '业务id';
`payload` varchar(500) NOT NULL COMMENT '回调内容',
`target_ring_at` bigint(20) NOT NULL COMMENT '目标的闹钟时间',
`latest_ring_at` bigint(20) DEFAULT NULL COMMENT '最晚的闹钟时间,超过这个时间就不回调了。未空表示没有限制,不管多晚都要回调',
`next_try_time` bigint(20) NOT NULL COMMENT '下一次尝试时间',
`retry_times` int(10) DEFAULT '0' COMMENT '尝试次数';
`create_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '修改时间',
PRIMARY KEY (`id`),
KEY `idx_next_try_time` (`next_try_time`),
UNIQUE `unk_business_id_application` (`business_id`, `application`)
) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb4 COMMENT='闹钟信息';
application和business_id建立了唯一索引,起到幂等的作用。
也可以通过application和business_id来删除闹钟。
使用一个“预加载线程”,将最近N秒即将到期的闹钟任务加载到内存中。查询任务通过next_try_time来过滤:
select * from clock where next_try_time<=1618387259000 limit 0, 20;
使用一个“响铃线程”,将内存中,已经到响铃时间的闹钟回调出去。回调通过RabbitMQ的一个direct的类型的exchange来实现。
为什么采用RabbitMQ来回调呢?
1、routingKey的机制,很适合过滤,routingKey采用定闹钟的application很方便实现各个应用之间的消息隔离。
2、解耦,“响铃线程”不应该做耗时过长的事情,不然在量大的时候,会导致回调整体延迟。
项目地址
https://gitee.com/zidongxiangxi/clock
后续优化
1、由于有了预加载的步骤,有可能预加载的数据特别大,会把内存撑爆的情况,因此控制了内存最大的闹钟数量,如果量短时间内响铃闹钟特别多的话可能会导致闹钟延迟,这个目前的解决方案是增加物理机。
2、目前方案的数据加载过程其实的串行的,如果处理成并行加载会更好、
3、加载闹钟的过程会经常去更新next_try_time这个字段的值,很可能会经常导致索引重建。-针对1、2的问题,可以尝试分片并行加载的方案,并且尝试舍弃next_try_time这个字段。
4、现在的方案,适合闹钟任务一定不能丢;闹钟定的周期长,存在大量冷数据。对于大量短时间内触发的定时场景,可能采用Redis的ZSet更合适。后续可能把clock-server改造成支持:Mysql+预加载和ZSet两种模式。