如何高效实现延时任务

1,545 阅读8分钟

背景

在业务场景中,有时需要对特定业务做延迟操作:达到一定时间后触发某种行为,在等待期间可以立即执行该行为,也可以取消定时,例如:

  1. 用户下单后15分钟未付款,发短信提醒用户进行付款操作
  2. 内容平台支持定时发布功能,在第二天早上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(草稿),并清空预期发布时间,则不会再被扫描到

优点

  1. 扫描范围小:在所有未发布的博客中,区分了草稿状态和定时发布中状态,给状态和发布时间这两个字段加联合索引,可以得到比较优秀的相应时间。一般来说处于定时发布中的博客比较少,因此每次扫描的范围也小
  2. 不会漏发布:由于是每1min扫描一次,扫描范围为过去5分钟,因此不会漏掉应该发布的博客,就算某次执行失败,下次扫描也能重试,正常来说每次扫描会把过去1min内应该发布的博客都发布
  3. 没有分布式一致性问题: 数据都在db中

缺点

  1. 若一次扫描出来的数据过多,且串行处理发布,可能影响后处理博客的发布实时性,但也有解决方案:

    1. 改为并行处理发布
    2. 缩短扫描间隔,降低每次扫描出来的数量
    3. 若进行了分表,还能启动多线程扫描多张表,提高并行度

redis zset

redis通过数据结构zset来实现延时任务,用score存储预期发布时间,member存储博客id

Key: schduled_publish

Member: blog_id

Score: timestamp:定时发文时间戳

注意这里不说将所有数据迁移到redis,而是只用redis来完成扫表工作,博客数据还是存在mysql

处理流程如下:

  1. 用户提交定时发布后,修改该博客在将博客的预期发布时间作为score,博客id作为member,放入zset

  2. 用定时任务定时查询zset中score分数最小的一批博客:

    1. ZRANGEBYSCORE schduled_publish now() - 5min now()
  3. 业务发布完每个博客后,在db修改该博客的状态,同时调用命令:ZREM schduled_publish blog_id 删除该博客

image.png

特殊需求支持:

  • 设置定时后,立即发布:发布后status改为3(已发布),同时从redis zset中删除该blog_id
  • 设置定时后,修改发布时间:正常修改发布时间,同时修改redis zset中该blog_id对应的score
  • 设置定时后,取消定时发布:将status改为1(草稿),同时从redis zset中删除该blog_id

优点

相比从扫mysql的表,扫redis zset的数据性能更好,因为是内存操作,且zset使用调表时间复杂度为O(logN)。性能越好,延时任务的实时性越高

缺点

  1. 同上一个方案,若一次扫描的数据过多,后处理的博客也有挤压问题,影响实时性

  2. redis大key问题:在定时发布中的blog较多时会出现

    1. 可以将redis分片,对所有分片并行轮询
  3. 引入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

其内部实现方案为:

  1. Broker在启动时,内部会创建一个内部主题:SCHEDULE_TOPIC,根据延迟level的个数,创建对应数量的队列
  2. Broker收到消息后,如果是延时消息,将真实的目标topic和queue放到该消息的属性中,将该消息转发到对应延迟level的队列
  3. 后台对每个队列启动一个定时任务,每隔100ms查一次对应的队列,如果队列中第一个任务还没到时间,就等待100ms后下一次执行。如果到期了,从该消息的属性中取出真实的topic和queue,将其投递到真正的队列中,这样消费者就进行消费。一直往后检查,直到遇到第一个没到期的消息,或检查完这批消息为止

可以看出,rocketmq内部也采用轮询实现延时消息

如果要延时的时间不在这18个级别怎么办?需要业务额外处理,例如需要要延时5小时:

  1. 先在db记录预期发布时间为5小时后,然后投递一个2h的延时消息
  2. 到期后消费,发现还有3小时才到期,再投递一个2h的延时消息
  3. 到期后消费,发现还剩1小时,投递1h的延时消息
  4. 到期后消费,发现已经到期,正常消费

总结下:每次在messageDelayLevel表中找到比自己的剩余过期时间小的最大的值,按这个级别进行投递

若用定时和延时消息 - 消息队列RocketMQ版 - 阿里云,可以支持支持40天内任意时刻的延时,无需业务额外处理。其他需要考虑的点如下:

  • 延时误差:秒级延时误差,开源版需要自己测试,阿里云版rocketmq为 1s~2s的延迟误差

  • 消费者考虑点:消息投递是 At Least Once,当发生主从切换(升级)及水平扩缩容时,可能会出现消息重复的情况,Consumer 必须幂等处理

    • 可通过blog表中的发布状态来保证幂等性,即在更新条件中加上 status = 2(定时发布中)

具体结合业务实现就比前两种方案简单,因为不用手动维护定时任务:

  1. 发布博客,修改db数据,同时往mq写入延时消息
  2. mq消费者能消息时,判断能够发布,如果能就发布,否则忽略该消息

什么情况下消费到消息,但博客不能发布呢?可能由以下特殊需求导致:

这里默认不支持取消延时任务功能(开源和阿里云版都不支持)

设置定时后,立即发布

  • 发布后status改为3(已发布),到时消费该blog的消息时,发现已经发布,则不做任何处理

设置定时后,修改发布时间

  • 正常修db中改发布时间,因为不支持取消消息,所以需要同时再发一条该blog的延时消息。这样可能有些消息到期后,并不代表该blog可以发布了
  • 例如之前生产延时消息,3h后发布,接着修改为5h后发布,那么当第一条3h的延时消息到期时,需要忽略
  • 根据什么判断是否需要忽略?需要判断当前时间是否等于db中该blog的预期发布时间,如果是则发布,不是则忽略该消息

设置定时后,取消定时发布

  • 将status改为1(草稿),消费时需要查db,如果状态不是“定时发布中”,也忽略该消息

image.png

优点

  1. 相比于前两种方案,用原生rocketmq实现延时消息最大的优点为无需自己维护一个定时任务,因为其内部已经维护了18个定时任务来扫队列

缺点

  1. 引入rocketmq,需保证更新db的同时写入mq成功,解决方案同方案二(一致性)

总结

本文介绍了常见的3中延时任务解决方案:

  • 若数据量较小,可以使用扫表,不会出现数据一致性问题,性能也很好。且没有引入其他组件,可用性最高
  • 若不想维护定时任务,且能容忍1~2s的误差,推荐使用rocketmq定时消息

参考文档:

  1. 深入理解RocketMQ延迟消息
  2. 开源版本的rocketmq
  3. 定时和延时消息 - 消息队列RocketMQ版 - 阿里云