5572 字
28 分钟

支付链路怎么设计才稳?从支付、退款到巡检补偿的完整实践

2026-05-29
pay/paylinks
加载中...

前言#

前段时间我在项目里梳理了一套比较完整的支付链路,里面既有微信收付通,也有易宝支付。最开始做支付的时候,很多人关注的是“怎么调起支付”“怎么发起退款”,但真正到了线上之后你会发现,支付系统最难的其实不是把接口调通,而是怎么把链路做稳

因为支付这件事天然就会遇到很多麻烦事:

  • 用户点了支付,但是回调晚到了
  • 用户取消了订单,结果第三方渠道又异步通知支付成功
  • 退款接口调用成功了,但本地状态没更新
  • MQ 丢了、回调失败了、网络抖动了,状态开始对不上
  • 订单明明已经支付成功,前端却还显示待支付

所以一个能上线的支付系统,一定不是“回调来了就改状态”这么简单,而是要把支付、取消、退款、查单、巡检、补偿、人工兜底全都串起来。

这篇文章我就结合一套实际项目经验,聊聊一条完整的支付链路通常该怎么设计。

思路#

我的理解里,稳定的支付链路一般要有下面几层保障:

  1. 入口校验:创建支付前先确认订单状态、活动状态、支付方式是否合法
  2. 状态机幂等:所有状态流转都要带前置条件,避免并发重复处理
  3. 回调驱动:支付成功、退款成功优先依赖渠道回调推进状态
  4. 主动查单:不能完全依赖回调,必要时主动向渠道查询订单或退款状态
  5. 延迟关单:长时间未支付的订单自动取消,避免脏数据堆积
  6. 巡检补偿:定时扫描异常订单、退款单,自动重试和补偿
  7. 人工兜底:极端情况下给运营或后台留重试入口

也就是说,支付系统不是单点成功,而是多层防线共同兜底

一、先看完整链路#

如果把链路抽象一下,大概就是这样:

  1. 用户创建支付单
  2. 服务端按渠道分流,走微信收付通或易宝
  3. 下单成功后,投递一条“延迟关单”消息
  4. 用户完成支付,第三方支付渠道异步回调
  5. 服务端验签、解密、幂等更新订单状态
  6. 如果订单已经被取消,但钱又到了,就自动转退款
  7. 退款成功后,渠道再次回调,或者系统主动查退款状态
  8. 如果中间任何一步没走通,就交给巡检任务继续补偿

这里最关键的一点是:

支付、退款、取消订单,这三个动作不能各写各的,它们本质上是同一个状态机上的不同分支。

补一段文字版时序图:支付主链路到底是怎么跑的#

如果把一次正常支付过程再拆细一点,它通常会按下面这个顺序推进:

  1. 用户提交订单,服务端校验订单状态、商品或活动状态、支付渠道配置
  2. 服务端调用微信收付通或者易宝的下单接口,拿到调起支付所需参数
  3. 本地下单成功后,立即投递一条延迟关单消息,给“超时未支付”预留自动取消能力
  4. 用户在微信或其他支付收银台完成付款
  5. 第三方支付渠道异步回调服务端,通知这笔订单已经支付成功
  6. 服务端先验签、再解密、再做状态判断,最后用带前置条件的更新把订单从 PENDING 推进到 PAID
  7. 如果这时发现本地订单已经被取消,那么就不再恢复订单,而是直接进入自动退款流程
  8. 如果回调没到,或者处理失败,后续还可以通过主动查单和巡检任务把状态补回来

这段时序里我最看重的是第 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 分钟后再来检查这笔订单还要不要继续保留。

三、支付成功为什么不能只靠回调#

很多刚接支付的人都会以为:渠道回调到了,我把订单改成已支付,这事就结束了。

实际上并不是。

回调当然很重要,但回调不是唯一真相,它只是一个信号。

因为线上一定会碰到这些情况:

  • 回调网络超时
  • 回调被网关拦截
  • 回调消费时报错
  • 回调重复推送
  • 回调比取消订单更晚到达

所以支付回调的正确打开方式应该是:

  1. 先验签
  2. 再解密
  3. 判断支付结果
  4. 做幂等更新
  5. 必要时走自动退款逻辑

一个脱敏后的回调处理示例:

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. 用户创建了支付单
  2. 系统因为超时把订单取消了
  3. 但第三方支付渠道那边,用户其实刚好支付成功
  4. 于是渠道回调又告诉你,这笔钱已经付过来了

如果这个时候你只是简单地拒绝回调,那资金和业务状态就对不上了。

更合理的方式是:

  • 本地订单已经取消,但渠道确认支付成功时,不再恢复为正常订单,而是直接进入退款流程

这也是为什么我一直觉得,支付系统里退款不是一个附属功能,而是主链路的一部分。

五、取消支付单怎么设计#

取消支付单一般至少要分两种情况:

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

也就是说,取消支付单和退款在已支付场景下,其实是同一件事

六、退款链路怎么做才不容易翻车#

很多支付系统在退款这里会变得很脆弱,原因是退款通常比支付更复杂。

因为支付一般是“用户主动发起一次”,而退款往往会受到这些因素影响:

  • 自动退款
  • 用户主动退款
  • 活动取消批量退款
  • 渠道退款处理中
  • 渠道退款失败
  • 本地已记退款中,但渠道其实没收到申请

所以我比较推荐退款单单独建表,维护自己的状态机,比如:

  • PENDING
  • PROCESSING
  • SUCCESS
  • FAIL
  • ABNORMAL
  • CLOSED

退款主流程可以设计成这样:

  1. 订单从 PAID 进入 REFUNDING
  2. 创建退款单,状态初始为 PENDING
  3. 异步消费者消费退款任务,把退款单推进到 PROCESSING
  4. 调用微信收付通或易宝退款接口
  5. 成功后等待回调,或者主动查退款状态
  6. 最终把退款单改成 SUCCESS,订单改成 REFUNDED
  7. 如果失败,就把订单从 REFUNDING 回滚回 PAIDCANCELLED

一个脱敏后的退款申请示例:

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 重复投递,或者多个消费者并发消费,也不会把同一笔退款打出去多次。

再补一段文字版时序图:退款与补偿是怎么衔接的#

退款链路如果只看“发起退款”这一个动作,会觉得很简单;但如果把补偿逻辑也一起看进去,它其实是这样跑的:

  1. 业务侧确认这笔订单需要退款,比如用户主动申请退款、活动取消自动退款、订单取消后发生后到款
  2. 服务端先把订单从 PAID 推进到 REFUNDING
  3. 同时创建一笔退款单,初始状态一般是 PENDING
  4. 异步消费者拿到退款任务后,先把退款单从 PENDING 改成 PROCESSING
  5. 状态抢占成功后,才真正调用微信收付通或易宝的退款接口
  6. 如果渠道很快回调退款成功,那么本地就把退款单推进到 SUCCESS,订单推进到 REFUNDED
  7. 如果回调没到,但渠道侧其实已经退款成功,那么巡检或主动查退款状态会把这一步补回来
  8. 如果退款申请调用失败,或者处理中超时,那么系统会把退款单回退到可重试状态,等待下次补投或人工介入

也就是说,退款系统真正稳定的关键不在“我会不会调退款接口”,而在于:

  • 我有没有把退款拆成独立状态机
  • 我能不能知道这笔退款卡在哪一层
  • 我能不能在失败后继续重试,而不是直接把单子卡死

很多线上资金异常,其实并不是退款彻底失败了,而是系统失去了继续推进它的能力。补偿设计存在的意义,就是不要让这种事情发生。

七、巡检为什么是支付系统的必备能力#

如果让我只保留支付系统中的一个兜底机制,我大概率会选巡检。

因为支付回调并不总是可靠,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 -> PAID
  • PAID -> REFUNDING
  • REFUNDING -> REFUNDED
  • PROCESSING -> SUCCESS

只要更新条件不满足,就说明这条数据已经被别的流程处理过了,这时候直接幂等返回即可。

2、回调 + 主动查单双保险#

回调负责第一时间推进状态,查单负责兜底。

这也是我一直很推荐的一种思路:

不要把支付渠道回调当作唯一来源,而要把它当作“高优先级事件”。

真正的最终状态,应该允许系统通过查单、巡检、补偿再次确认。

3、延迟关单避免脏订单堆积#

支付单创建成功后,就提前埋下一颗“超时取消”的定时炸弹。到了设定时间还没支付,系统就自动关单,这样能大幅减少后面需要人工处理的无效订单。

4、MQ 异步解耦 + 失败重试#

像退款申请、关闭订单、巡检补偿这些操作,放到异步队列里会更稳一些。

原因很简单:

  • 主流程可以更快返回
  • 失败后更容易重试
  • 可以削峰填谷
  • 能把“业务提交”和“三方调用”分层处理

5、事务里改状态,事务后发消息#

这个习惯非常重要。

如果你先发消息,再改数据库,万一数据库事务失败,就会出现“外部动作已经发生,但本地状态没落下来”的问题。

所以更稳妥的方式是:

  • 先在事务里把订单、退款单状态改好
  • 再在事务提交后投递 MQ

这样状态的一致性会好很多。

6、异常资金场景必须留人工入口#

支付系统永远不可能 100% 靠自动化跑通所有异常。

比如:

  • 订单已取消,但用户又支付成功
  • 自动退款失败
  • 三方渠道长时间处理中
  • 本地和渠道状态已经明显不一致

这种时候最怕的是系统完全没有人工介入手段。比较稳妥的做法是,在后台提供:

  • 重试退款
  • 重新查单
  • 手动补偿状态
  • 标记异常单

这样即使自动流程没兜住,至少还能人为收口。

7、把工程原则单独拎出来看#

如果把上面整条链路再抽象一层,我觉得支付系统能不能稳定,核心就落在下面这几条工程原则上。

第一,所有状态流转都要有前置条件#

不要直接把订单从某个状态覆盖到另一个状态,而是要明确:

  • 什么状态才能支付成功
  • 什么状态才能发起退款
  • 什么状态才能确认退款完成
  • 什么状态才允许回滚或重试

本质上你是在把“业务规则”写进状态机,而不是散落在 if else 里。这样做最大的好处,就是系统面对并发时不会那么脆弱。

第二,事务里改状态,事务后发消息#

这是我非常推荐的一条实践。

像退款申请、自动退款、延迟关单、关闭第三方支付单这些动作,通常都既涉及本地数据库,又涉及 MQ 或外部渠道调用。如果顺序处理不对,就很容易出现:

  • 消息发出去了,但本地事务回滚了
  • 本地状态已经改了,但外部动作根本没触发
  • 同一笔单子进入了一半新状态、一半旧状态

所以更稳妥的方式永远是先把本地状态落稳,再把后续动作异步投出去。

第三,回调是高优先级事件,但不是唯一真相#

很多系统天然会把第三方支付回调当成唯一依据,这其实很危险。

更合理的理解应该是:

  • 回调是最快知道结果的入口
  • 查单是补齐结果的工具
  • 巡检是持续兜底的保险

也就是说,回调很重要,但它不应该成为系统唯一依赖的那根线。

第四,巡检不是附加功能,而是最后一道保险#

很多人一开始会把巡检看成“出了问题再说”的辅助能力,但真正在支付场景里,它经常是最关键的收口手段。

因为现实世界里一定会有:

  • 回调短暂不可达
  • MQ 消费失败
  • 外部渠道处理中时间过长
  • 本地状态与三方状态短暂不一致

这些都很正常。系统真正成熟的标志,不是完全不出异常,而是异常出现后还能靠巡检把链路继续向前推。

第五,资金异常必须允许人工接管#

支付是少数那种“系统错一次,代价就很真实”的场景。

所以无论自动化做得多好,都应该给后台保留人工入口,让运营、财务或者研发在必要时可以:

  • 重新发起退款
  • 主动查订单状态
  • 主动查退款状态
  • 标记异常单并继续处理

自动化负责覆盖大多数场景,人工接管负责收尾那些极端但高风险的边角案例,这样整个系统才是闭环的。

九、我对支付稳定性的一点理解#

如果只站在“接口调通”的视角去看支付,会觉得支付就是几个 API:下单、回调、退款、查单。

但如果你站在“线上长期稳定运行”的视角去看,支付其实是一个持续纠偏的过程。

它不是要求每个动作都一次成功,而是要求:

  • 即使回调丢了,系统还能查回来
  • 即使退款失败了,系统还能重试
  • 即使订单取消后后到款了,系统还能自动退款
  • 即使自动化都失效了,人工还能接得住

说到底,支付稳定性的核心不是“绝不出错”,而是:

任何一步出错之后,系统仍然有能力把状态拉回正确轨道。

总结#

最后把这篇文章的重点收一下。

一条比较稳的支付链路,通常至少要覆盖这几件事:

  • 支付前做严格的业务态校验
  • 支付成功依赖回调,但不只依赖回调
  • 取消支付单要区分未支付取消和已支付退款
  • 退款单要有独立状态机
  • 通过延迟关单清理超时订单
  • 通过巡检任务补偿支付和退款状态
  • 通过 MQ 重试和人工入口兜住极端异常

如果你也在做支付系统,我的建议是尽量不要把它只当成“第三方接口对接”问题来看。真正难的部分,永远是在接口之外:并发、状态一致性、异常补偿、资金安全

这些东西做好了,支付链路才真的算稳。

支付链路怎么设计才稳?从支付、退款到巡检补偿的完整实践
https://www.zhaoyuqi.top/posts/pay/paylinks/
作者
爱哭的赵一一
发布于
2026-05-29
许可协议
CC BY-NC-SA 4.0