
写支付系统的可靠性绕不开一个场景用户下单后支付成功但订单却一直卡在“待支付”客服被用户反复催技术团队在后台盯着日志满头大汗。这种问题的根源往往不是某个单点故障而是整个支付链路里多个系统的状态需要保持一致但现实世界里网络会抖、服务会重启、消息会丢失任何一环断了订单状态就悬在半空。我做了几年支付相关系统越来越觉得所谓“可靠”不是不出错而是出错之后能自己兜回来。订单的补偿和补单机制就是这道兜底安全网。这篇文章把我实际落地这一套机制的经验整理出来包括方案选型、状态机设计、补单任务实现、踩坑记录希望对正在做交易、支付、订单系统的朋友有帮助。1. 补偿与补单先搞清楚这俩到底在解决什么问题很多刚接触支付系统的同学会把“补偿”和“补单”混为一谈其实它们在支付可靠性体系里负责的是不同层面的工作但目标一致把分布式链路里“状态不一致”的订单尽可能自动地拉回正确状态。1.1 补偿不是“事务回滚”而是“反向操作”传统单体应用里一个数据库事务内多条 SQL 要么全成功、要么全回滚一致性由数据库保证。但支付链路本质上是一个跨系统的分布式操作比如用户支付成功后需要通知订单系统更新状态、通知积分系统加积分、通知库存系统扣减库存这些系统有自己的数据库甚至可能属于不同的团队。这时候没法用本地事务把所有操作包在一起。补偿机制要做的事情是当某个环节失败或者状态不确定时主动执行一个“反向操作”来抵消前面的操作结果。比如订单已创建但支付超时那就把订单关掉返还预占的库存再比如支付成功但积分发放失败那就重新触发积分发放而不是把支付结果撤销。拿生活场景类比你网购了一件衣服付款成功但卖家一直没发货你不会把付款撤销而是会去催卖家发货。补偿就是那个“催”的动作而且这个动作要能重试要能记录每一次尝试的结果。1.2 补单是“自动修复人工兜底”的最后防线补单的含义更宽一些它包含两层一是系统通过定时任务扫描异常订单自动触发补偿动作把订单状态纠正过来二是当自动处理不了的时候提供一个运营后台或脚本工具让相关人员在人工介入后修复订单状态。也就是说补偿是一种手段补单是一套面向“异常订单”的处理闭环。一个合格的补单系统要有自动识别异常的能力、有自动执行补偿的动作、有重试和告警、有人工介入的入口还要有全过程的操作留痕。这样无论前面发生了什么诡异情况最终都能收口。2. 支付链路为什么这么脆弱分布式事务与最终一致性的底层逻辑既然要解决可靠性问题先得理解问题是怎么产生的。这里牵扯到一个在很多技术博客里被反复提起、但真正落地时又很容易被忽略的底层博弈强一致性和最终一致性的选择。2.1 一个订单背后牵动的不只是订单表我先画一条最常见的支付链路大家感受一下涉及的节点数量用户创建订单订单系统写入订单数据并锁定库存返回支付链接用户跳转到支付渠道完成扣款支付渠道异步回调通知支付系统支付系统更新支付单状态再通过消息队列通知订单系统订单系统更新为已支付通知仓储系统发货触发积分或优惠券核销。这条链路里只要任何两个系统之间的数据不一致就会出现问题。比如支付渠道回调丢失用户钱扣了但订单还挂着“待支付”再比如订单系统更新已支付但通知积分系统的消息被消费失败用户积分一直不到账。而且很多情况下出问题的不是某一个动作而是动作执行了但结果不确定——支付渠道超时未返回到底成没成功谁也不知道。2.2 强一致做不了只能靠最终一致兜底一部分人最开始会想能不能把所有操作放在一个分布式事务里要么全部成功要么全部回滚理论上可以比如用两阶段提交、TCC 之类的方式强约束所有参与方。但实际业务里支付渠道根本不会配合你玩分布式事务你不能要求微信或支付宝在回调你之前先“预提交”一笔扣款。而且强一致方案对性能的影响非常大加锁、资源预留、协调者单点在电商大促场景下基本扛不住。所以业界的通用思路是接受现状允许系统在某个时间窗口内存在短暂的不一致但通过补偿、重试、对账等手段保证最终所有系统会收敛到一致的状态。这就是最终一致性。补偿和补单机制本质上就是实现这种收敛能力的基础设施。3. 方案选型几种常见的补偿方案以及我的推荐明白了目标下一步就是选型。市面上常见的方案有本地消息表、事务消息、TCC、Saga我一个个说它们解决什么问题、坑在哪里、适合什么场景。3.1 本地消息表最朴素也最可靠的方案本地消息表的核心思想很简单在业务操作所在的同一个数据库里创建一张本地消息表业务操作和往消息表里插入一条“待发送消息”在同一个数据库事务里完成之后由一个异步任务扫描这个表把未发送的消息投递到 MQ投递成功后标记为“已发送”。为什么说它可靠因为消息的落库和业务操作是强一致关系不存在“业务成功了但消息没存下来”或“消息发了但业务没成功”的问题。如果 MQ 挂了消息还在本地表里服务起来继续扫就行。我第一次接触这个方案的时候觉得太土了后来发现很多大厂订单系统用了很多年核心原因就是它受外部依赖影响最小。缺点是本地消息表和业务耦合在同一库中会对业务数据库造成额外的 IO 压力消息表的数据量会持续增长需要定时清理而且它只解决了“业务操作和发消息”的一致性如果下游消费后还需要回写依然要依赖下游自己的重试机制。但作为兜底方案它依然是我心里最稳妥的一招。3.2 事务消息把消息发送和业务操作绑成“同一个事务”以 RocketMQ 事务消息为例它的思路是先发送一条“半消息”到 MQ此时消息对消费者不可见然后执行本地事务根据本地事务结果向 MQ 提交或回滚这条半消息。如果本地事务执行过程中进程崩溃MQ 会反向查询本地事务的状态然后决定消息的最终去向。这个方案相比本地消息表好处是不用在业务库建消息表消息的可靠性由 MQ 负责坏处是依赖特定 MQ 组件的能力且需要实现本地事务状态查询接口。如果你的团队已经用 RocketMQ这个方案值得考虑如果用 Kafka抱歉Kafka 没有原生事务消息能力要在业务层面自己拼那就又回到本地消息表方案了。3.3 TCC 和 Saga重量级方案要看场景再上TCCTry-Confirm-Cancel和 Saga 属于分布式事务框架级别的方案通常引入 Seata 之类的框架或者自己封装一套。TCC 要求每个参与方提供 Try、Confirm、Cancel 三个接口业务侵入性很高Saga 把长事务拆成一系列子事务每个子事务都配一个补偿动作编排起来也比较复杂。我在实际项目里的感受是支付核心链路不适合把 TCC 或 Saga 作为默认选项。原因很简单支付渠道回调不可控你没法让外部渠道配合 Try 和 Confirm 的语义。而且在订单状态已经清晰、补偿动作也比较明确的情况下自己实现一套轻量补偿机制比引入重量级框架更可控。除非你的业务涉及跨系统大资金、长事务比如跨国结算这类否则不建议一开始就上。3.4 我的建议先建设状态机和重试能力再谈框架说了这么多我的实际建议是先不要把精力花在选择分布式事务框架上而是先把订单状态机设计好、把补偿任务和重试机制做到极致、把监控告警建起来。很多支付系统运行多年核心靠的其实就是一张状态准确的订单表、一套可靠的重试机制、一个能干的对账脚本外加值班人员手里那个“一键补单”按钮。4. 核心机制设计订单状态机、幂等设计和消息去重方案选型是宏观决策真正让系统稳定的其实是微观设计。这一节我讲讲订单状态机怎么设计、幂等怎么保证以及消息消费如何避免重复处理。4.1 订单状态机把状态流转管起来我见过不少项目订单状态就是数据库一个字段谁想改就直接 update导致状态乱成一锅粥。正确做法是先定义状态机明确哪些状态之间存在合法的流转路径然后在代码层面对所有更新操作做校验。一个典型的订单状态流可以这样设计当前状态触发事件目标状态说明待支付用户支付成功回调已支付可能需要同时释放预占库存或触发后续流程待支付支付超时/用户取消已关闭取消原因记录在附加字段已支付调用仓储发货成功已发货如果发货是异步的可增加“发货中”作为中间状态已发货用户确认收货/超时自动确认已完成确认收货后触发后续结算任何状态退款申请退款中退款中属于业务挂起状态走单独流程退款中退款成功回调已退款补偿动作的终态之一状态机设计里有几个要点每个状态变更必须记录变更日志。推荐单独建一张订单状态流水表写清楚“从什么状态因为什么原因变成什么状态操作人是谁请求唯一标识是什么”。排查线上问题的时候这张表的价值不低于订单表本身。非法流转必须拦截并告警。比如“已关闭”的订单突然要被改成“已支付”这大概率是脏数据或者重复回调要直接拒绝并上报监控。状态机不能只在代码里写 if-else要用清晰的枚举和一目了然的前置校验。如果团队规模大可以考虑引入状态机框架比如 Spring StateMachine但小团队自己写一个校验方法也完全够用。4.2 幂等设计补偿操作的第一原则是“不能重复执行出问题”补偿和补单天然是“反复尝试”的操作所以幂等性是整个机制的地基。所谓幂等就是同一个操作执行一次和执行多次结果一致。拿“关闭超时订单”来说如果这个操作被重复执行订单已经从待支付变成已关闭了第二次执行就必须直接忽略而不是再发一次关单消息。实现方式我常用这样一个组合状态前置校验执行补偿之前先查询当前状态只有状态等于预期值时才继续。相当于乐观锁的思路更新语句里带where status 待支付影响行数为 0 就说明已经被处理过了直接返回成功。唯一业务键在补偿记录表、状态流水表、消息表里建立唯一索引比如用订单号补偿动作类型作为唯一键重复插入直接报唯一键冲突捕获之后当成功处理。外部接口调用带幂等号所有向外发起的补偿请求都带一个全局唯一的 requestId下游接收方依据这个 requestId 做去重判断。这样即使我们的重试机制重复发了几次请求下游也只会真正执行一次。4.3 消息队列消费去重不依赖“消息只投递一次”的承诺很多 MQ 组件都会跟你承诺“至少一次投递”翻译成人话就是消费端可能收到重复消息。所以消费端必须自己实现去重而不是指望 MQ 一条消息只处理一次。最常用的做法是消费逻辑里先查一次幂等表或者状态流水如果该消息 ID 已经处理过直接返回 ACK。更稳妥的做法是“先记录再处理”把正在处理的消息 ID 先插入一张处理记录表带唯一索引处理成功再更新状态这样即使中间崩溃重启重复消息也能被唯一索引拦住。5. 实操一套订单补偿和补单系统的落地细节概念讲再多不如直接看落地。这一节我从表结构设计开始带着你把一套轻量级补偿补单系统搭出来。5.1 补偿任务表所有异常订单的“待办清单”补偿任务表是整个补单系统的心脏扫描程序、人工后台、监控告警都围绕这张表工作。以下是我在实际项目中沉淀下来的表结构字段不算多但足够支撑大部分场景CREATE TABLE order_compensation_task ( id bigint(20) NOT NULL AUTO_INCREMENT COMMENT 自增主键, order_no varchar(64) NOT NULL COMMENT 订单号, biz_type varchar(32) NOT NULL COMMENT 业务类型PAY_TIMEOUT_CLOSE/PAY_CALLBACK_MISSING/REFUND_RETRY等, status tinyint(4) NOT NULL DEFAULT 0 COMMENT 任务状态0待处理 1处理中 2成功 3失败 4人工介入, retry_count int(11) NOT NULL DEFAULT 0 COMMENT 已重试次数, max_retry_count int(11) NOT NULL DEFAULT 5 COMMENT 最大重试次数, next_retry_time datetime DEFAULT NULL COMMENT 下次重试时间, last_retry_time datetime DEFAULT NULL COMMENT 上次重试时间, last_error_msg varchar(512) DEFAULT NULL COMMENT 上次失败原因, request_id varchar(64) NOT NULL COMMENT 幂等请求号全局唯一, extra_data text COMMENT 扩展数据JSON格式保存业务上下文, create_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT 创建时间, update_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT 更新时间, PRIMARY KEY (id), UNIQUE KEY uk_request_id (request_id), KEY idx_status_next_retry (status, next_retry_time), KEY idx_order_no (order_no) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT订单补偿任务表;几个字段我特别说明一下biz_type很关键它决定了补偿执行器用哪一种处理逻辑。不要把所有补偿动作塞进一个大方法里写一堆 if-else最好用策略模式每种 biz_type 对应一个处理器。request_id必须唯一这是幂等的最底层保障。我习惯用“业务类型订单号触发场景”拼一个出来比如PAY_CALLBACK_MISSING_ORDER202501011200001。next_retry_time要配合重试次数动态计算扫描任务只捞取“到了重试时间且状态为待处理”的数据避免集中风暴。5.2 扫描重试任务定时捞单、按策略退避有了表剩下的核心就是扫描任务。我通常用 xxl-job 或者 Quartz 实现一个分布式定时任务每 30 秒扫描一次每次捞取一批“待处理且 next_retry_time 小于当前时间”的任务然后交给线程池并发处理。伪代码如下大家可以参考public void scanAndProcess() { ListCompensationTask tasks compensationTaskMapper.scanDueTasks( MAX_BATCH_SIZE, now, lockTime); for (CompensationTask task : tasks) { compensationExecutor.submit(() - processTask(task)); } } public void processTask(CompensationTask task) { if (tryLock(task)) { // 用数据库行锁或Redis锁避免并发执行 try { task.setStatus(STATUS_PROCESSING); compensationHandlerFactory.getHandler(task.getBizType()) .handle(task); task.setStatus(STATUS_SUCCESS); } catch (Exception e) { handleRetryOrGiveup(task, e); } finally { releaseLock(task); } } } private void handleRetryOrGiveup(CompensationTask task, Exception e) { task.setLastErrorMsg(e.getMessage()); task.setRetryCount(task.getRetryCount() 1); if (task.getRetryCount() task.getMaxRetryCount()) { task.setStatus(STATUS_FAILED); alertService.sendAlert(补偿任务多次失败需要人工介入, task); } else { task.setNextRetryTime(calcNextRetryTime(task.getRetryCount())); task.setStatus(STATUS_PENDING); } }5.3 重试退避策略重试不是越快越好重试间隔的设计很有讲究。最开始我踩过坑把重试间隔设成固定 5 秒一次结果下游服务短暂抖动时补偿任务疯狂重试把下游直接打挂了。后来改成指数退避效果明显变好。一个我常用的策略重试次数延迟时间说明第1次30秒快速处理临时抖动第2次1分钟第3次5分钟第4次30分钟可能是下游缓慢恢复第5次2小时接近人工介入的临界点超过5次告警转人工不要无限制重试计算方式就是next_retry_time last_retry_time initial_delay * (2 ^ (retry_count - 1))同时加上一个随机扰动比如上下浮动 20%避免批量任务同时触发产生毛刺。5.4 人工补单工具必须留一个“人肉开关”无论自动化设计得多好总有机器处理不了的场景下游系统要升级维护、第三方渠道接口连续报错、脏数据导致状态死锁。这时候强制要求值班人员直接改数据库是最危险的做法正确的姿势是做一个带鉴权、带审计的人工补单页面。页面上至少要能展示待人工介入的任务列表、订单当前状态、任务失败原因、已重试次数操作人需要选择“将订单从未支付改为已支付”之类的动作并填写原因后台记录操作人和操作日志。我甚至会在操作页面二次弹窗提醒“此操作会影响用户资产请确认已与客服或业务方核实。”这种“麻烦”在关键时刻能挡掉很多事故。6. 常见问题与排查技巧实录这节我把自己在线上真实遇到过的几个经典问题整理成清单这些场景在书里不一定找得到但确实能帮大家少走弯路。6.1 订单卡在“待支付”但用户已经扣款了这种一般就是支付回调丢了或者回调处理逻辑里抛了异常。排查路径是这样先查支付渠道后台确认用户确实扣款成功再查支付回调接收日志确认回调是否到达、处理是否报错。如果回调确实没到最直接的补偿方案是写一个“主动查单任务”定时把超过 5 分钟还在待支付、且创建时间在支付渠道可查范围内的订单调用支付渠道的订单查询接口反向确认支付结果。我当时落地这个方案时把查单频率设为创建后 5 分钟、15 分钟、30 分钟各查一次查到已支付直接走“支付成功”的补偿流程查到未支付或已关闭再决定关单还是继续等。这个方案几乎能覆盖 99% 的回调丢失场景比单纯依赖回调可靠得多。6.2 重复支付用户下了两笔或者同一笔订单被支付两次先说同一笔订单被支付两次这个场景真是踩过用户在收银台点了两次支付生成了两个支付单都调起了第三方支付结果两个都成功了。这种情况处理原则是“一单一支付”即一个订单只能有一笔成功支付单其他支付单要主动发起退款。实现上创建支付单时需要加“订单号”唯一约束同样订单只能创建一个待支付的支付单如果用户又请求支付直接复用原支付单。万一因为历史原因已经出现了两笔成功支付需要告警出来并自动发起退款流程绝不能给用户“付了双份钱只有一份货”的体验。6.3 补偿任务越积越多扫不过来最常见的原因是执行器里某个下游调用没有设置超时时间导致线程池里的线程被长时间占用扫描任务不断捞新数据但处理不动。排查需要看任务执行耗时分布定位慢调用同时扫描 SQL 一定要带next_retry_time now条件别把还没到重试时间的任务一起捞出来否则大量无效占用。另外一个容易忽略的问题是表数据量膨胀。补偿任务表如果只增不减扫描会越来越慢我建议已经处于终态成功/失败/人工处理完成且超过 30 天的数据定期归档到历史表业务表保持轻量。6.4 凌晨对账发现两边数据对不上很多问题白天不明显到对账阶段就暴露了。支付系统的对账通常要核对三个维度订单系统与支付系统的支付单状态是否一致、支付系统与渠道的流水是否一致、订单系统与库存系统的扣减是否一致。我强烈建议在设计阶段就预留好对账的“数据抓手”订单号、支付流水号、渠道流水号、金额、状态、时间这几个字段在链路里所有核心表中都要存在。对账脚本其实不用写得多复杂按天拉取两侧数据做差集把不一致的数据打到专门的差异表再触发对应的补偿流程。我遇到最多的差异类型是“渠道有流水但支付系统没有支付单”这类基本都是回调丢失导致用主动查单方案就能修复。6.5 补单会不会把正常订单搞坏这是很多人对自动化补单最大的顾虑。要消除这个风险我的经验是两条一是所有补偿操作必须做状态前置校验用update ... where status 预期状态这种方式让数据库帮我们挡住并发二是灰度发布补偿任务先只处理 1% 的订单观察一段时间指标正常再逐步放量。线上无小事宁可慢也不能因小失大。6.6 定位问题一定要留好关键日志补单系统涉及大量异步操作出问题的时候如果日志不全排查起来异常痛苦。我建议核心操作至少打三类日志收到回调时的入参日志、状态更新前后的对照日志、补偿任务执行过程的步骤日志。日志里必须带上订单号和 requestId这样整个链路可以用一个订单号串起来查。我自己的习惯是打日志时不仅记录“发生了什么”还要记录“影响范围”比如“订单关闭释放库存 xxx 件当前剩余库存 xxx 件”这样后续排查能直接看到数据变化脉络。最后再分享一点心得这几年做交易系统的经验告诉我补偿和补单机制不是上线之后写几个定时任务就万事大吉的它是一个需要持续运维和打磨的体系。一开始可能一天只有几十条异常订单系统看起来“没它也行”但真到大促流量翻十倍、第三方接口偶发抖动的时候这套机制就是保命的东西。我个人的建议是新项目从第一天起就必须把状态机、幂等键、补单任务、监控告警这四样作为基础建设一次性设计进去而不是等出了问题再回头补。因为一旦线上跑了一段时间脏数据开始出现再想重构这些底层机制成本会成倍增加。如果读者朋友正在设计自己的订单系统希望这篇内容能帮你提前避开一些坑。有问题也欢迎留言交流一起把这套“兜底艺术”打磨得更可靠。