做支付系统做得久了,你会发现一个扎心的规律:线上百分之七八十的订单问题,都不是“钱没扣”或者“钱扣错了”,而是“支付结果没到订单系统”。回调超时、回调丢失、渠道接口抖动、消息队列积压,任何一个环节喘口气,用户的订单就卡在“支付中”。这时候,真正能让系统缓过来的不是主流程,而是平时不怎么被注意的补偿和补单——主动去查、去捞、去把漂在中间态的订单拉回正轨。这篇文章就来聊聊支付系统里这套兜底机制的设计逻辑和实战经验,适合做支付、做交易、做订单系统的同学,也适合那些准备面试被问到“分布式一致性”时,想拿真实案例说话的工程师。
1. 支付系统先得承认一件事:消息会丢、状态会卡
1.1 “已支付”是多方系统最终对账的结果,而不是一次回调的结果
用户点完支付按钮,链路大体是:收银台 -> 商户网关 -> 支付渠道 -> 银行/第三方支付网络。银行真正把用户的钱扣走之后,支付渠道再异步通知商户,商户回调接口更新订单状态。这条链路跨了至少三套系统,每一跳都可能出现网络闪断、应用重启、请求超时或者消息积压。也就是说,“用户的支付成功”和“你订单系统里记录的支付成功”天然隔着一条网络,它们不是同一个原子操作。
这里有个常见的误解:很多人以为回调接口收到通知,把订单置为 PAID 就完事了。可回调本身是靠发送方重试来保证可靠性的,商户端如果接口处理报错,渠道会过一段时间再次投递。问题来了:如果渠道发送方的重试策略、商户接口的异常分支、消息中间件的投递状态里任何一个环节出了问题,回调就永远到不了。那订单就一直停在 WAIT_PAY,可用户的钱已经出去了。没有一套主动兜底的机制,这个订单就是一笔“死账”。
1.2 链路越长,越不能用“同步等待”来解决问题
可能有人会问,为什么不直接让前端同步等支付结果,等到渠道返回成功再落库?现实里做不到。以最常见的网银支付、银行卡快捷支付为例,渠道内部要做风控校验、跨行清算,一个结果短则几百毫秒,长则几十秒甚至几分钟。HTTP 同步调用根本扛不住这么长的持有时间,代理服务器、网关、应用服务器的连接池都会被打满。所以支付渠道几乎都选择异步通知这种方式,把“结果告知”拆成独立的动作。
这就倒逼我们接受一个事实:订单系统的实时状态,在大多数瞬间只是一个“当前认知”,不是全链路真实状态。真实状态可能分散在支付渠道、清算系统、银行对账文件里。补偿和补单机制,本质上是建立一条属于我们自己的“主动获取真实状态”的通道,把网络的不可靠性兜住。
2. 订单状态机是补偿与补单的土壤
2.1 没有“支付中”这个中间态,补单就无从下手
把订单状态设计成只有 WAIT_PAY -> PAID 是最省事,也是最危险的。一旦支付回调丢失,订单将永远停在 WAIT_PAY,你根本不知道该不该去查、去查谁。所以可靠的支付系统里,一定有一个显式的中间态,一般叫 PAYING(支付中)或者类似名字。
用户发起支付、向渠道下单成功但未收到回调时,订单进入 PAYING。这个状态的含义是:本地已经发起了一笔支付,但结果尚未确认。PAYING 的存在,让补偿任务有了明确的扫描目标——查所有停留在 PAYING 且超过一定时长的订单,看渠道那边的真实状态。没有这个中间态的话,你只能用“创建超过N分钟且未支付”去猜,很容易把用户压根没付款的订单也捞起来,白白制造补偿噪音。
2.2 状态迁移必须带条件,防止“死订单复活”
订单的状态迁移一定要用条件更新,而不是先查出来在内存里判断再 update。我见过一个事故:一个订单已经因为超时被关单了(CLOSED),但定时补单任务在旧数据里查出“该订单已支付”,直接把状态改成 PAID,导致一笔早已关闭的订单突然变成“已支付”,用户余额被扣,而库存早还回去了。根因就是update ... set status = ? where id = ?没有带 status 约束。
正确写法是这样的:
update t_order set status = 'PAID', rev = rev + 1, pay_time = now() where order_no = 'xxx' and status in ('WAIT_PAY', 'PAYING') and rev = 12;带 status 约束之后,状态只会沿着定义好的路径迁移:WAIT_PAY 和 PAYING 可以到 PAID,CLOSED 不能到 PAID。就算补偿任务和回调还是并发执行,只要有一个把状态改了,另一个 update 影响行数为 0,自然被丢弃,不会产生回跳。
顺便说一句经常和订单状态绑在一起的问题:订单与库存的分布式事务。提交订单后如果用“下单即扣减库存”的模式,那就要特别小心,用户不支付导致订单超时关闭时,必须释放库存;如果用“支付成功才扣减库存”的模式,则要考虑高并发下的超卖风险。无论哪种,补单任务都不只是把订单状态从 PAYING 拉到 PAID 就结束,它还要在同一套补偿链路里同步触发库存扣减或释放的动作,否则订单状态正常了,库存数据却乱了,对账又是一笔烂账。
2.3 状态机之外还要有“流水”,不然问题来了没法查
状态机只是骨架,真正排查问题时靠的是订单状态流水表。每笔订单的状态变化,都应该记录order_no、from_status、to_status、operator_type(回调/补单/人工/退款)、operator_no、create_time。为什么这个表很重要?因为补偿和补单本身就是异步动作,时间线是乱的:回调可能在补单之后才到,补单可能和用户退款在时间上重叠。没有流水,你根本讲不清楚这笔订单为什么走到当前状态,线上被反问一句“这个状态是哪条路径改的”就哑火了。
流水表还有一个作用:做补单次数审计。订单状态反复在 PAYING 和 WAIT_PAY 之间横跳的时候,流水能帮你一眼看出是哪个补偿任务在“捣乱”。
3. 补偿不是无脑重试:三种常见模式的取舍
3.1 模式一:延迟消息驱动的主动查询
这是最贴近支付主链路的补偿方式。用户发起支付后,在向渠道下单成功的那一刻,同时往延迟队列投递一条“支付结果查询任务”,延迟时间可以按业务特点取 5 秒、30 秒、2 分钟。任务触发时,主动调用支付渠道的查询接口,拿到这笔订单在渠道侧的真实状态:已支付就本地落库置 PAID;未支付就继续等待或再投递;渠道明确返回失败就置成支付失败;渠道长时间无响应则转入人工。
这种模式的优点是延迟低、精确,不会把无关订单都扫一遍。缺点是它依赖渠道提供了查询接口,而且每次查询都有成本。所以它适合作为主补偿手段,覆盖绝大多数正常场景。很多支付 SDK 的 demo 只教你怎么接回调,不教你怎么做主动查询,但生产环境里主动查询往往是比回调更可靠的信号源。
3.2 模式二:定时批次扫描加对账兜底
不管延迟消息做得多完善,都建议保留一个定时扫描任务。它的职责是:扫描所有状态停留在 PAYING(或 WAIT_PAY)超过 N 分钟、且最近没有补单记录的订单,重新触发查询或发给对账模块。
定时扫描是真正的“最后防线”:即便前置查询任务因为消息丢失、应用重启、代码 bug 全部没跑,只要扫描任务还在,滞留订单终究会被捞起来。用分布式调度框架做分片,按订单号哈希或按商户号分片,保证每个订单只被一台机器扫描。扫描 SQL 要控制范围,不要全表扫,优先扫最近 30 天停留在中间态的订单,配合next_compensate_time字段做游标,避免每次都是同样一批订单被反复捞。
3.3 模式三:反向操作,让状态安全地“回去”
不是所有的补偿都要让订单往前走。用户重复点击支付、订单超时未付、渠道扣款成功后订单本地已关闭,这些场景补单拉不回正常路径,反而应该发起反向操作。
拿重复支付来说,用户先用余额支付失败,又换了银行卡支付成功,实际两笔钱都扣了,这时候就需要基于支付渠道流水号做查重,对多余的那笔发起退款。注意,退款也是一种补偿动作,它同样需要幂等:退款请求要带refund_request_no,同一个请求号重复提交,渠道只会退一次。多支付场景下,“关单-退款”这两个反向操作,比盲目往前查结果更重要。
3.4 三种模式怎么配合
放个对比表:
| 模式 | 触发时机 | 延迟 | 成本 | 主要风险 |
|---|---|---|---|---|
| 延迟消息主动查询 | 支付发起后立即投递 | 秒级到分钟级 | 低,准确命中在途订单 | 依赖渠道查询接口 |
| 定时扫描对账 | 周期性执行 | 分钟级到小时级 | 中,会扫描一部分无问题订单 | 扫描压力、补偿风暴 |
| 反向操作(关单/退款) | 超时、重复支付、异常 | 不固定 | 高,涉及资金操作 | 必须强幂等,否则重复退款 |
实战里的标准做法是:延迟消息做第一层,定时扫描做第二层,对账系统做第三层,反向操作作为异常分支兜底。各层之间用订单状态和补单记录互相衔接,防止同一笔订单被多层同时处理。这个分层思路,面试的时候能讲明白,基本就能证明你真正碰过支付系统,而不是只看过八股文。
4. 补单系统的核心设计:任务表、幂等、锁、人工降级
4.1 用一张任务表管理补单,不要只依赖消息队列
很多人一听“补单”就想到延迟队列,但我要说:消息队列适合做“触发”,不适合做“账本”。延迟消息一旦消费失败重试次数用完,消息就丢了,你连它失败过都不知道。可靠的做法是维护一张独立的补偿任务表,或者干脆在订单表上加next_compensate_time、compensate_count字段。
任务表的设计大致这样:
create table t_compensation_task ( id bigint primary key auto_increment, biz_type varchar(32) not null comment '业务类型:支付结果查询/关单/退款', biz_no varchar(64) not null comment '业务单号,如订单号', task_status tinyint not null comment '0待执行 1执行中 2成功 3失败 4人工处理', retry_times int not null default 0, max_retry_times int not null default 5, next_exec_time datetime not null, last_err varchar(512) default null, create_time datetime not null, update_time datetime not null, index idx_status_time (task_status, next_exec_time), unique key uk_biz (biz_type, biz_no) ) engine=InnoDB;执行时用一个简单的 SQL 捞任务:
select * from t_compensation_task where task_status = 0 and next_exec_time <= now() and retry_times < max_retry_times order by next_exec_time asc limit 100;为什么是 limit 100?因为要控制每一轮的扫描量,避免一次性拉出几万条任务把下游打爆。这地方是很多人忽略的隐患,我后面会专门讲补偿风暴。另外,任务表里一定要留last_err字段,没有它,调一次查询接口失败后你连为什么失败都看不到,排障全靠猜。
4.2 每个补单动作都要幂等,处理三次和一次效果必须一样
补单的本质是“对不确定的结果做二次确认”,既然是二次确认,就可能被并发执行:回调线程和补单线程同时处理同一个订单,或者两个补单任务间隔几秒接连触发。如果不做幂等,轻则状态字段被无意义地更新,重则资金流水重复入账。
我见过最典型的错误:补单任务发现订单是“已支付”,直接调insert into t_pay_flow插入一条流水。如果这个订单被两个补单任务先后处理,就会插入两条支付流水,后面财务对账又对不上。要避免这个问题,流水表上必须加唯一索引,比如uk(order_no, pay_channel, channel_transaction_no),并发插入时只有一条能成功,另外一条抛 DuplicateKey 异常后忽略即可。记住:数据库的唯一约束,永远比应用层的 if 判断可靠。
4.3 分布式锁:同一个订单不能被两台机器同时补
补单任务一般都跑在集群上,同一个订单可能被两台机器的两个扫描线程同时捞到。解决办法有两个层面:数据库层面用for update,或者用 Redis 的分布式锁。我更推荐后者,因为补单任务执行时间通常很短,用 Redis 锁更轻量,锁的 key 就是业务单号。
String lockKey = "comp:order:" + orderNo; boolean locked = redis.setIfAbsent(lockKey, "1", Duration.ofSeconds(30)); if (!locked) { log.warn("order {} is being processed, skip", orderNo); return; } try { handleCompensate(orderNo); } finally { redis.delete(lockKey); }锁的过期时间要设置成超过任务预估执行时间,一般 30 秒到 60 秒足够;如果查询渠道特别慢,适当加长。锁超时时间太短会导致两个线程同时进入处理逻辑,太长会影响其他任务的执行,需要根据实际接口耗时压测调整。这里有一个容易被忽视的细节:finally里删锁之前,最好再确认一下锁的 value 还是自己设置的,否则可能因为锁快过期时并发交错,删掉了别人刚拿到的锁。
4.4 重试要有上限,退避要有算法,最后必须有人工接口
补单不是无限重试,补偿任务重试次数超过max_retry_times后,任务状态要变成“人工处理”,并主动给值班同学发告警。重试间隔不建议固定,采用指数退避:next_exec_time = now + 60s * 2^retry_times,第 0 次失败后 1 分钟,第 1 次失败后 2 分钟,第 2 次 4 分钟,第 3 次 8 分钟。这样既不会在渠道抖动时疯狂打下游,也能在长时间故障恢复后自动捞起。
人工处理工单要能展示订单当前状态、最近一次渠道查询结果、补单历史错误信息。值班人员据此判断:是让订单置 PAID,还是发起退款,还是手工修改某些字段。人工操作必须记录账号和操作时间,方便出事回溯。没有人工处理通道的补单系统,就像没有应急出口的隧道,平时看不出问题,出事就是大事。
5. 一次线上故障复盘:回调丢失后订单怎么被“捞”回来
先说明一下,以下这个案例是我亲身经历过的,虽然渠道名和部分参数做了脱敏,但整个排查链路和修复思路是原汁原味的。
那天的问题先从客服群炸出来:用户反馈付款成功,订单却一直是“待支付”。查监控发现,某银行渠道的异步回调成功率从正常值突然掉了将近一半。渠道方给的反馈是他们的通知网关在升级,部分回调可能要延迟数小时甚至丢失。当时我们的第一反应是:补单系统应该能兜住。结果打开补单任务监控,确实在跑,成功率却是 0。
随后我们登进管理后台看错误日志,发现补单任务调用的查询接口返回了一堆参数校验错误。仔细对比后,定位到是发布新版本时,配置中心里某个渠道的 code 映射写错了,导致补单任务拿channelCode=A去请求渠道 B 的查询接口,请求发出去就被拒绝。
修复方案分了三步:
- 先把配置改对,让补单任务恢复正常;
- 然后把卡在 PAYING 超过 10 分钟、且近期无成功补单记录的订单,重新投递到补单任务里;
- 最后核对系统里是否有真正“查询不到、又确实扣款”的订单,把这类订单全部转人工,客服根据银行流水挨个确认。
整个过程大概持续了一个多小时。事后复盘最让我后怕的是:如果补单任务没有记录last_err,没有对失败率做监控告警,这个 bug 可能躺很久,而且一旦依赖补单的其他系统把状态当成“最终结论”,损失会被放大很多倍。
这个案例也说明一个原则:补单系统是用来兜底的,但它自己也需要被监控。失败率、处理延迟、积压数量,都要接入告警。兜底系统一旦失效,往往不是立刻暴露,而是等线上出了大问题,你才发现兜底网早就破了。
6. 补单系统自身的坑:补偿风暴、状态回跳与对账时间窗
6.1 补偿风暴才是不好收场的烂摊子
补偿风暴的场景:某个渠道故障了 40 分钟,期间所有支付都没回调,订单在 PAYING 里积压了几万条。渠道恢复后,定时扫描任务一次性捞出所有积压订单,全部并发发起查询,渠道接口瞬间被打满,响应超时,又触发新一轮扫描,恶性循环。
解决办法是给补单任务加限速。每次扫描只取固定数量(比如 100 条),并且每执行完一批 sleep 一小会儿;如果发现下游响应变慢,自动降低并发。真正的“防洪”在于把倾斜流量削平:宁可多花几分钟补完,也不能把下游打死。补单系统压垮支付渠道,听起来像笑话,但事故往往就是这么发生的。
6.2 状态回跳:补单把“已关闭”的订单救活了
这就是前面写到过的那个场景。用户下单后 15 分钟仍未支付,系统自动关单(CLOSED),但用户在关单之前其实已经通过某渠道完成支付,只是回调延迟。补单任务查到“渠道侧已支付”,如果不加状态约束,直接把 CLOSED 改成 PAID,订单就被“救活”了。可是对用户来说,这个订单界面大概率已经展示成“已取消”,突然又变成“待发货”,体验极差,还会引发库存扣减和物流的连锁反应。
处理逻辑应该反过来:当发现订单已 CLOSED 但渠道侧确实扣款成功时,不再去翻改原订单,而是走退款流程,把钱还给用户。也就是说,补单的结果不一定是“往前走”,也可能是“往回退”。把这一点想清楚,代码里就不会再有CLOSED -> PAID这样粗暴的迁移动作。
6.3 回调和补单同时触发的流水唯一性
回调和补单并发处理时,最怕的不是状态更新,而是资金流水重复。只有订单状态字段更新后,紧接着插入支付流水,而回调线程和补单线程的执行顺序不确定,就有可能都判断“当前状态是 PAYING 且是首次变 PAID”,然后各自插一条流水。
好消息是这个问题有标准解法,就是给流水表加唯一索引。索引的粒度可以是(order_no, gateway_transaction_no)或(order_no, payment_method, out_trade_no),只要同一笔真实支付在流水表里只能存在一条,重复插入直接报错并被吞掉,最终数据依然干净。宁可让异常日志里多一条 DuplicateKey,也不能让资金流水多发一条。
6.4 实时补单和对账结果打架时,以谁为准
补单系统走的是实时查询,拿到的结果只是渠道侧的“当前状态”;对账系统走的是 T+1 文件和银行清算结果,是资金的“最终裁决”。两边结论不一致的情况确实存在。比如补单查到“支付失败”,但 T+1 对账文件里出现了这笔支付,说明渠道的实时查询接口在那一刻返回了脏数据,或者钱被扣了但状态还没流转完。
所以我在系统里习惯给订单加一个settlement_status字段:补单逻辑只更新业务状态,对账逻辑负责更新清结算状态。业务状态和对账状态都确认一致后,订单才进入真正的“已完成”。这样即便补单把订单误判成失败,对账结果仍有机会纠正,不会造成资金侧错乱。这也是为什么我一直强调,补偿和补单要做成一整套机制,而不是在订单状态里补几个 if 分支就完事。
最后分享一个我踩过坑之后的习惯:订单状态机设计好的那天,就顺手把中间态的扫描任务和补偿任务表一起建了。不然等线上真的出现第一笔“支付成功但订单未更新”的工单再去补,往往已经晚了。也希望你接手的支付系统里,补单不只是一个定时任务,而是一套有状态、有监控、有值班通道的闭环机制。如果你也在做订单系统,欢迎把你们遇到的补偿坑拿出来聊聊,很多经验真的是踩过一次才记得住。