延迟任务:超时关单、定时提醒的三种实现

梦想恒久 5 阅读 技术沉淀

电商平台约定下单 30 分钟未支付自动关单、释放库存。第一版实现是订单服务里挂一个 JDK 的 ScheduledExecutorService,下单时提交一个 30 分钟后的任务,到期把订单置为已取消。上线两周撞上第一次发版:应用重启,内存里挂着的 500 个定时器全部丢失。

这篇按「不丢比准值钱的 B 端场景」写;准比不丢更要紧的场景不在这个范围。

这 500 单永远停在「待支付」,库存也没释放。客户运营凌晨盘库存时对不上账,第二天电话直接打到开发这边。

内存定时器的问题不是写错,是它把「到期要做的事」存在了进程里——进程一死任务就没了,崩溃实例上挂着的定时器没有别的实例接管。延迟任务(到某个时间点才执行的任务,超时关单、预约提醒、优惠券到期都算)的第一设计原则由此而来:任务清单必须离开进程,落在进程之外的地方。常见落点有三种:扫表、MQ 延迟消息、Redis ZSet,先建立一个判断框架再逐个看。

先分清要准时还是要不丢

三种实现的差异,压到底就是两个维度:精度(实际执行时间和约定时间差多少)和可靠性(进程重启、中间件故障之后任务还在不在)。业务对这两个维度的要求经常不一样,而且和直觉相反——多数 B 端场景里「不丢」比「准」值钱。超时关单晚关一分钟,没有客户投诉,库存多占一分钟而已;但关单任务丢了,库存永远不释放,财报和实物对不上。预约提醒早一分钟晚一分钟无所谓,漏发就是客诉。反过来,竞拍截价、秒杀开闸这一类才是真要秒级精度的,它们在 B 端系统里是少数。拿这两个维度给三种方案定位:扫表精度差(分钟级)但任务永远在库里;MQ 延迟消息精度到秒但依赖消息中间件的可靠性;Redis ZSet 精度好、速度快,但任务住在缓存里,丢了得自己补。

把任务记在表里,每分钟看一眼

扫表的写法和消息队列那一篇里的任务表一样,只是消费条件从「立即发」换成「到点才发」:

CREATE TABLE delay_task (
  id          BIGINT NOT NULL PRIMARY KEY,
  biz_type    VARCHAR(32) NOT NULL,  -- ORDER_TIMEOUT / APPT_REMIND
  biz_id      BIGINT NOT NULL,
  fire_at     DATETIME NOT NULL,     -- 期望执行时间
  status      VARCHAR(16) NOT NULL DEFAULT 'WAITING',
  updated_at  DATETIME NOT NULL,     -- RUNNING 卡死的回收依据
  executed_at DATETIME NULL,
  created_at  DATETIME NOT NULL,
  KEY idx_fire (status, fire_at)
);

表的结构和消息队列那一篇的 outbox_task 同一个形状,差别只在消费条件从「立即发」换成「到点才发」:扫描任务每分钟跑一次,捞 status=WAITING AND fire_at<=NOW() 的行,单批带 LIMIT(积压大时一次捞光会把库拖住),逐条处理捞到的行,biz_type 加 biz_id 指回业务单据,排障时靠它们。

扫表流程里有两个容易省略、省了就出事的动作:

  • 条件更新:抢任务要用条件更新,UPDATE delay_task SET status='RUNNING' WHERE id=? AND status='WAITING',受影响行数为一才算抢到——多实例部署时靠它防止同一任务被执行两遍。
  • 状态复核:执行前必须回头查业务状态,任务的语义是「30 分钟前下了单」,不是「这单现在还没付」,用户在第 29 分钟付了款,任务到点触发时订单已经是 PAID,直接关单就是把已付款的单关掉。正确动作是先查订单状态,仍待支付才执行关单,否则把任务置为 DONE 走人——任务触发和该不该执行是两码事,这句话值得写在 delay_task 表的注释里。

RUNNING 这个中间态要配超时回收,否则条件更新就成了新的单点:实例抢到任务、执行到一半崩了,行卡在 RUNNING,扫描只捞 WAITING,这单永远没人处理——开篇那种「订单停在待支付」的事故换个样子回来了。回收逻辑很简单:另一条低频任务把「RUNNING 且 updated_at 超过十分钟」的行置回 WAITING,让正常扫描重新捞起;连续回收超过 3 次标 FAILED(任务本身有毛病,重试无意义)进告警,防止毒任务无限循环。有了这一层,「任务永远在库里」的承诺才闭合。

先算精度账。扫描间隔 1 分钟,实际执行时间比 fire_at 最多晚一个间隔,30 分钟关单变成 30 到 31 分钟之间,库存多占的最坏情况就是这一分钟,没有客户会为这一分钟投诉。稳态下已到期未处理的行很少,idx_fire 上的一次范围查询毫秒级返回;真积压到百万行,那不是扫描的问题,是执行能力出了问题——按 fire_at 分桶(每天一张分区或按小时分片)再加消费实例。这个方案的组件成本是零——数据库本来就有,备份恢复现成,私有化客户环境里它是唯一不需要新装东西就能落地的选择。

延迟消息把精度提到秒,坑也埋在秒里

RocketMQ 和 RabbitMQ 都能做延迟投递,各自的坑形状不同。RocketMQ 4.x 只支持 18 个固定延迟等级——1s、5s、10s、30s、1m、2m、3m、4m、5m、6m、7m、8m、9m、10m、20m、30m、1h、2h,发送时指定等级而不是时间,30 分钟关单正好落在 30m 这一档,误差为零;要 25 分钟这种自定义时长,4.x 就只能挪档或改用 5.x。RocketMQ 5.x 换成了定时消息:任意指定未来时间点,默认精度 1 秒,延迟上限开源版默认一到三天(timerMaxDelaySec 参数可调),云上商业版最长 40 天——用开源版照抄商业版的 40 天口径,发送会直接失败。用了延迟消息,关单逻辑就搬到消费者里,消费前同样要先查订单状态——消息的语义是「30 分钟到了」,不是「该关单了」,这一条和扫表方案完全一致。

RabbitMQ 原生不支持延迟消息,常见做法是消息带 TTL(Time To Live,过期时间),过期后转发进死信交换机,消费者从死信队列接。落地时队列声明要带 x-dead-letter-exchange 参数指定转发目标;TTL 分两档,队列级 x-message-ttl 统一整队的过期时间,消息级 expiration 单条设置。这个组合有一个著名缺陷:队头阻塞。队列严格先进先出,队头那条 TTL 30 分钟的消息没过期,后面 TTL 5 分钟的消息就算早过期了也出不来——关单系统里先来一单 30 分钟关单、再来一单「5 分钟未支付提醒」,提醒永远迟到。解法是按延迟档位拆队列(每档一个队列,档内 TTL 一致),或者装 rabbitmq-delayed-message-exchange 插件(延迟交换机,按到期时间调度而不是排队)。拆队列要提前规划档位,插件要多维护一个组件,两边的账都要算进选型。

Redis ZSet 顺手,风险也要自己接

ZSet(Redis 的有序集合,按 score 排序的结构)做延迟任务很顺手,精度秒级,内存操作延迟极低,不挑中间件——很多系统 Redis 总是有的。用法三行:

# score = 到期时间戳(示例:当前时间加 30 分钟)
ZADD delay_queue <now+1800> order-timeout-10086
ZRANGEBYSCORE delay_queue 0 <now> LIMIT 100       # 轮询,每秒一次
ZREM delay_queue order-timeout-10086              # 删掉才算抢到,抢不到跳过

它的问题也全在「任务住在内存里」这一条:Redis 没配持久化或刚过 AOF 刷盘间隔,进程重启丢的就是真实任务;多实例同时轮询会拉到同一批 taskId,靠上面第三行的 ZREM 原子抢占。所以 ZSet 方案在工程上从来不单独存在,正经写法是「ZSet 做调度层,delay_task 表做台账」。补挂,指 Redis 丢过数据后把台账里到点未执行的任务重新挂回 ZSet,筛选条件是 status 仍是 WAITING 且 fire_at 已过期的行(执行中的任务不能补挂回去,会造成重复调度),补挂动作同样走条件更新。只想要 Redis 不想要表,那就要回答一个问题:Redis 丢数据的时候,谁替关单负责。

上线前对三个数

三种实现对照:扫表分钟级但任务永远在库里、MQ 延迟消息秒级但依赖中间件、ZSet 秒级但内存要台账兜底;共同的闸门是执行前先查业务状态

按业务容忍度选型,三个数从业务方拿到再动手:

  • 延迟误差:能接受的最大误差是多少,分钟级选扫表,秒级选延迟消息或 ZSet。
  • 丢失代价:丢一件任务的代价有多大,代价大于零就要台账,ZSet 必须配表。
  • 已有组件:客户环境里已经有什么中间件,有 Redis 没 MQ 时,扫表和「ZSet 加台账」都是零新增组件,选哪个看你要不要承担 ZSet 丢数据后自己补救的那套复杂度;两边都没有,扫表是唯一选择。

三个数都填完,方案通常只剩一个,剩下的工作是把它写对——抢任务的条件更新、RUNNING 超时回收、执行前的状态复核,这三处才是延迟任务的全部难点。


系列:相关 什么时候真的需要消息队列 | 订单状态机:别用 status 糊弄 | 技术纵深系列索引