结算状态机怎么设计
结算是那种"平时没人看,出事就是大事故"的模块。它的难点不在于逻辑复杂,而在于它同时要面对不可靠的外部渠道、重复的消息、和并发的修改。
三件事里任何一件处理不好,都会变成资损。
1. 先把状态画出来
主干通常不长:
待支付 → 支付中 → 已支付 → 已结算 →(终态)
↓
已关闭 / 已退款(终态)
设计的关键不是主干,而是三件事:哪些是终态、哪些转移是合法的、有没有能绕过去的路径。
1.1 终态必须明确
一个状态是终态,意味着它不能再回到任何中间态。
最常见的 bug 就出在这里:一笔已退款的订单,因为收到了一条迟到的支付成功回调,被改回了"已支付"。用户退了钱,订单又变成"已付款",然后发货了。
所以状态机的第一条纪律:终态不可逆转。收到与终态矛盾的转移请求时,忽略并记日志,不要执行。
1.2 合法转移要显式列出
不要写"如果状态是 X 就改成 Y"这种散落的判断。把转移表集中声明:
const TRANSITIONS: Record<Status, Status[]> = {
pending: ['paying', 'closed'],
paying: ['paid', 'pending', 'closed'], // 支付失败可以退回
paid: ['settled', 'refunding'],
refunding: ['refunded', 'paid'], // 退款失败退回
settled: [], // 终态
refunded: [], // 终态
closed: [], // 终态
}
function canMove(from: Status, to: Status) {
return TRANSITIONS[from].includes(to)
}
集中声明的好处是可以被测试穷举:任意两个状态的组合,要么在表里,要么必须被拒绝。
1.3 检查有没有"绕过去的路"
状态机画好之后,要检查代码里有没有绕过它的路径:
- 后台管理界面能不能直接改状态?
- 数据库有没有被直接 UPDATE 的脚本?
- 定时任务会不会跳过状态检查?
这些都是真实存在的绕过方式,而且通常是最先出事的。
2. 幂等:同一条消息会来多次
支付渠道的回调保证至少一次,不保证恰好一次。同一条支付成功通知可能因为网络重试、你的响应超时、对方的重发策略而到达多次。
2.1 幂等键
每条外部消息带一个唯一 ID(渠道侧的流水号或事件 ID)。处理前先查这张表:
CREATE TABLE processed_events (
event_id VARCHAR(64) PRIMARY KEY,
order_id BIGINT NOT NULL,
processed_at TIMESTAMP NOT NULL,
result JSONB
);
处理流程是:插入 event_id → 成功才继续处理 → 已存在就直接返回之前的结果。
用数据库的唯一约束而不是"先查再插"——后者在并发下会失效。
2.2 状态转移本身也要可重入
即便做了事件去重,状态转移也要写成可重入的:
UPDATE orders
SET status = 'paid', paid_at = COALESCE(paid_at, $2)
WHERE id = $1 AND status IN ('pending', 'paying')
WHERE 里的状态条件是核心:如果状态已经不是这两个,更新会命中 0 行——这就是"重复消息被安全忽略"的机制。
用条件更新,不要用"读出来判断再写回"。后者在并发下会有竞态:两个请求都读到 paying,都判断合法,都写入 paid,然后重复执行了后续逻辑(发货、记账)。
条件更新把判断和写入合成了一个原子操作,这是这类问题最干净的解法。
2.3 幂等的粒度
幂等键应该是业务动作而不是"请求"。比如"这笔订单支付成功"是一个幂等单元,"收到一条回调"不是——因为同一笔支付可能对应多条格式不同的消息。
判断标准:同一个幂等键重复执行 N 次,业务结果应该和执行 1 次完全一样,包括副作用(发货、记账、通知)。
3. 并发
同一笔订单可能被同时操作:用户在前端点支付、后台运营在改状态、定时任务在做超时关闭。
三个可选手段:
乐观锁(版本号)。 表里带 version,更新时带上 WHERE version = $n,命中 0 行说明被别人改过,重试或报错。适合冲突不频繁的场景。
行锁。 SELECT ... FOR UPDATE。冲突频繁时用,但要控制持锁时间——不要在持锁期间调外部接口。
条件更新。 就是 2.2 那个写法。对状态机来说它其实是最常用的,因为大多数转移本来就有前置状态约束。
实践中经常是"条件更新 + 版本号"组合:条件更新保证语义正确,版本号让重试能发现冲突。
4. 外部渠道的状态才是真相
一条容易被忽略的原则:你的状态是副本,渠道的状态是事实。
你的库里写着"已支付",渠道那边可能是退款了、可能是失败了、可能这笔根本不存在。所以要有对账:
- 定时拉取渠道的账单/流水,与本地比对
- 差异要有明确的归类(长款、短款、状态不一致)
- 差异要能告警,不能只在报表里
对账发现不一致时,以渠道为准,但要留下调整记录(不能悄悄改掉本地状态)。
这条也是为什么"已结算"应该是终态:一旦和渠道对平并结算,本地就不该再单方面改了。
5. 超时与悬挂状态
"支付中"是最危险的状态——它没有终态的确定性,也最容易悬挂。
必须有:
超时机制。 支付中超过 N 分钟没有结果 → 主动查询渠道状态 → 按结果转移,或标记为需要人工处理。
不要无限等待。 有些渠道的最终结果可能几小时后才到(比如银行转账)。这种订单要有单独的"待确认"路径,不能卡在支付中。
主动查询优于被动等待。 回调可能丢,主动查是兜底。这两条都要有——只有回调会漏单,只有查询会慢。
6. 日志与审计
结算模块的日志不是"方便排查",是能不能说清楚钱去哪了。
每一条状态变更至少记录:
- 订单号、动作、前后状态
- 触发来源(用户 / 回调 / 定时任务 / 后台操作人)
- 外部消息 ID
- 时间戳
- 失败原因(如果失败)
其中"触发来源"最常被漏。出事之后最常见的问题是"这笔状态是谁改的"——如果日志里没有来源,就只能靠猜。
7. 与 AI 场景的交叉
一个具体的关系:如果 AI 客服能查询或处理订单,那么它绝不能直接触发状态变更。
正确做法是模型只识别意图和提取订单号,实际动作走一个独立的、有权限校验和幂等保证的服务——见《工具调用的七种状态》,涉及写操作的工具要硬确认,而《护栏要做在基础设施层》里把"付款、删除、对外发布"列为最高风险档。
原因是模型的判断是概率性的,而状态变更需要确定性。把两者混合,等于让一个会出错的组件持有写权限。
8. 清单
- 状态与合法转移集中声明,可被测试穷举
- 终态明确,且终态不接受任何转移
- 外部消息有幂等键,靠唯一约束去重
- 状态更新用条件更新(
WHERE status IN (...)),不是读-判断-写 - 并发用乐观锁或行锁,持锁期间不调外部接口
- 支付中有超时与主动查询
- 有对账任务,差异可告警、可归类
- 每次状态变更记录来源与外部消息 ID
- 检查过没有绕过状态机的路径(后台、脚本、定时任务)
9. 一句话
结算模块的复杂度不在状态有多少个,而在于外部世界不可靠。回调会重复、网络会超时、渠道状态会和你的不一致。
状态机是骨架,幂等和条件更新是让它在这个不可靠的世界里不出错的机制。两者缺一,早晚资损。