连载中 9/20

延迟消息:订单超时关闭的优雅实现

2026-08-03 · 1719 阅读 · 0 评论 · 0 赞

最熟悉的陌生需求

「订单 30 分钟未支付自动关闭」,电商系统的人人都要写一遍的需求。看着简单,做起来坑多:怎么让一条记录在 30 分钟后被精准地「想起来」?这一篇把四种方案从土到洋排一遍,最后落笔在延迟消息上。

方案一:定时扫表

最直觉的实现:定时任务每分钟扫一次订单表:

-- 每分钟执行:找出超时未支付的订单
SELECT id FROM orders
WHERE status = 20 AND create_time < NOW() - INTERVAL 30 MINUTE
LIMIT 200;

能用,但三宗罪:一是扫描压力恒定存在,绝大多数订单早就支付了,每分钟都要为极少数超时单付一次查询;二是精度受限,扫表间隔 1 分钟,关单就可能晚 59 秒;三是数据量大了以后,这把扫帚会越来越沉,还得处理多实例分布式调度、分页深扫等各种麻烦。

方案二:JDK DelayQueue 或时间轮

进程内方案:把超时任务塞进 DelayQueue 或时间轮(Netty 的 HashedWheelTimer),到点自动出队。精度高、无扫描开销,但它是内存态的单机方案:进程重启任务全丢,多实例部署还得处理重复关单。只适合任务丢了无所谓的场景,关单显然不在此列。

方案三:延迟消息,把「定时」外包给 MQ

下单成功后,顺手发一条 30 分钟后投递的消息:

Message msg = new Message(ORDER_TIMEOUT_TOPIC, body);
// RocketMQ 4.x:固定级别,第 14 级 = 30m(1s 5s 10s 30s 1m 2m...2h 共 18 档)
msg.setDelayTimeLevel(14);
producer.send(msg);

// RocketMQ 5.x:任意时间戳,精确到秒,默认最长 3 天
msg.setDeliverTimeMs(System.currentTimeMillis() + 30 * 60 * 1000);

30 分钟后消费者收到消息,检查订单仍是未支付,执行关闭。扫表的压力没了,精度到秒,可靠性跟着 MQ 的存储走。Redis 系列讲过的 Redisson 延迟队列是同类思路的轻量版,但持久化与高可用都不如 MQ 的实现扎实。

延迟消息是怎么实现的

RocketMQ 4.x 的实现很朴素:延迟消息不进原 Topic,而是改头换面写进系统内部的 SCHEDULE_TOPIC_XXXX,按延迟级别分队列存放。Broker 里有个定时任务盯着这 18 个队列,发现队头消息到期了,再把消息「还」回原 Topic,消费者这时才看得见它。理解这个设计,你就明白为什么 4.x 只支持 18 个固定级别——内置定时任务按级别分桶,粒度做不了任意秒。

RabbitMQ 的经典做法是 TTL 加死信交换机:消息设 30 分钟过期,过期后转投死信交换机,消费者监听死信队列。它的坑在队头阻塞:同一队列里前一条没过期,后面已过期的也出不去(TTL 只检查队头),所以要按延迟档位拆多个队列,或直接用延迟消息插件。Kafka 没有内置延迟,需要自己用时间轮加本地存储造轮子,这也是业务团队少用 Kafka 做延迟场景的原因之一。

别忘了竞态:关单和支付在赛跑

第 29 分 59 秒,用户点了支付;第 30 分 01 秒,关单消息到了。两条路径在赛跑,处理不当就是「钱付了,单关了」的事故。解法还是状态机:

-- 关单方:只有待支付状态才允许关闭
int rows = orderMapper.closeOrder(orderId);  // AND status = 20
if (rows == 0) {
    return;  // 已支付,放弃关单
}
refundService.autoRefundIfPaid(orderId);  // 兜底:真竞态到了就退款

支付回调方同理:只有待支付才允许标记已支付,发现单已关闭就走退款。两把状态机的锁,让赛跑的输赢都有体面的结果。延迟消息只负责「准时想起」,竞态安全靠数据库状态机,这是两件必须分开设计的事

四方案对比

方案精度可靠性适用
定时扫表秒到分钟高(数据在库里)量小,容忍分钟级延迟
时间轮毫秒级低(内存态)单机内高频短延迟任务
MQ 延迟消息秒级高(随 MQ 持久化)订单超时、定时提醒等业务主流
TTL+死信秒级RabbitMQ 体系,注意队头阻塞

小结

延迟需求的正解是把「定时」外包给专业选手:MQ 的延迟消息兼顾精度与可靠性,扫表降级为兜底对账手段。再记一条铁律:延迟消息解决「准时」,状态机解决「竞态」,谁也别替谁值班

下一站的难度升一档:事务消息。「下单成功」和「发消息」要绑成原子操作,本地消息表之外,RocketMQ 的半消息机制是怎么做到的?

503

10 年全栈工程师 · 503咖啡馆主理人

#消息队列#延迟消息#订单超时#定时扫表#竞态

评论 (0)

相关推荐

连载中 12/20

排查四件套:jstack、jmap、jstat、jcmd 的实战分工

jstack 看线程在干什么,jmap 看堆里装了什么,jstat 看运行时在变什么,jcmd 是统一入口。四把刀各管一段,配合着用没有查不动的现场。

#jstack#jmap#jstat#jcmd#排查工具
2026-09-16 · 5 阅读 · 0 评论 · 0 赞
连载中 11/20

GC 日志:把回收过程翻译成人话

一行 GC 日志里塞着七种信息:谁触发的、收了哪、停了多久、活了哪些。加上 -Xlog 配置,再加上日志分析工具,GC 不再是只能盯监控曲线的黑盒。

#GC日志#Xlog#日志分析#GC监控#Full GC排查
2026-09-16 · 13 阅读 · 0 评论 · 0 赞
连载中 10/20

ZGC:亚毫秒停顿是怎么炼成的

百 G 大堆停顿不到一毫秒,靠的是把搬家全部挪到并发阶段——着色指针让引用自带状态,读屏障让搬运中的对象依然可访问。代价是吞吐与内存,收益是停顿与堆大小解耦。

#ZGC#着色指针#读屏障#亚毫秒停顿#分代ZGC
2026-09-15 · 8 阅读 · 0 评论 · 0 赞