支付链路怎么设计才稳?从支付、退款到巡检补偿的完整实践
前言
前段时间我在项目里梳理了一套比较完整的支付链路,里面既有微信收付通,也有易宝支付。最开始做支付的时候,很多人关注的是“怎么调起支付”“怎么发起退款”,但真正到了线上之后你会发现,支付系统最难的其实不是把接口调通,而是怎么把链路做稳。
因为支付这件事天然就会遇到很多麻烦事:
- 用户点了支付,但是回调晚到了
- 用户取消了订单,结果第三方渠道又异步通知支付成功
- 退款接口调用成功了,但本地状态没更新
- MQ 丢了、回调失败了、网络抖动了,状态开始对不上
- 订单明明已经支付成功,前端却还显示待支付
所以一个能上线的支付系统,一定不是“回调来了就改状态”这么简单,而是要把支付、取消、退款、查单、巡检、补偿、人工兜底全都串起来。
这篇文章我就结合一套实际项目经验,聊聊一条完整的支付链路通常该怎么设计。
思路
我的理解里,稳定的支付链路一般要有下面几层保障:
- 入口校验:创建支付前先确认订单状态、活动状态、支付方式是否合法
- 状态机幂等:所有状态流转都要带前置条件,避免并发重复处理
- 回调驱动:支付成功、退款成功优先依赖渠道回调推进状态
- 主动查单:不能完全依赖回调,必要时主动向渠道查询订单或退款状态
- 延迟关单:长时间未支付的订单自动取消,避免脏数据堆积
- 巡检补偿:定时扫描异常订单、退款单,自动重试和补偿
- 人工兜底:极端情况下给运营或后台留重试入口
也就是说,支付系统不是单点成功,而是多层防线共同兜底。
一、先看完整链路
如果把链路抽象一下,大概就是这样:
- 用户创建支付单
- 服务端按渠道分流,走微信收付通或易宝
- 下单成功后,投递一条“延迟关单”消息
- 用户完成支付,第三方支付渠道异步回调
- 服务端验签、解密、幂等更新订单状态
- 如果订单已经被取消,但钱又到了,就自动转退款
- 退款成功后,渠道再次回调,或者系统主动查退款状态
- 如果中间任何一步没走通,就交给巡检任务继续补偿
这里最关键的一点是:
支付、退款、取消订单,这三个动作不能各写各的,它们本质上是同一个状态机上的不同分支。
补一段文字版时序图:支付主链路到底是怎么跑的
如果把一次正常支付过程再拆细一点,它通常会按下面这个顺序推进:
- 用户提交订单,服务端校验订单状态、商品或活动状态、支付渠道配置
- 服务端调用微信收付通或者易宝的下单接口,拿到调起支付所需参数
- 本地下单成功后,立即投递一条延迟关单消息,给“超时未支付”预留自动取消能力
- 用户在微信或其他支付收银台完成付款
- 第三方支付渠道异步回调服务端,通知这笔订单已经支付成功
- 服务端先验签、再解密、再做状态判断,最后用带前置条件的更新把订单从
PENDING推进到PAID - 如果这时发现本地订单已经被取消,那么就不再恢复订单,而是直接进入自动退款流程
- 如果回调没到,或者处理失败,后续还可以通过主动查单和巡检任务把状态补回来
这段时序里我最看重的是第 3、6、8 步。
- 第 3 步决定了系统有没有“超时回收”能力
- 第 6 步决定了系统能不能扛住重复回调和并发更新
- 第 8 步决定了系统是不是只能“靠运气等回调”
很多支付系统之所以线上容易出问题,不是因为支付接口不会调,而是因为它们只写了第 1 到第 5 步,却没把第 6 到第 8 步真正设计完整。
二、支付单创建怎么做
创建支付单时,最容易犯的错,就是一进来直接调第三方支付接口。这样做短期看起来快,长期一定会出问题。
正确做法应该是:先校验业务状态,再选择支付渠道,最后才是调用支付网关。
我比较推荐的校验顺序:
- 订单是否存在
- 订单是否还是待支付
- 活动或商品是否还允许支付
- 支付方式是否和业务匹配
- 当前订单是否已经支付过
- 商户号、子商户号这类渠道配置是否完整
一个脱敏后的示例大概如下:
async function createPayment(orderNo: string, userId: string) { const order = await orderRepository.findByOrderNo(orderNo)
if (!order || order.userId !== userId) { throw new Error('订单不存在') }
if (order.status !== 'PENDING' || order.paid) { throw new Error('当前订单状态不允许支付') }
if (order.activityStatus === 'CANCELLED') { throw new Error('活动已取消,禁止继续支付') }
const channelClient = paymentChannelFactory.getClient(order.paymentChannel) const payInfo = await channelClient.createPayment({ orderNo: order.orderNo, amount: order.amount, payerId: userId, })
await mq.emitDelayClose(order.orderNo, 15 * 60) return payInfo}这里有两个点特别重要。
1、支付前一定要做业务态校验
比如活动已经关闭了、名额已经失效了、订单已经取消了,这种情况就不该再调起支付。不然你后面一定会面对“钱付进来了,但业务已经不允许成交”的问题。
2、下单成功后就要安排“超时自动取消”
很多支付单最终都不会真正支付,如果不主动清理,系统里会堆很多长期待支付订单。所以我一般会在创建支付单成功后,立即发一条延迟消息,15 分钟后再来检查这笔订单还要不要继续保留。
三、支付成功为什么不能只靠回调
很多刚接支付的人都会以为:渠道回调到了,我把订单改成已支付,这事就结束了。
实际上并不是。
回调当然很重要,但回调不是唯一真相,它只是一个信号。
因为线上一定会碰到这些情况:
- 回调网络超时
- 回调被网关拦截
- 回调消费时报错
- 回调重复推送
- 回调比取消订单更晚到达
所以支付回调的正确打开方式应该是:
- 先验签
- 再解密
- 判断支付结果
- 做幂等更新
- 必要时走自动退款逻辑
一个脱敏后的回调处理示例:
async function onPayCallback(payload: EncryptedNotify) { const notify = await paymentNotifyService.parseAndVerify(payload)
if (notify.tradeState !== 'SUCCESS') { return { code: 'SUCCESS' } }
const order = await orderRepository.findByOrderNo(notify.outTradeNo) if (!order) { return { code: 'SUCCESS' } }
if (order.status === 'CANCELLED') { await refundService.autoRefundPaidOrder(order) return { code: 'SUCCESS' } }
await orderRepository.updateByCondition( { orderNo: order.orderNo, status: 'PENDING', paid: false, }, { status: 'PAID', paid: true, paidAt: new Date(), transactionId: notify.transactionId, }, )
return { code: 'SUCCESS' }}这里面最核心的是这句:
updateByCondition({ status: 'PENDING', paid: false }, { status: 'PAID', paid: true })这类带条件的更新,本质上就是一种很实用的幂等手段。它可以避免:
- 重复回调把状态改乱
- 回调和查单同时处理同一笔订单
- 回调和取消订单并发执行
也就是说,支付系统里很多“稳定性设计”并不复杂,关键是不要直接覆盖状态,而要基于当前状态去流转。
四、为什么订单取消后,还要考虑“后到款”
这个问题是支付系统里非常经典的坑。
场景通常是这样的:
- 用户创建了支付单
- 系统因为超时把订单取消了
- 但第三方支付渠道那边,用户其实刚好支付成功
- 于是渠道回调又告诉你,这笔钱已经付过来了
如果这个时候你只是简单地拒绝回调,那资金和业务状态就对不上了。
更合理的方式是:
- 本地订单已经取消,但渠道确认支付成功时,不再恢复为正常订单,而是直接进入退款流程
这也是为什么我一直觉得,支付系统里退款不是一个附属功能,而是主链路的一部分。
五、取消支付单怎么设计
取消支付单一般至少要分两种情况:
1、未支付取消
这种最简单。
如果订单还处于待支付状态,并且本地确认还没收款,那么就可以:
- 把订单状态改为已取消
- 回滚库存、名额、报名关系等业务资源
- 通知第三方渠道关闭支付单
脱敏后的示例:
async function cancelPendingOrder(orderNo: string) { const updated = await orderRepository.updateByCondition( { orderNo, status: 'PENDING', paid: false, }, { status: 'CANCELLED', cancelledAt: new Date(), }, )
if (!updated) return
await stockService.restore(orderNo) await signupService.cancel(orderNo) await paymentOrderService.closeRemoteOrder(orderNo)}2、已支付取消
如果订单的钱已经付进来了,那这就不是“关单”了,而是“退款”。
这个时候更推荐的流程是:
- 订单进入
REFUNDING - 创建一笔退款单
- 发送退款申请
- 等待退款回调或主动查退款状态
- 成功后把订单改成
REFUNDED
也就是说,取消支付单和退款在已支付场景下,其实是同一件事。
六、退款链路怎么做才不容易翻车
很多支付系统在退款这里会变得很脆弱,原因是退款通常比支付更复杂。
因为支付一般是“用户主动发起一次”,而退款往往会受到这些因素影响:
- 自动退款
- 用户主动退款
- 活动取消批量退款
- 渠道退款处理中
- 渠道退款失败
- 本地已记退款中,但渠道其实没收到申请
所以我比较推荐退款单单独建表,维护自己的状态机,比如:
PENDINGPROCESSINGSUCCESSFAILABNORMALCLOSED
退款主流程可以设计成这样:
- 订单从
PAID进入REFUNDING - 创建退款单,状态初始为
PENDING - 异步消费者消费退款任务,把退款单推进到
PROCESSING - 调用微信收付通或易宝退款接口
- 成功后等待回调,或者主动查退款状态
- 最终把退款单改成
SUCCESS,订单改成REFUNDED - 如果失败,就把订单从
REFUNDING回滚回PAID或CANCELLED
一个脱敏后的退款申请示例:
async function requestRefund(order: PaidOrder) { await db.transaction(async (tx) => { await tx.order.updateByCondition( { orderNo: order.orderNo, status: 'PAID', }, { status: 'REFUNDING', }, )
await tx.refund.create({ orderNo: order.orderNo, refundNo: generateRefundNo(), status: 'PENDING', amount: order.amount, }) })
await mq.emitRefundApply(order.orderNo)}退款消费侧则更适合这么做:
async function handleRefundApply(refundNo: string) { const locked = await refundRepository.updateByCondition( { refundNo, status: 'PENDING' }, { status: 'PROCESSING' }, )
if (!locked) return
try { await refundChannelService.applyRefund(refundNo) } catch (err) { await refundRepository.updateByCondition( { refundNo, status: 'PROCESSING' }, { status: 'PENDING' }, ) throw err }}这段逻辑背后其实就两个关键词:
- 先抢状态,再调用三方
- 失败可回退,方便重试
这样即使 MQ 重复投递,或者多个消费者并发消费,也不会把同一笔退款打出去多次。
再补一段文字版时序图:退款与补偿是怎么衔接的
退款链路如果只看“发起退款”这一个动作,会觉得很简单;但如果把补偿逻辑也一起看进去,它其实是这样跑的:
- 业务侧确认这笔订单需要退款,比如用户主动申请退款、活动取消自动退款、订单取消后发生后到款
- 服务端先把订单从
PAID推进到REFUNDING - 同时创建一笔退款单,初始状态一般是
PENDING - 异步消费者拿到退款任务后,先把退款单从
PENDING改成PROCESSING - 状态抢占成功后,才真正调用微信收付通或易宝的退款接口
- 如果渠道很快回调退款成功,那么本地就把退款单推进到
SUCCESS,订单推进到REFUNDED - 如果回调没到,但渠道侧其实已经退款成功,那么巡检或主动查退款状态会把这一步补回来
- 如果退款申请调用失败,或者处理中超时,那么系统会把退款单回退到可重试状态,等待下次补投或人工介入
也就是说,退款系统真正稳定的关键不在“我会不会调退款接口”,而在于:
- 我有没有把退款拆成独立状态机
- 我能不能知道这笔退款卡在哪一层
- 我能不能在失败后继续重试,而不是直接把单子卡死
很多线上资金异常,其实并不是退款彻底失败了,而是系统失去了继续推进它的能力。补偿设计存在的意义,就是不要让这种事情发生。
七、巡检为什么是支付系统的必备能力
如果让我只保留支付系统中的一个兜底机制,我大概率会选巡检。
因为支付回调并不总是可靠,MQ 也并不总是一次成功,网络更不可能永远稳定。真正决定系统是否稳的,往往是你有没有后补能力。
我在实际项目里比较常见的巡检有这几类:
1、超时未支付订单巡检
定时扫描还处于待支付状态、且创建时间已经超时的订单,然后自动取消。
这样可以持续清理无效支付单。
2、支付结果巡检
如果用户主动刷新订单详情,或者系统发现一笔订单长时间没收到回调,就主动去微信收付通或易宝查单。
只要渠道明确告诉你“已经支付成功”,那本地状态就应该补齐。
3、退款结果巡检
对于已经进入 PROCESSING 的退款单,定时向渠道查询退款状态。
如果回调没到,但渠道已经退款成功,就由巡检来补写本地状态。
4、异常退款补投
有些退款单卡在 PENDING 很久,本质上可能是退款申请消息没发出去,或者消费者处理时失败了。这个时候巡检任务可以把这些退款单重新投递一遍。
一个简单的脱敏示例:
@Cron('*/10 * * * *')async function inspectRefundOrders() { const refunds = await refundRepository.findTimeoutProcessingOrders()
for (const refund of refunds) { await mq.emitRefundInspect(refund.refundNo) }}这类巡检任务看起来很普通,但它是很多线上异常的最后一道保险。
八、这套链路是怎么保证稳定性的
这一部分我单独拎出来总结一下。
1、状态机幂等
所有核心状态流转都基于“当前状态”来更新,而不是无脑覆盖。
例如:
PENDING -> PAIDPAID -> REFUNDINGREFUNDING -> REFUNDEDPROCESSING -> SUCCESS
只要更新条件不满足,就说明这条数据已经被别的流程处理过了,这时候直接幂等返回即可。
2、回调 + 主动查单双保险
回调负责第一时间推进状态,查单负责兜底。
这也是我一直很推荐的一种思路:
不要把支付渠道回调当作唯一来源,而要把它当作“高优先级事件”。
真正的最终状态,应该允许系统通过查单、巡检、补偿再次确认。
3、延迟关单避免脏订单堆积
支付单创建成功后,就提前埋下一颗“超时取消”的定时炸弹。到了设定时间还没支付,系统就自动关单,这样能大幅减少后面需要人工处理的无效订单。
4、MQ 异步解耦 + 失败重试
像退款申请、关闭订单、巡检补偿这些操作,放到异步队列里会更稳一些。
原因很简单:
- 主流程可以更快返回
- 失败后更容易重试
- 可以削峰填谷
- 能把“业务提交”和“三方调用”分层处理
5、事务里改状态,事务后发消息
这个习惯非常重要。
如果你先发消息,再改数据库,万一数据库事务失败,就会出现“外部动作已经发生,但本地状态没落下来”的问题。
所以更稳妥的方式是:
- 先在事务里把订单、退款单状态改好
- 再在事务提交后投递 MQ
这样状态的一致性会好很多。
6、异常资金场景必须留人工入口
支付系统永远不可能 100% 靠自动化跑通所有异常。
比如:
- 订单已取消,但用户又支付成功
- 自动退款失败
- 三方渠道长时间处理中
- 本地和渠道状态已经明显不一致
这种时候最怕的是系统完全没有人工介入手段。比较稳妥的做法是,在后台提供:
- 重试退款
- 重新查单
- 手动补偿状态
- 标记异常单
这样即使自动流程没兜住,至少还能人为收口。
7、把工程原则单独拎出来看
如果把上面整条链路再抽象一层,我觉得支付系统能不能稳定,核心就落在下面这几条工程原则上。
第一,所有状态流转都要有前置条件
不要直接把订单从某个状态覆盖到另一个状态,而是要明确:
- 什么状态才能支付成功
- 什么状态才能发起退款
- 什么状态才能确认退款完成
- 什么状态才允许回滚或重试
本质上你是在把“业务规则”写进状态机,而不是散落在 if else 里。这样做最大的好处,就是系统面对并发时不会那么脆弱。
第二,事务里改状态,事务后发消息
这是我非常推荐的一条实践。
像退款申请、自动退款、延迟关单、关闭第三方支付单这些动作,通常都既涉及本地数据库,又涉及 MQ 或外部渠道调用。如果顺序处理不对,就很容易出现:
- 消息发出去了,但本地事务回滚了
- 本地状态已经改了,但外部动作根本没触发
- 同一笔单子进入了一半新状态、一半旧状态
所以更稳妥的方式永远是先把本地状态落稳,再把后续动作异步投出去。
第三,回调是高优先级事件,但不是唯一真相
很多系统天然会把第三方支付回调当成唯一依据,这其实很危险。
更合理的理解应该是:
- 回调是最快知道结果的入口
- 查单是补齐结果的工具
- 巡检是持续兜底的保险
也就是说,回调很重要,但它不应该成为系统唯一依赖的那根线。
第四,巡检不是附加功能,而是最后一道保险
很多人一开始会把巡检看成“出了问题再说”的辅助能力,但真正在支付场景里,它经常是最关键的收口手段。
因为现实世界里一定会有:
- 回调短暂不可达
- MQ 消费失败
- 外部渠道处理中时间过长
- 本地状态与三方状态短暂不一致
这些都很正常。系统真正成熟的标志,不是完全不出异常,而是异常出现后还能靠巡检把链路继续向前推。
第五,资金异常必须允许人工接管
支付是少数那种“系统错一次,代价就很真实”的场景。
所以无论自动化做得多好,都应该给后台保留人工入口,让运营、财务或者研发在必要时可以:
- 重新发起退款
- 主动查订单状态
- 主动查退款状态
- 标记异常单并继续处理
自动化负责覆盖大多数场景,人工接管负责收尾那些极端但高风险的边角案例,这样整个系统才是闭环的。
九、我对支付稳定性的一点理解
如果只站在“接口调通”的视角去看支付,会觉得支付就是几个 API:下单、回调、退款、查单。
但如果你站在“线上长期稳定运行”的视角去看,支付其实是一个持续纠偏的过程。
它不是要求每个动作都一次成功,而是要求:
- 即使回调丢了,系统还能查回来
- 即使退款失败了,系统还能重试
- 即使订单取消后后到款了,系统还能自动退款
- 即使自动化都失效了,人工还能接得住
说到底,支付稳定性的核心不是“绝不出错”,而是:
任何一步出错之后,系统仍然有能力把状态拉回正确轨道。
总结
最后把这篇文章的重点收一下。
一条比较稳的支付链路,通常至少要覆盖这几件事:
- 支付前做严格的业务态校验
- 支付成功依赖回调,但不只依赖回调
- 取消支付单要区分未支付取消和已支付退款
- 退款单要有独立状态机
- 通过延迟关单清理超时订单
- 通过巡检任务补偿支付和退款状态
- 通过 MQ 重试和人工入口兜住极端异常
如果你也在做支付系统,我的建议是尽量不要把它只当成“第三方接口对接”问题来看。真正难的部分,永远是在接口之外:并发、状态一致性、异常补偿、资金安全。
这些东西做好了,支付链路才真的算稳。