最熟悉的陌生需求
「订单 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 的半消息机制是怎么做到的?
评论 (0)