你可能觉得订单超时自动取消是个特别简单的功能:下单时搞个定时器,到点了把订单状态一改,完事。我第一次接到这个需求也是这么想的,结果上线当晚就被现实教育了——应用一重启,几百个待支付订单直接没人管了。后来我在这个功能上反复优化了两年,才终于把它从单体应用里的一个“扫表任务”,逐步演进成能扛住大促流量、支撑分布式架构的完整方案。这篇文章就围绕SpringBoot商城里的订单30分钟自动取消,把单体到分布式这条路上的实现思路、踩坑记录和最终落地方案一次讲透。不管你是刚接触电商后端的新人,还是正在为订单关闭方案纠结的开发者,这里都有能直接参考的东西。
1. 订单超时取消的本质:一个“延迟+状态流转”问题
1.1 先拆解需求:取消订单不是改一个状态位
很多人接到“订单30分钟自动取消”的需求,第一反应是“到点把status改成已取消”。但如果真这么做,你会发现业务方第二天就来找你投诉:库存没释放、优惠券没退回、支付单还挂在支付渠道上,用户收到扣款但订单显示已取消,全乱套了。
取消一个待支付订单,本质是一连串的关联操作:
- 订单状态从“待支付”流转到“已取消”
- 回补被冻结的库存
- 退回下单时占用的优惠券/营销资格
- 如果用户已经跳转到支付渠道(比如收银台挂起了),还要尝试关闭支付单
- 通知用户订单已取消
所以它不是一个单点操作,而是一个需要保证最终一致性的业务动作。在设计技术方案之前,先把这条链路理清楚,否则后面选型容易南辕北辙。
1.2 数据模型与“到期判定”的核心
所有超时取消方案,最后都要回答一个问题:怎么判断一个订单“到期了”。订单表里有两个字段很关键:
create_time DATETIME -- 下单时间 expire_time DATETIME -- 到期时间,下单时就算好下单时直接算出expire_time = 当前时间 + 30分钟,而不是每次查询都用create_time < NOW() - INTERVAL 30 MINUTE去算。后者的写法不是不行,但一旦查询条件里混入状态过滤、索引设计又没跟上,很容易出现全表扫描。
expire_time落库之后,扫描条件就非常干净:
SELECT id, order_no, user_id FROM t_order WHERE status = 'NEW' AND expire_time < NOW() ORDER BY expire_time LIMIT 100;配合联合索引(status, expire_time),这个SQL可以走索引,扫描速度会快很多。到这里,基础的“扫哪些订单”才真正定下来。
1.3 为什么不能“下单后睡30分钟再取消”
最直觉的做法是下单成功后在代码里Thread.sleep(30 * 60 * 1000),睡醒后执行取消。这个方案能跑通Demo,但完全不能上生产:
- 线程被白白占住:一个订单占一个线程,假设每秒下单100笔,半小时后你就需要占着18万个线程,应用不崩才怪。
- 进程一重启,任务全丢:内存里的“睡眠中”任务随进程一起消失,没有任何持久化。
- 集群环境下每个节点都会执行:假如部署了3个实例,同一个订单可能被3个节点各自“睡醒”后取消一次。
所以“延迟取消”的正确姿势,一定是想方设法把延迟动作从业务线程里解耦出来,交给独立的调度机制去触发。下面就是这个解耦过程在单体、分布式两个阶段的演进路径。
2. 单体阶段的三种“自嗨方案”,以及我踩过的坑
2.1 Timer/ScheduledExecutorService:本地延迟任务只配在开发环境跑跑
单体阶段最简单的写法,就是下单后往ScheduledExecutorService里丢一个延迟任务:
// 反面教材:仅用于演示 ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(10); scheduler.schedule(() -> closeOrder(orderNo), 30, TimeUnit.MINUTES);当时我第一版就是抄的这种思路,单机测试一切正常。直到某次发布重启,运维告诉我“有十几个订单一直停在待支付没人处理”,我才意识到:这种任务不在任何存储里,JVM一停,任务就蒸发了。
再往后想,就算进程不重启,多实例部署时每个节点都会重复执行。除非再引入分布式锁,否则这种方案怎么补都不干净。它只适合开发环境联调,或者极少数“丢了也无所谓”的场景。
2.2 Spring @Scheduled定时扫表:单体场景下的“正解”
如果项目规模还处于单体阶段,日订单量几千到几万,最稳的方案反而是“老土”的定时扫表:一个@Scheduled方法,每隔固定时间扫一次过期订单,批量处理。
@Component @Slf4j public class OrderCloseScheduler { @Autowired private OrderCloseService orderCloseService; @Scheduled(fixedDelay = 30_000) public void scanExpireOrders() { List<Order> expiredOrders = orderCloseService.scanExpiredOrders(100); for (Order order : expiredOrders) { try { orderCloseService.closeOrder(order.getOrderNo()); } catch (Exception e) { log.error("关闭订单失败, orderNo={}", order.getOrderNo(), e); } } } }注意我用的是fixedDelay = 30_000,而不是cron。区别在于:fixedDelay是上一轮执行完再等30秒跑下一轮,不会出现上一轮还没跑完、下一轮又冲进来的情况;cron则是固定时刻触发,如果任务执行时间超过周期,就会产生任务堆积。
扫表为什么在单体阶段是正解?因为它天然持久化、天然可恢复。订单过期是写在数据库里的客观事实,无论应用重启多少次,只要扫表的逻辑还在,过期的订单迟早会被捞出来处理。这正是超时取消这类业务最需要的基本盘。
2.3 我踩过的坑:扫表SQL没走索引,直接拖垮了主库
单体方案本身没什么问题,问题出在SQL上。我当时图省事,扫表条件写成了这样:
-- 反面教材:函数包住索引列,索引直接失效 SELECT id FROM t_order WHERE status = 'NEW' AND DATE_FORMAT(create_time, '%Y-%m-%d %H:%i:%s') < DATE_SUB(NOW(), INTERVAL 30 MINUTE);DATE_FORMAT把create_time包了一层,MySQL的优化器没法走索引,只能全表扫。订单量小的时候感觉不到,等表里积累了几百万条历史订单,每次扫表都扫全表,主库CPU瞬间飙高,连正常的下单接口都被拖慢了。
后来我把表结构调整成了status + expire_time联合索引,SQL改成最朴素的写法,同样的数据量,单次扫描耗时从几秒降到了几十毫秒。这个优化被我发现纯属偶然:有一次大促复盘,DBA把慢查询日志拉出来,排在最前面的就是这条扫表SQL。
顺带还要说一个深分页的坑。用LIMIT offset, size分页扫表,越往后offset越大,MySQL需要先扫过前面大量行才能定位目标。正确做法是每次只取前N条,处理完删除或更新状态后,下一轮继续取前N条,靠查询条件的过滤天然形成分页,而不是靠offset硬跳。
3. 拆成分布式之后,为什么老方案集体翻车
3.1 集群节点都会执行@Scheduled,重复取消防不胜防
服务从单机部署改成多实例之后,第一个问题就是:@Scheduled在每个节点上都会执行。三个实例,同一张订单表,同一批过期订单,会被同时捞出来处理三次。
如果不做幂等,重复取消至少会造成以下后果:库存被重复回补、优惠券被退回两次、给用户发三条取消通知。哪怕你觉得自己代码里做了判断,只要状态更新和库存回补不在同一个数据库事务里,并发下照样有缝隙。
解决办法有两个方向:
- 用分布式锁保证同一时刻只有一个节点在扫表,比如 ShedLock,锁信息存数据库,简单可靠;
- 用“CAS式更新”让取消动作本身幂等(这个我在第5章细讲)。
ShedLock的用法很简单:
@Scheduled(fixedDelay = 30_000) @SchedulerLock( name = "closeOrderScanLock", lockAtMostFor = "PT5M", lockAtLeastFor = "PT30S" ) public void scanExpireOrders() { // 只有一个节点能进来 }lockAtLeastFor防止执行太快导致锁被立刻释放,lockAtMostFor防止任务异常 hanging 时锁被永久占住。
3.2 数据量上来之后,扫表的延迟和数据库压力都放大
订单量大了之后,定时扫表会面临一个两难:扫描间隔设短,数据库压力飙升;扫描间隔设长,订单实际取消时间可能从“30分钟”变成“30分+5分钟”,业务方会问“为什么用户等到35分钟才被取消?”。
而且订单表是持续增长的,哪怕有索引,每次捞一批过期订单,也要在“百万行级别”的范围里做索引查找。如果关单动作还要同步回补库存,单个事务里锁定的行会更多,主库的长事务和锁等待会变多。
有人会想用“分库分表”解决,但分库分表之后,扫表逻辑要改成按分片扫,复杂度立刻上一个台阶。这时候就需要引入专门的延迟消息组件,把“扫描”这个动作分散掉。
3.3 服务拆分之后,本地事务已经罩不住整条链路
单体时代,订单状态、库存、优惠券都在同一个库里,一个@Transactional就能把“关单+回补库存”包起来。微服务拆分之后,订单服务和库存服务各管各的库,本地事务彻底失效。
这时候典型做法是把库存回补这种操作做成“异步补偿”:关单主流程里,先更新订单状态,然后把“回补库存”这个动作写到一张本地任务表,后台任务逐条调用库存服务接口。库存服务自己保证幂等,即使重试多次也不能多回补。
这也意味着,超时取消方案从“单任务”变成了“一个主流程+N个补偿任务”,设计复杂度从代码层面转移到了架构层面。
3.4 并发竞态:支付和取消在赛跑
还有一类问题,和分布式本身关系不大,但分散式之后更容易暴露:用户在下单29分59秒时发起支付,支付回调到达和定时取消任务同时发生。两边同时更新订单状态,谁先谁后都合理,但不能出现“支付成功但订单已取消”这种前后矛盾。
解决这个竞态的核心,是带状态约束的原子更新,也就是CAS。下面的SQL就是整条链路的“安全闸门”:
UPDATE t_order SET status = 'CLOSED', close_time = NOW() WHERE order_no = ? AND status = 'NEW';只有返回影响行数为1,说明订单之前确实是“待支付”状态,并且这次更新真的把它变成了“已取消”。如果影响行数为0,说明订单已经被支付回调改成“已支付”,或者被其他任务改成了“已取消”,直接跳过,不需要做任何后续操作。
支付回调那边同理:
UPDATE t_order SET status = 'PAID', pay_time = NOW() WHERE order_no = ? AND status = 'NEW';两边都用CAS,数据库的行锁会保证同一时刻只有一个更新成功,另一个等待后拿到的是新状态,影响行数为0,不会出现双写覆盖的问题。
4. 四类主流延迟消息方案横评:Redis、MQ、时间轮
4.1 Redis过期监听:看起来很美,实际上别用它做关单
一到分布式阶段,很多人第一反应是“Redis不是有key过期事件吗?订单放进去,30分钟过期,触发回调取消订单”。这个方案在测试环境非常顺滑,但生产环境我建议直接排除。
原因很明确:
- 过期事件不一定准时:Redis的过期事件是靠“惰性删除+定期删除”触发的,如果内存一直不紧张,某些key可能过期很久才被清理和通知;
- 事件可能丢:Redis发布订阅模式没有确认机制,消费者断开期间产生的事件,订阅重连之后拿不回来;
- 不能作为业务正确性的依据:就算事件丢了,订单还是那个订单,库存还是那个库存,你不能因为“Redis没通知”就让用户白白等下去。
所以Redis过期监听可以做辅助提醒,但绝不要作为唯一触发器。
4.2 Redis ZSET延时队列:比过期监听靠谱,但本质还是“扫表”
比过期监听可靠一点的做法,是用Redis的ZSET做一个延时队列:score存“期望执行时间戳”,value存订单号。后台任务每隔几秒取一次score <= now的元素,处理关单。
// 入队:下单成功后执行 long executeAt = System.currentTimeMillis() + 30 * 60 * 1000; redis.zadd(ORDER_CLOSE_DELAY_QUEUE, executeAt, orderNo); // 消费:后台轮询执行 Set<String> orderNos = redis.zrangeByScore( ORDER_CLOSE_DELAY_QUEUE, 0, System.currentTimeMillis(), 0, 100 ); for (String orderNo : orderNos) { // zrem返回1才算抢到,防止多个消费者重复处理 if (redis.zrem(ORDER_CLOSE_DELAY_QUEUE, orderNo) == 1) { closeOrder(orderNo); } }这个方案最大的价值是“不用扫数据库的订单表”,而是扫Redis里的一个有序集合,数据库压力小很多。但它仍然受限于Redis的持久化配置:如果Redis宕机且没有开启AOF,队列里的延迟任务可能全部丢失。所以它更适合作为“减轻数据库扫描压力”的前置层,而不是最后的兜底。
4.3 RabbitMQ死信队列:经典方案,但别把它神化
RabbitMQ实现延迟消息有两种姿势,第一种就是让消息先进入一个“等待队列”,设置TTL为30分钟,TTL到期后消息变成死信,被路由到死信交换机,再转发给真正的业务消费队列。
配置开起来大概是这样的:
// 等待队列:30分钟TTL + 死信交换机 @Bean public Queue orderWaitQueue() { Map<String, Object> args = new HashMap<>(); args.put("x-dead-letter-exchange", ORDER_DEAD_EXCHANGE); args.put("x-dead-letter-routing-key", ORDER_CLOSE_ROUTING_KEY); args.put("x-message-ttl", 30 * 60 * 1000); return new Queue(ORDER_WAIT_QUEUE, true, false, false, args); }生产者把订单消息发到ORDER_WAIT_QUEUE,30分钟后消息过期,变成死信进入ORDER_DEAD_EXCHANGE,业务消费者从ORDER_CLOSE_QUEUE里拿到消息开始关单。
这个方案在很长一段时间里是业界常用做法,但我劝你别直接照搬,有两个坑:
- 队头阻塞:RabbitMQ的消息过期是基于消息在队列头部的时间判断的。如果第一、第二条消息的TTL是2小时,第三条消息的TTL是30分钟,那么第三条必须等前面两条到期后才能被处理。等你混入多种延迟时间,问题会非常明显。
- TTL重排困难:需求一旦变更,比如把30分钟改成15分钟,已经发出去的消息没法单独调整,只能等自然过期。
4.4 RabbitMQ延迟插件和RocketMQ延迟消息:生产环境更推荐
比死信队列舒服的是RabbitMQ延迟消息插件,它引入了一种新的交换机类型。消息发进去之后,不需要等“队头”依次检查,插件会按延迟时间给每条消息分别调度,到期后才会路由到真正的业务队列。
@Bean public CustomExchange orderDelayExchange() { Map<String, Object> args = new HashMap<>(); args.put("x-delayed-type", "direct"); return new CustomExchange(ORDER_DELAY_EXCHANGE, "x-delayed-message", true, false, args); }发送时只需在消息属性里带上延迟时间:
rabbitTemplate.convertAndSend( ORDER_DELAY_EXCHANGE, ORDER_CLOSE_ROUTING_KEY, orderNo, message -> { message.getMessageProperties().setDelay(30 * 60 * 1000); return message; } );配置上多装一个插件,但换来的是“每条消息独立调度”,队头阻塞问题直接消失。
如果团队用的是RocketMQ,那就更简单了。RocketMQ原生支持延迟消息,直接设置延迟级别即可。30分钟对应的是延迟级别16:
Message msg = new Message("ORDER_CLOSE_TOPIC", "", orderNo.getBytes()); msg.setDelayTimeLevel(16); // 16级 = 30分钟 producer.send(msg);这里要注意,RocketMQ的延迟级别是固定的,不能指定任意秒数。内置级别大致是:1s、5s、10s、30s、1m、2m、3m、4m、5m、6m、7m、8m、9m、10m、20m、30m、1h、2h。30分钟刚好有对应档位,所以很适合订单关单场景。万一业务方要求的是“25分钟”这种没有对应档位的,就得选更大一档再靠兜底扫表补误差。
4.5 Netty时间轮:单机内存版玩具,适合短超时场景
还有一派人喜欢直接用Netty的HashedWheelTimer做时间轮,号称“高性能延迟任务”。这东西原理不复杂,但它仍然是把任务放在内存里,进程一重启就消失,集群下还要自己处理“每个订单只被一个节点调度”的问题。
我的看法是:时间轮适合“长连接超时检测”“短会话清理”这类不需要持久化的场景。把它用在订单关单这种必须最终一致的业务上,属于给自己找麻烦。
4.6 方案横评表格
| 方案 | 延迟精度 | 可靠性 | 集群支持 | 运维成本 | 适用阶段 |
|---|---|---|---|---|---|
| @Scheduled扫表 | 取决于扫描周期 | 高,基于数据库 | 需配合分布式锁 | 低 | 单体/小规模 |
| Redis过期监听 | 低,不可控 | 低,事件会丢 | 支持 | 低 | 不推荐用于关单 |
| Redis ZSET延时队列 | 秒级 | 中,依赖Redis持久化 | 支持 | 中 | 可作为前置层 |
| RabbitMQ死信队列 | 较高,有队头阻塞风险 | 高 | 支持 | 中 | 经典但可被插件替代 |
| RabbitMQ延迟插件 | 高 | 高 | 支持 | 中高 | 推荐 |
| RocketMQ延迟消息 | 高(固定级别) | 高 | 支持 | 中高 | 推荐 |
| Netty时间轮 | 高 | 低,内存任务 | 不天然支持 | 低 | 非订单场景 |
5. 兜底才是王道:延迟触发+补偿扫表+状态机三件套
5.1 为什么不能只依赖MQ:三种丢消息的方式
很多人选完MQ方案就以为万事大吉,但任何一个在生产环境跑过订单系统的都会告诉你:消息中间件只能保证“大概率触发”,不能保证“一定正确处理”。以下三种情况,MQ方案都救不了:
- 消息投递到消费者,消费时抛了异常,重试几次之后还是失败,最后被确认丢弃;
- Broker集群在某个时间窗口内不可用,期间送达的消息丢失或延迟不可控;
- 消费者逻辑本身有bug,消息被“成功”消费但没有真正执行关单。
所以“发了延迟消息”不等于“订单最终会被取消”。最终的正确性,必须建立在“数据库里订单状态真实流转”这个事实上,而不是建立在“消息是否被消费”上。
5.2 三件套架构:各司其职,互相兜底
我在最终落地方案里,把整个功能拆成三层,每一层解决一个明确问题:
第一层:延迟触发层。负责“准时”发起关单尝试。选型上用RabbitMQ延迟插件或RocketMQ延迟消息。消息到达消费者时,订单并不一定会被取消,消费者只是“收到触发信号”,真正执行时要再判断一次订单是否过期。
@RabbitListener(queues = ORDER_CLOSE_QUEUE) public void onDelayedClose(String orderNo) { // 延迟消息只是触发信号,执行前还要再校验一次 orderCloseService.closeOrder(orderNo); }第二层:状态机与幂等层。所有入口的关单操作,都走同一个CAS更新。不管是延迟消息触发、定时扫表触发,还是用户打开页面时手动触发,都只能通过同一道闸门:
public boolean closeOrder(String orderNo) { int updated = orderMapper.casCloseOrder(orderNo); if (updated == 0) { // 已支付/已取消/不存在,直接返回 return false; } // 更新成功,执行后续释放库存等操作 try { stockService.releaseStock(orderNo); couponService.rollbackCoupon(orderNo); } catch (Exception e) { // 失败写入补偿表,异步重试 closeTaskService.createRetryTask(orderNo); } return true; }第三层:兜底扫表层。不管延迟消息有没有触发,定时任务始终在跑。每天晚上或者每5分钟,扫描一次status = 'NEW' AND expire_time < NOW()的订单,对漏网之鱼执行同样的关单逻辑。
@Scheduled(fixedDelay = 5 * 60 * 1000) @SchedulerLock(name = "closeOrderFallbackScan", lockAtMostFor = "PT10M", lockAtLeastFor = "PT1M") public void fallbackScan() { List<Order> expiredOrders = orderMapper.scanExpiredOrders(200); for (Order order : expiredOrders) { orderCloseService.closeOrder(order.getOrderNo()); } }这三层之间的关系是:延迟消息保证“大部分订单在30分钟左右被取消”,兜底扫表保证“即使消息全部丢失,订单也一定会在30分+5分钟以内被取消”。两者合起来,用户感知的超时时间最多比理论值多一个扫描周期,而且任何一层挂了,另一层都能接住。
5.3 失败重试:本地补偿表代替MQ重试
关单链路里有一步失败(比如库存服务超时),不能吞掉错误,也不能让MQ无限重试。我的做法是建一张业务补偿表,专门记录“关单成功的订单里,还有哪些关联动作没做完”。
CREATE TABLE t_order_close_task ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(64) NOT NULL, task_type VARCHAR(32) NOT NULL DEFAULT 'CLOSE_ORDER', status TINYINT NOT NULL DEFAULT 0 COMMENT '0待执行 1成功 2失败', retry_count INT NOT NULL DEFAULT 0, next_retry_time DATETIME NOT NULL, last_error TEXT, create_time DATETIME NOT NULL, update_time DATETIME NOT NULL, UNIQUE KEY uk_order_no (order_no) );关单主流程把订单状态CAS成功后,如果后续释放库存异常,就插入一条待重试任务。补偿扫描线程按next_retry_time轮询执行,成功后更新任务状态,失败就递增重试次数并计算下一次执行时间。重试达到上限后,任务状态置为失败并触发告警,人工介入。
这套做法的好处是:所有数据都在自己的数据库里,不依赖MQ的重试机制,排查问题的时候直接从补偿表看执行历史,比捞消息轨迹清爽得多。
5.4 对账:你的系统真的“取消得够及时”吗
三件套落地之后,还得有一个能直观反映方案好坏的对账指标。最简单也最有效的一条SQL:
SELECT DATE(close_time) AS close_day, AVG(TIMESTAMPDIFF(SECOND, expire_time, close_time)) AS avg_delay_sec, MAX(TIMESTAMPDIFF(SECOND, expire_time, close_time)) AS max_delay_sec FROM t_order WHERE status = 'CLOSED' AND close_time > NOW() - INTERVAL 7 DAY GROUP BY DATE(close_time);close_time - expire_time就是“订单实际取消时间”和“理论取消时间”的差值。正数代表延迟了多少秒,负数代表提前取消(那是大事故)。这个数值比你盯着任何中间件监控都更有业务说服力。理想情况下,绝大多数订单应该集中在0到5分钟之间——因为延迟消息那一层会准时触发,只有兜底扫表捞出来的订单才有额外延迟。
6. 生产环境里看不见的细节
6.1 时间基准:用数据库时间,而不是应用服务器时间
多实例部署之后,“应用服务器时间”根本不可信。某台机器时钟慢了2分钟,它判断“订单未过期”,另一台时钟正常,判断“已过期”,同一个订单在集群里可能得到两种结论。
正确做法是:所有和过期判断相关的SQL,都用数据库的NOW()做比较;expire_time在写入时也用数据库时间,不要让应用服务器自己System.currentTimeMillis()拼一个时间塞进去。NTP同步再可靠,也不如直接在数据库层面统一时间基准来得省心。
6.2 用户端懒检查:不要让用户看到“过期但未取消”的订单
定时任务周期再短,也存在空窗期:订单已经超过了expire_time,但兜底扫表还没跑到它。此时用户在“待支付”列表里还能看到这个订单,点进支付页甚至还能发起支付,然后支付永远失败,体验很差。
我的办法是在用户查看订单详情的接口里加一个“懒检查”:
public OrderVO getOrderDetail(String orderNo, Long userId) { Order order = orderMapper.selectByOrderNo(orderNo); if (order != null && "NEW".equals(order.getStatus()) && order.getExpireTime().isBefore(LocalDateTime.now())) { orderCloseService.closeOrder(orderNo); } return convertToVO(order); }等于把“取消判断”前置到了用户的每一次查看操作里。虽然不能解决所有并发问题,但能把“用户看到过期订单”的窗口缩到最小。需要注意的是,懒检查里也要走CAS关单逻辑,不能直接改状态,避免和支付回调打架。
6.3 操作顺序:先改状态,再释放库存
关单时“先改订单状态”还是“先回补库存”?我在文档里看到过不少先释放库存再改状态的写法,理由是“库存释放失败就不该把订单置为取消”。这个思路在单体事务里成立,在分布式里会出问题。
设想一下:先调用库存服务回补库存,成功,但接下来CAS更新订单状态失败(比如用户支付回调抢先改了状态)。库存已经回补了,订单却还是“已支付”,等于多释放了一次库存。
所以正确的顺序一定是:先CAS把订单置为“已取消”,成功了再释放库存。释放库存失败不要紧,补偿表会重试,重试到成功为止。订单状态是主数据,库存、优惠券这些关联资源是附属数据,附属数据可以迟到,但不能先行动。
6.4 大促期间的“关单风暴”要提前演练
大促时会有一种特殊现象:大量订单在同一秒到达30分钟超时,延迟消息同一时间被推给消费者,瞬时并发可能把下游库存服务打懵。
我一般会在消费者里加一个小的随机延迟,把“同时到达”打散成“0到30秒内陆续到达”:
// 消费端削峰:打散同时到达的关单请求 Thread.sleep(ThreadLocalRandom.current().nextLong(0, 30_000)); orderCloseService.closeOrder(orderNo);这当然会增加一点理论延迟,但换来的是下游系统不会在高峰期被瞬间冲垮。对“30分钟超时”这种业务来说,延迟30秒用户根本感知不到,而下游服务被打挂的影响就大得多了。
同理,兜底扫表的LIMIT不要设置得过大会把主库打满,200到500一批比较稳妥。如果订单量实在大,可以按订单号哈希分桶,每个桶有自己的扫描任务。
6.5 可观测性:四类指标,缺一不可
最后说说监控。做这个功能的人往往会盯着MQ控制台看“有没有消息堆积”,但只盯这个远远不够。我建议至少盯四类指标:
- 延迟队列积压量:MQ队列里待触发的消息数,积压飙升说明消息通道有问题;
- 兜底扫表命中量:每轮兜底扫表捞出来的订单数,这个数字如果长期偏高,说明延迟消息送达率有问题,需要排查;
- 补偿任务堆积量:
t_order_close_task里status=0的行数,堆积说明下游释放库存等操作一直失败; - 关单延迟分布:就是5.4里那个
close_time - expire_time的值,分位线(P50/P95)比平均值更敏感。
有一个指标要特别警惕:兜底扫表命中量长期为0。很多人会觉得“这是好事啊,说明消息很准时”,但更可能是你的兜底扫表SQL写错了,根本没捞到数据。没有兜底订单,并不总是好事,也可能是兜底链路本身悄悄失效了。我就在一个长尾项目里见过这种情况:延迟消息一直正常,兜底扫表的SQL因为一次字段迁移漏改了条件,系统一直在“裸奔”跑了一个多月,全靠延迟消息单腿支撑。
我个人的体会是,这个功能做到后期,真正难的已经不再是“怎么在30分钟后触发一个任务”,而是“怎么在组件故障、消息丢失、并发竞态同时发生的时候,仍然保证订单状态绝对正确”。把状态机、CAS、补偿扫表这套基础设施搭稳,延迟消息无论用Redis还是MQ都只是触发手段而已。所以选择技术方案之前,先想清楚你的业务能容忍多少延迟、能接受哪些失败,再决定要不要上重型组件;对大部分订单系统来说,延迟触发加兜底扫表这套组合拳,已经比任何花哨的单点方案都可靠了。