连载中 9/20

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

2026-08-03 · 1723 阅读 · 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)

热门推荐

连载中 11/22

主从搭建实操:从零配出一主两从

光讲原理不过瘾?手把手搭一主两从:my.cnf 六个参数、复制账号、GTID、CHANGE REPLICATION SOURCE TO、SHOW REPLICA STATUS 验收,附翻车排查清单。

#MySQL#主从复制#GTID#主从搭建#高可用
2026-05-07 · 10101 阅读 · 0 评论 · 0 赞
连载中 16/22

连接池:HikariCP 参数与连接风暴

连接池不是越大越好:8 核机器配 1000 连接反而更慢的数学原理,HikariCP 四个必调参数,maxLifetime 与 wait_timeout 的隐形陷阱。

#MySQL#连接池#HikariCP#maxLifetime#连接风暴
2026-05-10 · 9873 阅读 · 0 评论 · 0 赞
连载中 4/16

缓存穿透:恶意 ID 打穿 MySQL 的四道防线

请求的数据在缓存和数据库里都不存在时,缓存形同虚设。聊聊参数校验、空值缓存、布隆过滤器、限流熔断四道防线的原理与组合打法。

#Redis#缓存穿透#布隆过滤器#高可用
2026-05-16 · 9294 阅读 · 21 评论 · 287 赞