Home
avatar

.𝙃𝙖𝙣

支付状态机和异步回调,我是怎么把它做稳的

这篇接 papain 的第一篇总起文往下写,主要记项目里最硬的一块:支付状态机和异步回调。

前一篇写的是,为什么这个项目让我开始真正理解“稳定交付”。这篇就落到具体技术和业务动作上。因为 papain 这条链里,真正最不能乱的地方,就是支付结果怎么落、异步回调怎么接、状态怎么同步、重复触发怎么拦、失败以后怎么补。

1. 先把问题说透:支付接进系统以后,最难收的是“这次状态变化到底算不算数”

一开始做支付时,最自然的理解通常是这样的:

  • 用户发起支付
  • 第三方支付成功
  • 回调通知系统
  • 系统把订单状态改成已支付
  • 后续业务继续往下跑

这条线看起来非常顺。

问题是,真实业务里它几乎从来不会这么干净。

因为一旦支付进了项目,后面马上就会跟出来很多现实问题:

  • 回调可能会重复来
  • 回调可能会延迟来
  • 用户前端已经支付成功,但后端还没收到通知
  • 系统已经更新过一次状态,后面又来一次旧回调
  • 支付成功了,但终端任务没跟上
  • 终端任务已经推进过了,支付状态更新又晚到

所以真正要盯住的是:

系统要怎么判断,这次回调带来的状态变化到底能不能写进去,写进去以后又会影响哪一段后续链。

这个问题一旦没收住,后面支付、任务、终端执行这几层都会被带乱。

2. 我最早踩的坑,是把支付状态想得太简单了

如果只从页面展示看,支付状态好像就那么几种:

  • 待支付
  • 已支付
  • 已取消
  • 失败

但真放到业务系统里,这种理解很快就不够了。

因为系统要处理的事,比“用户看到哪个标签”多得多:

  • 当前订单有没有发起支付
  • 第三方有没有明确成功
  • 回调有没有验过签
  • 业务侧有没有消费掉这次支付结果
  • 后续拍报任务有没有被触发
  • 整条链最终是不是闭环了

这些步骤如果全压成一个“已支付”,后面很多判断都会很别扭。

所以我后来越来越倾向于把支付这件事拆成两层去看:

第一层:支付结果状态

比如:

  • 待支付
  • 支付中
  • 支付成功
  • 支付失败

第二层:业务消费状态

比如:

  • 支付结果已收到但未处理
  • 支付结果已消费
  • 后续任务已触发
  • 后续链路执行异常待补偿

这个拆法对我帮助特别大。

因为它把“钱到没到”和“业务有没有接住这笔钱”分开了。

这两个状态分不开,后面很多问题都没法判断清楚。

3. 这也是我第一次比较明确地把状态机当成业务模型来看,而不是代码技巧

以前听到“状态机”这个词时,我多少会把它想得有点工程味。

像是:

  • 把状态列一列
  • 把流转关系写一写
  • 代码里按规则跳一跳

后来做 papain 才发现,状态机在这里根本不是为了让代码显得高级。

它更像是业务系统必须有的一张规则图。

因为支付这件事一旦进来,后端一定要先回答:

  • 哪些状态可以往前走
  • 哪些状态不能再重复消费
  • 哪些状态下允许重入
  • 哪些状态变化必须被拒绝

比如:

  • 已支付的订单,后面再来一条成功回调,能不能再触发一次后续任务
  • 已取消的订单,再收到旧支付成功通知,系统应该怎么处理
  • 已经推进到终端执行的任务,如果支付状态被旧数据覆盖,会不会把整条链带乱

这些问题说到底都是明确的业务规则问题。

所以我后来对状态机的理解变得很直接:

状态机的价值,是把“允许发生什么、不允许发生什么”提前写清楚。

这样系统后面才不至于全靠 if else 硬顶。

4. 异步回调最麻烦的,是它来的时机经常不刚好

异步这件事本身不稀奇。

真正难受的是,它来的时机经常不刚好。

这在支付场景里尤其明显。

因为你会碰到各种时间顺序问题:

  • 用户前端已经看到支付成功了,回调才晚一点到
  • 回调已经到了,但业务线程处理还没落库
  • 同一笔支付重复回调了两次
  • 一次回调先到,另一次更旧的数据后到
  • 回调到的时候,订单状态已经被别的流程推进过了

所以异步回调最核心的问题,其实是:

系统能不能在任意时机收到这条消息时,依然做出正确判断。

所以我特别在意:

  • 当前订单状态是什么
  • 上一次处理到哪一步了
  • 这条回调是不是重复消费
  • 这次状态写入会不会覆盖掉更晚、更正确的状态

这层不想清楚,接口就算能收回调,系统也未必稳。

5. 幂等在这里,已经是主功能的一部分

前面做别的业务时,我也会接触到幂等,但在 papain 这里,这件事的重量明显不一样。

因为支付和终端动作都不适合重复推进。

比如:

  • 同一笔支付被消费两次
  • 同一个拍报任务被触发两次
  • 同一条订单记录被重复写成不同状态

这些都不是“小问题”。

所以放在这类链路里,幂等直接就是主功能的一部分。

我当时越来越在意的几个点,基本都和这个有关:

  • 同一个订单号是不是只允许被成功消费一次
  • 同一个成功回调多次到达时,后面动作是不是还能继续往下跑
  • 某一步已经成功推进过了,重复请求是不是应该被短路掉

这个阶段我对幂等最实际的理解就是:

系统要能认出“这件事我已经处理过了”,并且后续别再重复做一遍。

这件事说起来简单,落到代码里其实很值钱。

6. 我对“支付成功”这件事的理解,从“状态改掉”变成“业务闭环往下走”

一开始会很自然地觉得,支付成功的核心动作就是把订单状态改成已支付。

这个动作当然必须做,而且很重要。

但项目做下去以后,我会更在意另外一层:

状态改掉以后,后面的业务到底有没有真正被接上。

因为在 papain 里,支付走完以后,后面的拍报链路才刚开始。

支付成功以后,后面至少还会牵出这些动作:

  • 触发拍报任务
  • 更新终端执行状态
  • 记录支付和执行映射
  • 留下后续对账和排查依据

所以“支付成功”如果只停在订单表里一个状态变化,那其实只是完成了一半。

另一半是:

  • 后续链有没有开始
  • 后续链的状态有没有对上
  • 前后记录是不是能串起来

也正因为这样,我后来会把支付回调处理看成一个“状态推进入口”,不是一个“单点更新接口”。

7. 到这个阶段,我开始特别在意“状态变更有没有证据链”

这点和我前面写日志那篇是连上的。

因为支付、回调、终端动作、任务推进这些东西一旦全混在一起,没有证据链的话,后面会非常难查。

我后来会更在意这些记录到底有没有:

  • 这笔支付什么时候创建
  • 什么时候收到成功通知
  • 回调验签有没有过
  • 当前订单状态从什么改成了什么
  • 后续拍报任务什么时候被触发
  • 如果失败了,卡在了哪一步

这些信息平时看起来像在“多记东西”。

真出问题时,它们就是唯一能把支付、订单、终端动作重新串起来的证据链。

所以我后来对“状态机”和“日志”是绑着看的。

因为状态机告诉你应该怎么流转,日志负责告诉你它实际是怎么流转过的。

8. 回头看,这块最难收的,是“支付状态”和“业务状态”要长期同步

这应该算我做完这一段以后,最明确的一个感受。

如果只是接一次支付接口、收一次回调,其实事情没有那么难。

麻烦就在这里,系统得长期保证两套东西尽量一致:

第一套:支付状态

也就是第三方支付世界里的结果。

第二套:业务状态

也就是系统内部订单、任务、终端执行这一侧的结果。

问题就在于,这两套状态并不是天然同步的。

中间会隔着:

  • 网络
  • 回调延迟
  • 重复通知
  • 状态覆盖
  • 下游执行失败
  • 补偿逻辑

所以后端要做的,除了“接收支付结果”,还得想办法让这两套状态尽量对齐,至少出问题时还能知道差在哪。

这个工作量,比单纯接一个支付能力大得多。

9. 我更愿意把“支付状态机”看成系统可信度的一部分

以前我会觉得状态机更偏实现细节。

到了 papain,这个看法明显变了。

因为你会发现,支付状态机写得清不清楚,直接影响的是:

  • 同一笔业务会不会被重复推进
  • 出问题时状态还能不能还原
  • 回调乱序时系统能不能稳住
  • 后面财务、运营能不能信任系统记录

它当然能让开发少写一些 if else。

它其实是在决定:

系统到底值不值得被信任。

这层感觉一旦有了,对“状态机”这件事的态度就会完全不一样。

10. 阶段结论

  1. 支付接入本身不难收,真正要盯的是支付结果怎么被业务可靠消费
  2. 支付状态和业务状态要分开看,不然后面很多问题会混在一起
  3. 异步回调最难处理的,往往是它到达时机不稳定
  4. 幂等在这类链路里是主功能的一部分,不是后面补的安全网
  5. 支付状态机当然能让代码更整齐,但更重要的是它能让系统在重复、延迟、乱序情况下还保持可信

这篇先写到这里。

.NET 支付 状态机 异步回调 papain