订单超时取消时,消息重试用指数退避还是直接扔死信?我们把三种策略放一起跑了一遍

订单超时取消这类场景,用不用死信不是一道选择题,而是看你的系统能不能承受消息重复消费的代价。我们拿三种策略跑了一轮压测,结论很明确:业务补偿在数据一致性上的表现最好,指数退避适合延迟敏感的场景,而死信队列必须配合人工处理流程,否则就是个定时炸弹。

我参与的这个项目是电商订单系统,日均订单量在 20 万单左右,峰值 QPS 约 300。订单创建后,如果 15 分钟内未支付,系统自动取消并释放库存。消息队列用的是 RocketMQ 4.9.3,消费者是 Spring Boot 2.7 应用,数据库是 MySQL 8.0,库存扣减采用乐观锁。测试环境部署了 3 台 4C8G 的消费者实例,消息积压阈值设为 10 万条。

先说我们怎么设计的测试方案。模拟的场景分三种:第一种是数据库瞬时死锁导致的消费失败,第二种是下游库存服务超时,第三种是业务校验不通过(比如订单状态已被其他流程变更)。每种失败类型按 20%、10%、5% 的比例混合注入,总消息量 50 万条,观察 30 分钟内三种策略的吞吐量、延迟分布和最终一致性。

指数退避:延迟友好但会放大瞬时故障

指数退避的最大问题是,它在瞬时故障面前太“温柔”了,反而把简单问题复杂化。

我们配置的退避策略是 1 秒、2 秒、4 秒、8 秒、16 秒,最多重试 5 次,超过次数后写入数据库失败记录表。压测结果显示,当数据库死锁率从 0.1% 升到 1% 时,指数退避的 P99 延迟从 3 秒飙到了 78 秒。原因很简单:每次死锁重试都意味着整个重试链条被拉长,而后续消息还在不断涌入,消费者线程池的 20 个线程很快被占满。

更麻烦的是,退避期间订单状态可能已经变了。比如第 3 次重试时,用户恰好完成了支付,但取消逻辑还在重试队列里等着执行。我们抓到了 67 个订单在支付成功后又被取消的案例,这些都是实打实的资损。

但指数退避并非一无是处。在死锁率低于 0.3% 时,它的最终一致性成功率是 99.7%,而且不需要额外开发补偿逻辑。如果你的业务对延迟敏感,且能通过监控严格控制瞬时故障率,这个方案实现成本最低。

死信队列:不是终点站,是急诊室

直接扔死信的策略在压测里暴露了一个致命问题:死信积压后,消息的消费顺序完全不可控。

我们设的规则是消费失败超过 3 次直接进死信,然后由定时任务每分钟拉取死信消息重放。在 50 万条消息、5% 失败率的场景下,死信队列在 12 分钟时积压了 1.8 万条,定时任务每次拉取 500 条处理,但处理过程中又会产生新的失败,死信队列越滚越大。30 分钟结束时,还有 4200 条消息没处理完。

而且死信重放时没有顺序保证。我们观察到订单 A 的取消消息比订单 B 早 3 分钟进入死信,但因为重放时的批量拉取和并发处理,订单 B 先被取消。虽然最终都取消了,但库存释放的顺序乱了,导致一个 SKU 出现了 3 秒的超卖。

死信队列真正的价值是作为人工介入的缓冲带。我们在测试后半段加入了一个死信监控面板,当死信数量超过 1000 时触发告警,运维手动检查后批量重放,一致性才回到 100%。所以死信队列必须配监控和 SOP,否则就是埋了个雷。

业务补偿:多写代码,少擦屁股

业务补偿策略的核心是把“取消订单”拆成两个动作:先查订单状态,再决定是否执行取消。

具体实现上,消费者收到消息后,先调用订单查询接口,如果订单状态已经是已支付或已取消,直接返回消费成功,不再执行后续逻辑。只有状态为待支付时,才执行取消和库存释放。如果库存释放失败,不重试,而是写入补偿表,由另一个补偿任务每 30 秒扫描处理。

这套方案在压测里表现最稳。50 万条消息全部在 8 分钟内处理完,P99 延迟 1.2 秒,零资损。代价是代码量多了 400 行,补偿表的写入带来了 8% 的数据库额外开销。但换来的是消息可以无脑重试——因为每次消费都先校验状态,重试多少次都不会出错。

补偿任务的设计有个细节值得注意。我们用了一个单独的表 order_compensation,字段包括订单 ID、补偿类型(取消/退款/释放库存)、重试次数、下次执行时间。补偿任务每次扫描 next_execute_time < NOW() 的记录,处理完后更新重试次数并将下次执行时间延后。超过 10 次的重试记录转人工处理。这套逻辑在生产环境跑了 3 个月,累计处理了 1.2 万条补偿记录,人工介入的只有 23 条。

三种策略怎么选

压测数据拉出来之后,我们内部讨论的结论是:没有银弹,但有明确的取舍逻辑。

如果你的订单取消是强实时需求(比如闪购场景,15 秒不支付就释放),且下游服务 SLA 在 99.9% 以上,指数退避够用。我们线上一个日订单 5 万的业务线用的就是这套,半年没出过问题。

大多数电商场景,我建议直接用业务补偿。多写的那些代码,会在第一次故障时值回票价。我们主站的订单取消链路切到补偿模式后,之前每个月都要处理的 3-5 起资损工单直接归零。

死信队列不要作为自动重试策略,它是个监控和人工兜底的机制。我们的死信阈值设的是 100 条触发告警,500 条自动熔断消费者,防止积压雪崩。

常见问题

消息消费失败后,为什么不能无限重试?

因为重试会占用消费者线程,导致后续消息堆积。我们测试过无限重试的配置,在 5% 失败率下,消费者在 3 分钟内就全部卡死,吞吐量降到 0。RocketMQ 的默认重试上限是 16 次,这个值已经偏大了,普通业务设 3-5 次足够。

业务补偿表的补偿任务和定时任务扫描订单表有什么区别?

补偿表是精确打击,只处理失败记录;定时扫订单表是全量扫描,订单量大了之后数据库压力扛不住。我们做过对比,500 万订单量时,扫补偿表(1.2 万条)耗时 200ms,扫订单表的 SQL 跑了 12 秒,还触发了慢查询告警。

指数退避的时间间隔怎么定?

别用网上抄的固定公式。我们的做法是拉出下游服务的 P99 响应时间,第一次重试间隔设成它的 2 倍,之后每次翻倍。比如库存服务 P99 是 800ms,那第一次重试就等 1.6 秒。这样能避开大部分瞬时故障的持续时间窗口。