ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

支付系统兜底机制:补偿与补单设计实战解析

支付系统兜底机制:补偿与补单设计实战解析 做支付系统做得久了你会发现一个扎心的规律线上百分之七八十的订单问题都不是“钱没扣”或者“钱扣错了”而是“支付结果没到订单系统”。回调超时、回调丢失、渠道接口抖动、消息队列积压任何一个环节喘口气用户的订单就卡在“支付中”。这时候真正能让系统缓过来的不是主流程而是平时不怎么被注意的补偿和补单——主动去查、去捞、去把漂在中间态的订单拉回正轨。这篇文章就来聊聊支付系统里这套兜底机制的设计逻辑和实战经验适合做支付、做交易、做订单系统的同学也适合那些准备面试被问到“分布式一致性”时想拿真实案例说话的工程师。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 可以到 PAIDCLOSED 不能到 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) ) engineInnoDB;执行时用一个简单的 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 映射写错了导致补单任务拿channelCodeA去请求渠道 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 实时补单和对账结果打架时以谁为准补单系统走的是实时查询拿到的结果只是渠道侧的“当前状态”对账系统走的是 T1 文件和银行清算结果是资金的“最终裁决”。两边结论不一致的情况确实存在。比如补单查到“支付失败”但 T1 对账文件里出现了这笔支付说明渠道的实时查询接口在那一刻返回了脏数据或者钱被扣了但状态还没流转完。所以我在系统里习惯给订单加一个settlement_status字段补单逻辑只更新业务状态对账逻辑负责更新清结算状态。业务状态和对账状态都确认一致后订单才进入真正的“已完成”。这样即便补单把订单误判成失败对账结果仍有机会纠正不会造成资金侧错乱。这也是为什么我一直强调补偿和补单要做成一整套机制而不是在订单状态里补几个 if 分支就完事。最后分享一个我踩过坑之后的习惯订单状态机设计好的那天就顺手把中间态的扫描任务和补偿任务表一起建了。不然等线上真的出现第一笔“支付成功但订单未更新”的工单再去补往往已经晚了。也希望你接手的支付系统里补单不只是一个定时任务而是一套有状态、有监控、有值班通道的闭环机制。如果你也在做订单系统欢迎把你们遇到的补偿坑拿出来聊聊很多经验真的是踩过一次才记得住。
返回列表