需求场景:
平时下单后未完成付款时,一般情况30分钟后订单仍未支付就会变更为已取消;如果让自己设计一下这个自动取消订单的功能,大家先自己想想如何实现呢?
思路1:定时任务
- 实现思路:写一个定时任务,以固定的频率遍历任务,将满足条件的任务取出并执行。如果任务量比较多,可以考虑任务分片和单个分片多线程处理;
- 优缺点:
- 优点:实现简单;
- 缺点:
- 时间精度不足:只能支持固定周期的定时触发,例如每分钟执行一次,这样会存在一分钟(某个任务当前任务执行时未到触发时间,任务执行完毕后刚好到触发执行时间,只能等待下次任务调度时才能执行)甚至更长(单个任务执行时间较长)的时间延迟;
- 数据库压力大:由于时间精度取决于扫表的频率,为了提高精度,需要增大扫表的频率,但是此时给数据库造成较大的访问压力;
- 支持的任务规模有限:当任务的规模较大时,在一个调度周期内,存在不能完成一遍对任务的扫描的风险,导致任务不能在规定的时间执行;
- 适用场景:
- 小规模,对时间精度无高要求的定时任务场景;
思路2:时间轮
- 实现思路:
- 时间轮,顾名思义就是一个类似钟表原理,一个包含n个元素的环形数组,数组的每个元素是一个slot,每一个slot是一个链表(类似hashmap),将每个待执行的任务放到slot中,以固定的时长t(每秒)顺序移动指针,移动到的位置从当前链表中取出待执行的任务,执行当前任务。
- 任务存储与执行逻辑:
- 定时任务的执行时间相对于当前时间延迟d时间单位(秒/分钟),任务存储在slot中的位置index=d%(n*t),如果同一个slot遇到冲突的任务,追加到当前slot上的链表(与hashmap类似)。
- 当延迟时间d大于时间轮一周所能表达的最大时长时,即index=d%(n*t)!=0,此时需要引入轮次的概念(可以理解为套圈),任务存储时轮次r=d/(n*t);
- 执行到对应的slot时,需要同时判断当前任务的轮次r=0?,如果是0,则立即执行,否则当前slot的轮次r=r-1等待下次调度时执行;
- 拓展:
- 当定时任务的延迟时间较大时,为了保证执行时间精度,需要n较大的时间轮,此时会占用较大的内存,但是待执行的任务可能时间分布比较稀疏。综合以上情况,时间轮出现了很多变种,例如kafka的延迟队列使用的分层时间轮,类似钟表的秒、分钟、时的层级关系,感兴趣的同学可以了解一下kafka的延迟队列实现思路。
- 优缺点:
- 优点:
- 简洁、高效。时间轮的逻辑思路理解成本较低,且任务存储在内存中,所有的操作都是在内存中,不依赖外部中间件。
- 扩展性好,当任务规模较大时,可以通过增加机器,通过请求负载的方式,实现扩容;
- 缺点:
- 任务丢失风险,所有任务都是在内存中,重启或宕机时,在时间轮中未执行的任务会丢失;
- 实现成本高:
- 当延迟时间较大时,就需要分层时间轮,实现成本高。
- 如果不能容忍任务丢失,需要自行实现补偿机制,也就会增加实现成本。
- 优点:
- 适用场景:
- 任务规模不限制,时间精度要求高,可以容忍一定的任务丢失场景。如果延迟的时间越长,任务出现丢失的可能性越大,机器被重启或出问题的概率会变大;
思路3:Redis失效通知机制
- 实现思路:redis具备key失效时通知的能力,通过设置key的失效时间,key失效时的消息通知来实现延迟消费;实现方案参考:
- 优缺点:
- 实现简单;
- 无延迟时间限制;
- 性能高,支持的规模取决于redis的能力;
- 稳定性,因为redis缓存失效存在懒性删除的场景,并且当cpu负载较高时,redis能否及时做到缓存失效,同时下发失效消息,此处待确定(如果有同学对redis缓存失效与通知机制比较了解,请评论指导一下,感谢);
- 适用场景:
- 任意规模、短时间的定时任务;
思路4:延迟消息队列
- 实现思路:
- 部分消息队列中间件提供了延时消息的能力,支持了一定时间内的消息延迟消费的能力。可以直接使用消息队列,实现延时执行任务。例如rocketMq/kafka等消息中间件;
- 优缺点:
- 延迟时间限制,不同消息中间件支持的延迟消费时间方式不同,例如rocketmq当前支持16个不同的延迟等级,且最大延迟时间是2小时,即不支持超过2个小时的延时消息。
- 实现方式简单,延时消息同普通消息基本无区别,只需要将执行的逻辑写在消费线程中即可。
- 性能高,支持的任务规模无限制,rocketmq(最大支持1000qps)、kafka基本满足大部分的任务规模,可以直接使用。如果任务规模超过1000qps此方案酌情考虑使用;
- 稳定可靠,专业的事情交给专业的人来做,不用担心任务丢失、时效的问题,且执行失败时,MQ天然的重试的能力,尽力保证至少被消费一次。
- 使用场景:
- 任意规模、短时间的定时任务。
思路5:改进的延迟队列
- 实现思路:
- 使用MQ的来实现定时执行的功能,已经相当完美,但是roketmq支持的延迟时间有上限,不能满足本次的业务诉求。所以,提出一种巧妙的“不断延时”思路:
- 接收到定时任务时,根据任务定时执行的时间,计算需要延迟的时间;
- 从rocketmq支持的延时级别从大到小遍历,直到找到找到第一个小于要延迟的级别,发送该延迟级别的延时消息,并将任务要执行的时间存到消息中;
- 延时消息的消费者,在接收到消息时,再次计算任务要延迟执行的时间:
- 如果延迟的时间小于等于0,则执行任务;
- 如果延迟的时间大于0,则再次从rocketmq支持的延时时间从大到小进行遍历,找到第一个小于延迟时间的延时级别,再次发送延迟消息;
- 重复以上步骤
- 举个例子,rocketmq支持的延时级别是[2h,1h,30m,20m,10m,9m,8m,7m,6m,5m,4m,3m,2m,1m,30s,10s,5s,1s],要设定一个延迟的时间是23小时15分钟的定时任务:
- 第一次发现23小时15分钟大于2小时,那么先发送一个延迟2小时的消息;
- 消费者接收都这消息后,发现需要再次延迟21小时15分钟,则再次发送一个2小时的延迟消息;
- 以此类推,最终会到一个1小时15分钟的消息,那么此时发送一个1小时的延迟消息;
- 再次消费到该任务时,需要延迟的时间是15分钟,则再次发送一个延迟10分钟的消息;
- 再次消费到该任务时,发送一个5分钟的延迟消息;
- 再次消费到该任务时,发现延迟时间为0,可以直接执行任务。
- 优缺点:
- 稳定可靠、支撑规模大;
- 任意时长的延迟;
- 实现相对简单;
- 对MQ有压力。一个任务从开始到最终执行,会生成多个消息,对MQ有一定的压力。
- 适用场景:
- 任意规模、任意时间精度、任意时长的定时任务。
- 使用MQ的来实现定时执行的功能,已经相当完美,但是roketmq支持的延迟时间有上限,不能满足本次的业务诉求。所以,提出一种巧妙的“不断延时”思路: