订单超时关单的三条触发链路:RabbitMQ 队头阻塞、Redis ZSET 抢单与扫表兜底
订单超时关单常见三条触发链路:定时扫表、延迟消息、Redis ZSET。选型前先把到期时刻的存法定下来,再分别看各条链路在迟到、积压和时间语义上的边界。消息只负责提醒,数据库里的最新状态才是事实来源。
表结构:存 expire_at,不存 create_time + 30 分钟
很多实现是扫描时算 create_time last_id。禁止 OFFSET 深分页,几十万行后 OFFSET 500000 会把前面的行都读一遍。
每批处理完 sleep 20~50ms 让主库喘气;批量上限固定,不随积压放大。
多实例用调度器的分片广播(XXL-JOB 的 shardIndex/shardTotal),实例只处理 id % N == shardIndex 或表分片,避免抢同一批行。分布式锁按批次粒度会把整个任务串行化,量上来就拖。
扫描间隔决定最坏迟到:1 分钟扫一次、间隔 1 分钟,最坏 2 分钟才关。用户视角就是「31 分钟还能付款」。
RabbitMQ:TTL + DLX 的队头阻塞
思路是下单发一条消息到 delay queue,队列 x-message-ttl=30min,过期后被 dead-letter 到关单队列。
坑在 per-message TTL:在消息属性里设 expiration 时,RabbitMQ 只检查队头消息是否过期。队头是 30 分钟的单,后面 5 分钟的单即使已过期也不会被投递,要等队头先过期。混合超时时长(15min/30min/24h)时,延迟会退化到最长那个。
两种规避:每种超时时长一个队列,TTL 设在队列上,队头阻塞消失,但队列数量等于超时时长种类数;或换 x-delayed-message 插件。
插件是 rabbitmq_delayed_message_exchange,新 Exchange 类型 x-delayed-message,消息先存 mnesia,到点再投。代价是多一个插件运维项,延迟精度不保证秒级(官方口径是「尽力而为」),集群切换/节点重启时 mnesia 里的延迟消息要核对。
无论哪种,消息只是「提醒」,不能当事实来源。
RocketMQ
4.x:18 个固定延迟级别 setDelayTimeLevel(1..18),1s/5s/10s/30s/1m/2m/3m/4m/5m/6m/7m/8m/9m/10m/20m/30m/1h/2h——最高 2 小时,且只能落在这 18 个档位。30 分钟能对上第 16 级,但 45 分钟只能退化成 1 小时或自己拼两级。
5.x:定时消息支持任意时刻(timer wheel),具体上限与精度按版本核对,别照抄博客。
超过 2 小时的(7 天自动收货、30 天自动评价)不要塞延迟消息,用低频扫表。
Redis ZSET:把到期时间当 score
ZADD order:delay
取到期任务必须原子,否则多消费者重复取:
-- KEYS[1]=order:delay ARGV[1]=now
local items = redis.call('ZRANGEBYSCORE', KEYS[1], '-inf', ARGV[1], 'LIMIT', 0, 100)
if #items == 0 then return {} end
redis.call('ZREM', KEYS[1], unpack(items))
return items
ZRANGEBYSCORE 与 ZREM 分两次调用会出现 A 也取到、B 也取到,同一单被关两次,资源释放重复。Lua 内是单线程原子的。
ZREM 在前、业务处理在后面失败,任务会丢失,必须有扫表兜底。
替代方案:ZPOPMIN 只能按最小 score 弹一个,且不保证 score = expire_at` 是唯一安全做法。
代价是每条消息多一次主键查询,值得。
积压恢复会集中放量
MQ 挂 20 分钟再恢复,到期消息集中投递;Redis ZSET 的 Lua 一次最多取 100 条,消费端如果没限速会瞬间打满 DB 和关单接口。
消费端做令牌桶限速(比如 200 QPS),并给关单接口单独线程池/队列,避免和下单链路共用连接池。扫表兜底的批量也要在这个时刻降速,否则两路一起打。
双写:消息发不出去怎么办
「本地事务提交 + 发 MQ」不在同一事务里:先发消息后提交,回滚了会留下找不到订单的消息(消费端能挡,但脏);先提交后发消息,发失败就永远没人关单。
两种做法:
- 本地消息表 / outbox:同一事务写 order + outbox,后台轮询 outbox 补发,发成功打标记。
- RocketMQ 事务消息:half message + 本地事务回查。要求回查接口幂等、别查主库热点。
最简版:先提交事务,再发消息,发送失败只记日志,靠扫表兜底。中小系统这个取舍是合理的——兜底扫描本来就是必须的。
关单动作本身
关单用一条带条件的 UPDATE 做幂等抢占:
UPDATE orders SET status='CLOSED', closed_at=NOW()
WHERE order_no=? AND status='PENDING';
RowsAffected=1 才去释放库存/退券;=0 回查状态。释放库存要幂等:库存流水表按 (order_no, sku_id) 唯一,重复释放直接冲突返回,别用「先查有没有释放过再 UPDATE」。
关单与支付回调在超时临界点撞车的资金处理(ORDERPAID、迟到回调、T+1 单边账)不在这篇展开,站内已发的竞态篇里讲。
观测
必须有的三个数:
- 关单延迟分布:
closed_at - expire_at的 p50/p99。p99 超过一个扫描周期说明触发链路有病。 - 兜底扫表捞到的单量占比。占比从 0.1% 涨到 5%,说明延迟消息在丢。
- 关单时状态非 PENDING 的比例(抢单失败率)。太高说明重复触发。
告警阈值按业务定,但「扫表任务连续两个周期捞到 0 条」也要告警——正常待支付订单不会归零,可能是索引失效或任务没跑。
参考
- 阿里云开发者社区《订单超时自动取消怎么做才靠谱:延迟队列、定时扫描与幂等关单的取舍》https://developer.aliyun.com/article/1762231
- 博客园《支付超时自动关单 + 库存回滚 Java 实现方案》https://www.cnblogs.com/liuyi135354966/p/22012862
- RabbitMQ Delayed Message Plugin https://github.com/rabbitmq/rabbitmq-delayed-message-exchange