Skip to main content

结算状态机怎么设计

结算是那种"平时没人看,出事就是大事故"的模块。它的难点不在于逻辑复杂,而在于它同时要面对不可靠的外部渠道、重复的消息、和并发的修改。

三件事里任何一件处理不好,都会变成资损。

待支付支付中已支付已结算已关闭/退款幂等同一笔支付回调可能来多次,状态转移必须可重入终态定义清楚哪些是终态,终态不能再回到中间态对账以外部渠道为准做核对,不能只信自己的状态状态机画出来之后,先检查有没有"能绕过去"的转移路径
图:结算状态机的主干与三条纪律

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. 一句话​

结算模块的复杂度不在状态有多少个,而在于外部世界不可靠。回调会重复、网络会超时、渠道状态会和你的不一致。

状态机是骨架,幂等和条件更新是让它在这个不可靠的世界里不出错的机制。两者缺一,早晚资损。