背景
在业务场景中,有时需要对特定业务做延迟操作:达到一定时间后触发某种行为,在等待期间可以立即执行该行为,也可以取消定时,例如:
- 用户下单后15分钟未付款,发短信提醒用户进行付款操作
- 内容平台支持定时发布功能,在第二天早上8点准时发布
本文将以发布博客为例,介绍几种常见的延时任务处理方案,包括另外3个常见的配套需求:
- 延时没到就立即执行
- 修改延时时间
- 取消延时
博客表blog的关键字段如下:
| 字段名 | 含义 | 备注 |
|---|---|---|
| blog_id | 博客id | |
| title | 博客标题 | |
| ... | 省略其他字段 | |
| expect_publish_time | 预期发布时间 | |
| publish_time | 实际发布时间 | |
| status | 状态 | 1:草稿 2:定时发布中 3:已发布 |
扫表
最容易想到的思路就是扫表(例如mysql),用一个定时任务,每隔一段时间就扫描blog表,判断每条博客是否达到了触发条件,如果是就执行相应的发布动作
扫描条件:
status = 2:状态为定时发布中now() - 5min<=expect_publish_time<=now():预期在过去5分钟内发布的所有博客
扫描间隔:1min(视业务要求精度而定)
特殊需求支持:
- 设置定时后,立即发布:发布后status改为3(已发布),以后不会再被扫描到
- 设置定时后,修改发布时间:正常修改发布时间即可,无影响
- 设置定时后,取消定时发布:将status改为1(草稿),并清空预期发布时间,则不会再被扫描到
优点
- 扫描范围小:在所有未发布的博客中,区分了草稿状态和定时发布中状态,给状态和发布时间这两个字段加联合索引,可以得到比较优秀的相应时间。一般来说处于定时发布中的博客比较少,因此每次扫描的范围也小
- 不会漏发布:由于是每1min扫描一次,扫描范围为过去5分钟,因此不会漏掉应该发布的博客,就算某次执行失败,下次扫描也能重试,正常来说每次扫描会把过去1min内应该发布的博客都发布
- 没有分布式一致性问题: 数据都在db中
缺点
-
若一次扫描出来的数据过多,且串行处理发布,可能影响后处理博客的发布实时性,但也有解决方案:
- 改为并行处理发布
- 缩短扫描间隔,降低每次扫描出来的数量
- 若进行了分表,还能启动多线程扫描多张表,提高并行度
redis zset
redis通过数据结构zset来实现延时任务,用score存储预期发布时间,member存储博客id:
Key: schduled_publish
Member: blog_id
Score: timestamp:定时发文时间戳
注意这里不说将所有数据迁移到redis,而是只用redis来完成扫表工作,博客数据还是存在mysql
处理流程如下:
-
用户提交定时发布后,修改该博客在将博客的预期发布时间作为score,博客id作为member,放入zset
-
用定时任务定时查询zset中score分数最小的一批博客:
ZRANGEBYSCORE schduled_publish now() - 5min now()
-
业务发布完每个博客后,在db修改该博客的状态,同时调用命令:
ZREM schduled_publish blog_id删除该博客
特殊需求支持:
- 设置定时后,立即发布:发布后status改为3(已发布),同时从redis zset中删除该blog_id
- 设置定时后,修改发布时间:正常修改发布时间,同时修改redis zset中该blog_id对应的score
- 设置定时后,取消定时发布:将status改为1(草稿),同时从redis zset中删除该blog_id
优点
相比从扫mysql的表,扫redis zset的数据性能更好,因为是内存操作,且zset使用调表时间复杂度为O(logN)。性能越好,延时任务的实时性越高
缺点
-
同上一个方案,若一次扫描的数据过多,后处理的博客也有挤压问题,影响实时性
-
redis大key问题:在定时发布中的blog较多时会出现
- 可以将redis分片,对所有分片并行轮询
-
引入redis,需考虑数据同步到redis失败的情况,即分布式一致性。其实写入redis失败的情况非常少,可以简单地处理为:
- 同步重试:若达到重试次数上限返回定时发布失败,并回滚blog状态
- 异步重试:放到后台任务中不断重试,超过最大次数后报警,人工介入处理
rocketmq定时消息
开源版本的rocketmq支持18个级别的延时消息
messageDelayLevel=1s 5s 10s 30s 1m 2m 3m 4m 5m 6m 7m 8m 9m 10m 20m 30m 1h 2h
其内部实现方案为:
- Broker在启动时,内部会创建一个内部主题:
SCHEDULE_TOPIC,根据延迟level的个数,创建对应数量的队列 - Broker收到消息后,如果是延时消息,将真实的目标topic和queue放到该消息的属性中,将该消息转发到对应延迟level的队列
- 后台对每个队列启动一个定时任务,每隔100ms查一次对应的队列,如果队列中第一个任务还没到时间,就
等待100ms后下一次执行。如果到期了,从该消息的属性中取出真实的topic和queue,将其投递到真正的队列中,这样消费者就进行消费。一直往后检查,直到遇到第一个没到期的消息,或检查完这批消息为止
可以看出,rocketmq内部也采用轮询实现延时消息
如果要延时的时间不在这18个级别怎么办?需要业务额外处理,例如需要要延时5小时:
- 先在db记录预期发布时间为5小时后,然后投递一个2h的延时消息
- 到期后消费,发现还有3小时才到期,再投递一个2h的延时消息
- 到期后消费,发现还剩1小时,投递1h的延时消息
- 到期后消费,发现已经到期,正常消费
总结下:每次在messageDelayLevel表中找到比自己的剩余过期时间小的最大的值,按这个级别进行投递
若用定时和延时消息 - 消息队列RocketMQ版 - 阿里云,可以支持支持40天内任意时刻的延时,无需业务额外处理。其他需要考虑的点如下:
-
延时误差:秒级延时误差,开源版需要自己测试,阿里云版rocketmq为 1s~2s的延迟误差
-
消费者考虑点:消息投递是
At Least Once,当发生主从切换(升级)及水平扩缩容时,可能会出现消息重复的情况,Consumer 必须幂等处理- 可通过blog表中的发布状态来保证幂等性,即在更新条件中加上 status = 2(定时发布中)
具体结合业务实现就比前两种方案简单,因为不用手动维护定时任务:
- 发布博客,修改db数据,同时往mq写入延时消息
- mq消费者能消息时,判断能够发布,如果能就发布,否则忽略该消息
什么情况下消费到消息,但博客不能发布呢?可能由以下特殊需求导致:
这里默认不支持取消延时任务功能(开源和阿里云版都不支持)
设置定时后,立即发布
- 发布后status改为3(已发布),到时消费该blog的消息时,发现已经发布,则不做任何处理
设置定时后,修改发布时间
- 正常修db中改发布时间,因为不支持取消消息,所以需要同时再发一条该blog的延时消息。这样可能有些消息到期后,并不代表该blog可以发布了
- 例如之前生产延时消息,3h后发布,接着修改为5h后发布,那么当第一条3h的延时消息到期时,需要忽略
- 根据什么判断是否需要忽略?需要判断当前时间是否等于db中该blog的预期发布时间,如果是则发布,不是则忽略该消息
设置定时后,取消定时发布
- 将status改为1(草稿),消费时需要查db,如果状态不是“定时发布中”,也忽略该消息
优点
- 相比于前两种方案,用原生rocketmq实现延时消息最大的优点为无需自己维护一个定时任务,因为其内部已经维护了18个定时任务来扫队列
缺点
- 引入rocketmq,需保证更新db的同时写入mq成功,解决方案同方案二(一致性)
总结
本文介绍了常见的3中延时任务解决方案:
- 若数据量较小,可以使用扫表,不会出现数据一致性问题,性能也很好。且没有引入其他组件,可用性最高
- 若不想维护定时任务,且能容忍1~2s的误差,推荐使用rocketmq定时消息
参考文档: