Article / 并发与一致性
订单超时之后
RocketMQ 延迟消息的分级重投、re-arm 策略与取消链路上的状态机竞态。
座位锁回答的是「此刻谁能买」;十五分钟一到,还需要有人把座位还回去。清理动作和支付、用户取消三条链路撞在一起时,怎么保证只有一个人说了算,是订单超时链路的主题。
为什么是延迟消息
最笨也最准确的方案是内存定时器:下单时 schedule(task, 15min)。问题是进程重启任务就没了——座位永远卡在锁定状态,直到对账把它扫出来。定时扫表好一些:每分钟查一次 expire_time < now() 的待支付订单。但精度受扫描间隔限制,订单量上来后每次全表扫描都是白白消耗,而且「过期」这个事件本身变成了「下次扫描」的近似。
SeatFlow 选择把时钟交给消息队列:下单成功后发一条延迟消息,broker 到点投递,消费者决定该不该取消。RocketMQ 支持延迟消息,但它的延迟是分级的——默认十八个固定级别(1s、5s、10s、30s、1m 直到 2h),不支持任意秒数。十五分钟落在 10 分钟与 20 分钟两级之间,于是有了两种朴素选择,都不对:
- 取 20 分钟级:座位晚还五分钟,商业上不可接受;
- 取 10 分钟级:消息早到,检查发现订单还没到期——如果就这么结束,座位永远等不到下一次清理。
正确的做法是让消费者做一次重投(re-arm):收到消息时先看订单是否到期,没到期就按剩余时长再发一条。选择级别的规则是「取不超过剩余时长的最大级别」:
public static int levelFor(Duration remaining) {
if (remaining == null || remaining.compareTo(LEVELS[0]) < 0) {
return 1; // 不足 1s,按最短级投递
}
for (int i = LEVELS.length - 1; i >= 0; i--) {
if (remaining.compareTo(LEVELS[i]) >= 0) {
return i + 1;
}
}
return 1;
}
十五分钟会走两跳:首次发 10 分钟级,到期时剩余约 5 分钟,re-arm 发 5 分钟级,随后到点取消。这个链条必然收敛:每一跳选择的延迟不会超过剩余时长、也不小于 1 秒,剩余时间严格变小,最终跌进「已到期」分支。计划阶段的一个测试用例把 3 秒期望成 5 秒级(第 2 级),但 5 大于 3——消息会晚于截止时间到达。实现按规则取了 1 秒级,测试随规则修正。
发送时机:提交之后,失败不阻塞
延迟消息在数据库事务提交之后发出,注册的是 afterCommit 回调:
private void registerTimeoutMessage(String orderNo) {
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
@Override
public void afterCommit() {
try {
timeoutMessageSender.send(orderNo, expireDuration);
} catch (Exception e) {
log.error("timeout message send failed, reconcile will recover: {}", orderNo, e);
}
}
});
}
两个决定都在这一小段代码里。其一,事务回滚时消息不会发出——否则会出现「消息到了、订单没提交」的幽灵取消。其二,发送失败不抛给用户:下单本身已经成功,MQ 抖动不应该让用户重新下单;这里只记错误日志,兜底交给对账——对账 #1 专门扫描「已过期仍待支付」的订单,把它们取消掉。也就是说,消息允许丢,座位不允许漏还,漏还的部分由对账按五分钟一轮的节奏捡回来。
消费侧:薄壳与决策
消费者本身很薄:恢复 traceId、记录消费耗时指标、清理 MDC,然后把订单号交给处理器。真正的决策在 OrderTimeoutHandler:
public void handle(String orderNo) {
OrderInfo order = orderInfoMapper.selectByOrderNo(orderNo);
if (order == null || !Objects.equals(order.getStatus(), OrderService.STATUS_PENDING)) {
return; // 不存在或已决出结果,忽略
}
LocalDateTime now = LocalDateTime.now();
if (order.getExpireTime() != null && now.isBefore(order.getExpireTime())) {
timeoutMessageSender.send(orderNo, Duration.between(now, order.getExpireTime()));
return; // 未到期,重投剩余时长
}
orderCancelService.cancelAndRelease(orderNo, "超时未支付");
}
延迟消息的投递语义是至少一次:broker 重试、消费者 rebalance、网络重投,都可能让同一条消息到达两次。处理器没有去重表,幂等完全由状态机承担——订单不是待支付状态就直接忽略,重复投递的第二条消息找不到可以做的事。同一个处理器同时服务三个入口:延迟消息、用户主动取消、对账扫描,行为完全一致。
取消的事务边界:两个类是有意的
取消动作本身有两件事:改数据库(订单状态、删除座位占用行)和改 Redis(座位释放、限购计数回退)。它们的边界是:
@Service
public class OrderCancelTxService {
@Transactional
public CancelResult cancelPending(String orderNo, String reason) {
int changed = orderInfoMapper.cancelPending(orderNo, reason); // CAS: 待支付→已取消
if (changed == 0) {
return new CancelResult(false, List.of()); // 已被支付/取消,幂等退出
}
// ... 查出该订单的座位,删除 order_seat 行,返回座位列表
}
}
@Service
public class OrderCancelService {
public CancelResult cancelAndRelease(String orderNo, String reason) {
CancelResult result = orderCancelTxService.cancelPending(orderNo, reason);
if (!result.cancelled()) {
return result;
}
try {
seatStateService.releaseSeats(order.getShowId(), order.getUserId(), result.seatIds());
} catch (RuntimeException e) {
log.error("seat release failed after cancel, reconcile will recover: {}", orderNo, e);
}
return result;
}
}
拆成两个类不是风格偏好,而是 Spring 的硬性约束:同类内部自调用不走代理,@Transactional 会静默失效。如果 cancelAndRelease 直接调用同一个类里的 cancelPending,CAS 与删行就不在同一事务里,中间崩溃会留下「订单已取消、占用行还在」的半成品。计划阶段就把这个坑拆掉了。
Redis 释放放在事务提交之后,失败只记日志。顺序的理由与座位锁的回滚路径一致:Redis 不参与数据库回滚。宁可出现「DB 已取消、Redis 短暂仍锁定」(对账 #4 会修掉),也不能出现「Redis 已释放、DB 回滚后订单仍然有效」——后者意味着座位可以被别人买走,而原订单还活着,最终靠唯一索引拦下。
三方竞态:只有一个人赢
支付回调、超时消息、用户取消可能在同一秒发生。三条路径的第一步都是同一个条件更新:
UPDATE order_info SET status = ? WHERE order_no = ? AND status = 0
数据库行锁把三条语句串行化,WHERE status = 0 保证只有第一方成功,其余双方拿到零行。输家的处理各不相同:
sequenceDiagram
participant P as 支付回调
participant T as 超时/取消
participant DB as MySQL(状态机)
P->>DB: CAS 待支付→已支付
T->>DB: CAS 待支付→已取消
Note over DB: 行锁串行化,只有一方成功
alt 支付赢
P->>DB: 座位置已售(1→2)
T-->>T: 查询发现已支付,直接忽略
else 超时/取消赢
T->>DB: 释放座位 + 删除占用行
P-->>P: 发现订单已取消 → discrepancy 分支
end
- 支付赢:超时处理器下次被唤醒(或本次)读到状态不是待支付,直接返回;座位已经由回调置为已售。
- 超时/取消赢:支付回调发现订单已取消,进入差异(discrepancy)分支:只记日志与指标,不修改订单、不卖座位。用户确实付了钱但订单已经取消——自动退款是 P2,当前先用指标把这类差异暴露出来(见《回调为什么必须幂等》对这一取舍的完整讨论)。
- 用户取消与超时互撞:两边都执行同一个
cancelPending,一方 CAS 成功、另一方changed = 0拿到cancelled = false,幂等退出;座位只可能被释放一次,因为释放脚本只对「锁定(1)」的座位生效,重复执行是无害的空操作。
测试与验证
自动化测试用 FakeTimeoutSender 替换真实发送器(测试 profile 下 MQ 整体关闭),四个用例覆盖决策树:过期订单被取消并释放座位;已支付订单被忽略;未到期订单触发 re-arm 且携带正确的剩余时长;重复处理同一个订单号第二次无副作用。
真实链路的验证放在端到端冒烟脚本里:应用以 10 秒过期启动,下单、不支付、轮询——订单变为已取消、座位回到可售、order_seat 占用行消失。这条脚本真正跑过 RocketMQ 的延迟投递、消费组与延迟级别,弥补了单测里被替身的空白。
代价与边界
- 延迟级别离散:十五分钟由 10+5 两跳完成;极端剩余时长可能多跳一两轮,每跳成本很低。
- 未用事务消息:RocketMQ 事务消息能保证「本地事务与消息发送」的原子性,但要引入回查机制和事务消息接口。这里选择了「本地事务 + afterCommit 发送 + 对账兜底」——链路更简单、可测试性更好,代价是消息可能丢,最坏多锁五分钟。
- 过期消息无法撤回:用户提前支付后,那条延迟消息依然会到达并被忽略,成本是一条无用消息。
- 重复投递的日志噪音:被忽略的消息会记一条 info 日志,高频场景下可以降级为 debug。
延迟消息给了一个可靠的时钟,状态机保证了执行一次。而钱到账的通知可能重复、可能迟到——那是一条需要幂等的链路。