支付状态机和异步回调,我是怎么把它做稳的
这篇接 papain 的第一篇总起文往下写,主要记项目里最硬的一块:支付状态机和异步回调。
前一篇写的是,为什么这个项目让我开始真正理解“稳定交付”。这篇就落到具体技术和业务动作上。因为 papain 这条链里,真正最不能乱的地方,就是支付结果怎么落、异步回调怎么接、状态怎么同步、重复触发怎么拦、失败以后怎么补。
1. 先把问题说透:支付接进系统以后,最难收的是“这次状态变化到底算不算数”
一开始做支付时,最自然的理解通常是这样的:
- 用户发起支付
- 第三方支付成功
- 回调通知系统
- 系统把订单状态改成已支付
- 后续业务继续往下跑
这条线看起来非常顺。
问题是,真实业务里它几乎从来不会这么干净。
因为一旦支付进了项目,后面马上就会跟出来很多现实问题:
- 回调可能会重复来
- 回调可能会延迟来
- 用户前端已经支付成功,但后端还没收到通知
- 系统已经更新过一次状态,后面又来一次旧回调
- 支付成功了,但终端任务没跟上
- 终端任务已经推进过了,支付状态更新又晚到
所以真正要盯住的是:
系统要怎么判断,这次回调带来的状态变化到底能不能写进去,写进去以后又会影响哪一段后续链。
这个问题一旦没收住,后面支付、任务、终端执行这几层都会被带乱。
2. 我最早踩的坑,是把支付状态想得太简单了
如果只从页面展示看,支付状态好像就那么几种:
- 待支付
- 已支付
- 已取消
- 失败
但真放到业务系统里,这种理解很快就不够了。
因为系统要处理的事,比“用户看到哪个标签”多得多:
- 当前订单有没有发起支付
- 第三方有没有明确成功
- 回调有没有验过签
- 业务侧有没有消费掉这次支付结果
- 后续拍报任务有没有被触发
- 整条链最终是不是闭环了
这些步骤如果全压成一个“已支付”,后面很多判断都会很别扭。
所以我后来越来越倾向于把支付这件事拆成两层去看:
第一层:支付结果状态
比如:
- 待支付
- 支付中
- 支付成功
- 支付失败
第二层:业务消费状态
比如:
- 支付结果已收到但未处理
- 支付结果已消费
- 后续任务已触发
- 后续链路执行异常待补偿
这个拆法对我帮助特别大。
因为它把“钱到没到”和“业务有没有接住这笔钱”分开了。
这两个状态分不开,后面很多问题都没法判断清楚。
3. 这也是我第一次比较明确地把状态机当成业务模型来看,而不是代码技巧
以前听到“状态机”这个词时,我多少会把它想得有点工程味。
像是:
- 把状态列一列
- 把流转关系写一写
- 代码里按规则跳一跳
后来做 papain 才发现,状态机在这里根本不是为了让代码显得高级。
它更像是业务系统必须有的一张规则图。
因为支付这件事一旦进来,后端一定要先回答:
- 哪些状态可以往前走
- 哪些状态不能再重复消费
- 哪些状态下允许重入
- 哪些状态变化必须被拒绝
比如:
- 已支付的订单,后面再来一条成功回调,能不能再触发一次后续任务
- 已取消的订单,再收到旧支付成功通知,系统应该怎么处理
- 已经推进到终端执行的任务,如果支付状态被旧数据覆盖,会不会把整条链带乱
这些问题说到底都是明确的业务规则问题。
所以我后来对状态机的理解变得很直接:
状态机的价值,是把“允许发生什么、不允许发生什么”提前写清楚。
这样系统后面才不至于全靠 if else 硬顶。
4. 异步回调最麻烦的,是它来的时机经常不刚好
异步这件事本身不稀奇。
真正难受的是,它来的时机经常不刚好。
这在支付场景里尤其明显。
因为你会碰到各种时间顺序问题:
- 用户前端已经看到支付成功了,回调才晚一点到
- 回调已经到了,但业务线程处理还没落库
- 同一笔支付重复回调了两次
- 一次回调先到,另一次更旧的数据后到
- 回调到的时候,订单状态已经被别的流程推进过了
所以异步回调最核心的问题,其实是:
系统能不能在任意时机收到这条消息时,依然做出正确判断。
所以我特别在意:
- 当前订单状态是什么
- 上一次处理到哪一步了
- 这条回调是不是重复消费
- 这次状态写入会不会覆盖掉更晚、更正确的状态
这层不想清楚,接口就算能收回调,系统也未必稳。
5. 幂等在这里,已经是主功能的一部分
前面做别的业务时,我也会接触到幂等,但在 papain 这里,这件事的重量明显不一样。
因为支付和终端动作都不适合重复推进。
比如:
- 同一笔支付被消费两次
- 同一个拍报任务被触发两次
- 同一条订单记录被重复写成不同状态
这些都不是“小问题”。
所以放在这类链路里,幂等直接就是主功能的一部分。
我当时越来越在意的几个点,基本都和这个有关:
- 同一个订单号是不是只允许被成功消费一次
- 同一个成功回调多次到达时,后面动作是不是还能继续往下跑
- 某一步已经成功推进过了,重复请求是不是应该被短路掉
这个阶段我对幂等最实际的理解就是:
系统要能认出“这件事我已经处理过了”,并且后续别再重复做一遍。
这件事说起来简单,落到代码里其实很值钱。
6. 我对“支付成功”这件事的理解,从“状态改掉”变成“业务闭环往下走”
一开始会很自然地觉得,支付成功的核心动作就是把订单状态改成已支付。
这个动作当然必须做,而且很重要。
但项目做下去以后,我会更在意另外一层:
状态改掉以后,后面的业务到底有没有真正被接上。
因为在 papain 里,支付走完以后,后面的拍报链路才刚开始。
支付成功以后,后面至少还会牵出这些动作:
- 触发拍报任务
- 更新终端执行状态
- 记录支付和执行映射
- 留下后续对账和排查依据
所以“支付成功”如果只停在订单表里一个状态变化,那其实只是完成了一半。
另一半是:
- 后续链有没有开始
- 后续链的状态有没有对上
- 前后记录是不是能串起来
也正因为这样,我后来会把支付回调处理看成一个“状态推进入口”,不是一个“单点更新接口”。
7. 到这个阶段,我开始特别在意“状态变更有没有证据链”
这点和我前面写日志那篇是连上的。
因为支付、回调、终端动作、任务推进这些东西一旦全混在一起,没有证据链的话,后面会非常难查。
我后来会更在意这些记录到底有没有:
- 这笔支付什么时候创建
- 什么时候收到成功通知
- 回调验签有没有过
- 当前订单状态从什么改成了什么
- 后续拍报任务什么时候被触发
- 如果失败了,卡在了哪一步
这些信息平时看起来像在“多记东西”。
真出问题时,它们就是唯一能把支付、订单、终端动作重新串起来的证据链。
所以我后来对“状态机”和“日志”是绑着看的。
因为状态机告诉你应该怎么流转,日志负责告诉你它实际是怎么流转过的。
8. 回头看,这块最难收的,是“支付状态”和“业务状态”要长期同步
这应该算我做完这一段以后,最明确的一个感受。
如果只是接一次支付接口、收一次回调,其实事情没有那么难。
麻烦就在这里,系统得长期保证两套东西尽量一致:
第一套:支付状态
也就是第三方支付世界里的结果。
第二套:业务状态
也就是系统内部订单、任务、终端执行这一侧的结果。
问题就在于,这两套状态并不是天然同步的。
中间会隔着:
- 网络
- 回调延迟
- 重复通知
- 状态覆盖
- 下游执行失败
- 补偿逻辑
所以后端要做的,除了“接收支付结果”,还得想办法让这两套状态尽量对齐,至少出问题时还能知道差在哪。
这个工作量,比单纯接一个支付能力大得多。
9. 我更愿意把“支付状态机”看成系统可信度的一部分
以前我会觉得状态机更偏实现细节。
到了 papain,这个看法明显变了。
因为你会发现,支付状态机写得清不清楚,直接影响的是:
- 同一笔业务会不会被重复推进
- 出问题时状态还能不能还原
- 回调乱序时系统能不能稳住
- 后面财务、运营能不能信任系统记录
它当然能让开发少写一些 if else。
它其实是在决定:
系统到底值不值得被信任。
这层感觉一旦有了,对“状态机”这件事的态度就会完全不一样。
10. 阶段结论
- 支付接入本身不难收,真正要盯的是支付结果怎么被业务可靠消费
- 支付状态和业务状态要分开看,不然后面很多问题会混在一起
- 异步回调最难处理的,往往是它到达时机不稳定
- 幂等在这类链路里是主功能的一部分,不是后面补的安全网
- 支付状态机当然能让代码更整齐,但更重要的是它能让系统在重复、延迟、乱序情况下还保持可信
这篇先写到这里。
