本附录把卷三各章的状态机集中在一起。每张表列出「从哪个状态、因为什么事件、到哪个状态、谁触发」,以及并发下最容易出问题的地方。
先说明三条通用规则:
- 状态迁移必须由单一服务校验,不能让多个服务各自改同一个状态字段。
- 每次迁移都要记录事件与操作者,不能只存最终状态。
- 跨对象约束(例如订单与支付)要用同一条业务流水关联,不能靠时间戳猜。
订单状态机
订单状态覆盖从创建到结束的全过程。
| 从 | 事件 | 到 | 触发者 | 并发约束 |
|---|---|---|---|---|
| created | pay | paid | 支付回调/查单 | 与超时关闭互斥 |
| created | close | closed | 超时任务/用户取消 | 与支付回调互斥 |
| paid | fulfill | fulfilled | 履约服务 | 幂等,重复履约不重复发货 |
| paid | refund | refunded | 退款服务 | 累计退款不超过实付 |
| fulfilled | refund | refunded | 退款服务 | 需先确认可退金额 |
| closed | — | — | — | 终态,不接受任何事件 |
| refunded | — | — | — | 终态,不接受任何事件 |
并发重点是 created → paid 与 created → closed:两者必须通过条件更新(例如 UPDATE ... WHERE state='created')互斥。先关闭后支付,需要把支付记为「异常支付」并进入退款流程,而不是把订单改回 paid。
订单状态不建议加太多中间态。fulfilling、partially_refunded 这类状态会让迁移表迅速膨胀,通常用子状态或流水表达更清楚。
支付状态机
支付单描述「一次支付尝试」,与订单是一对多:一个订单可能因为超时、换渠道产生多次支付尝试。
| 从 | 事件 | 到 | 触发者 | 说明 |
|---|---|---|---|---|
| init | request | pending | 支付服务 | 已向渠道发起 |
| pending | success | succeeded | 回调/查单 | 金额、币种、单号校验通过 |
| pending | fail | failed | 回调/查单 | 渠道明确失败 |
| pending | expire | expired | 超时任务 | 渠道未返回,需查单确认 |
| succeeded | refund | refunded | 退款服务 | 部分退款不改支付单状态 |
| failed | — | — | — | 终态 |
| expired | — | — | — | 终态,可重新发起新支付单 |
支付单的终态不是「订单终态」。订单可以因为一次支付失败而重新发起支付,但失败的支付单不能复用。
回调处理必须满足:验签、校验金额与币种、按渠道单号幂等、再更新订单。四步顺序不能换,先更新订单再验签会让伪造回调有机可乘。
退款状态机
退款是独立的资金流,不是支付单的状态回退。
| 从 | 事件 | 到 | 触发者 | 说明 |
|---|---|---|---|---|
| created | submit | processing | 退款服务 | 已提交渠道 |
| processing | success | succeeded | 回调/查单 | 渠道确认退款成功 |
| processing | fail | failed | 回调/查单 | 渠道明确失败 |
| processing | unknown | pending | 查单 | 渠道状态未知,继续查 |
| pending | success | succeeded | 查单 | 最终确认 |
| failed | retry | processing | 退款服务 | 允许有限次重试 |
| succeeded | — | — | — | 终态 |
退款单要有唯一业务键(订单 + 退款批次),保证「同一笔退款请求」不会因为重试产生两条退款。部分退款时,累计金额校验必须在同一事务或同一把锁内完成。
「冲正」与「退款」不同:冲正用于纠正错误的记账,通常发生在对账发现差异后;退款是用户发起的资金退回。两者都要有独立流水,不能互相覆盖。
库存状态机
库存不是单一状态,而是三个量:可用、预留、已售。
| 操作 | 可用 | 预留 | 已售 | 触发 |
|---|---|---|---|---|
| 预扣 | −n | +n | 0 | 下单 |
| 确认 | 0 | −n | +n | 支付成功 |
| 释放 | +n | −n | 0 | 超时/取消 |
| 回补 | +n | 0 | −n | 退款/人工调整 |
约束:任何时刻三个量都非负;预扣与释放必须成对;确认只能在预扣之后。
库存回补要幂等。用「订单 ID + 操作类型」作为幂等键,避免对账任务重跑时重复回补。
优惠券状态机
券有模板与实例两层。模板控制发行规则,实例控制单张券的生命周期。
| 从 | 事件 | 到 | 触发者 | 说明 |
|---|---|---|---|---|
| issued | lock | locked | 下单 | 锁定,防止并发使用 |
| locked | use | used | 支付成功 | 核销 |
| locked | unlock | issued | 订单关闭 | 释放回可用 |
| issued | expire | expired | 定时任务 | 到期 |
| used | refund | issued | 退款成功 | 是否退券由规则决定 |
| expired | — | — | — | 终态 |
「锁定」这一步很关键。没有锁定,同一张券可能被两个并发订单同时使用;只有「使用」没有「解锁」,订单关闭后券会永久占用。
退款是否退券要在产品层定义清楚:全额退款通常退券,部分退款通常不退,活动券可能不退。规则写进状态迁移表,不要散落在代码里。
活动状态机
活动状态相对简单,但预热与库存回补容易出问题。
| 从 | 事件 | 到 | 触发者 | 说明 |
|---|---|---|---|---|
| draft | publish | pending | 运营 | 审核通过后发布 |
| pending | start | running | 定时任务 | 到达开始时间 |
| running | end | ended | 定时任务 | 到达结束时间 |
| running | stop | ended | 运营 | 手动终止 |
| ended | settle | settled | 结算任务 | 库存与预算结算完成 |
| pending | cancel | cancelled | 运营 | 开始前取消 |
活动库存要与商品库存分开记。预热是把活动库存加载到高速存储,不是扣减商品库存;活动结束后,未售出的活动库存要结算回商品库存,且结算要幂等。
权益状态机
权益包括会员折扣、免运费、专属客服等,按用户发放。
| 从 | 事件 | 到 | 触发者 | 说明 |
|---|---|---|---|---|
| granted | activate | active | 发放服务 | 立即生效 |
| active | expire | expired | 定时任务 | 到期 |
| active | revoke | revoked | 退款/风控 | 回收,需原因 |
| expired | — | — | — | 终态 |
| revoked | — | — | — | 终态 |
到期与回收语义不同:到期是自然结束,回收是主动取消。回收必须记录原因与操作者,否则用户投诉时无法解释。
权益发放要幂等,用「用户 + 权益 + 来源单号」作为键。同一笔订单重复回调不能发两次权益。
订阅状态机
订阅是周期性权益,状态与权益不同。
| 从 | 事件 | 到 | 触发者 | 说明 |
|---|---|---|---|---|
| active | cancel | cancel_at_period_end | 用户 | 到期不续,当前周期仍有效 |
| active | renew_success | active | 续费任务 | 周期顺延 |
| active | renew_fail | past_due | 续费任务 | 进入宽限期 |
| past_due | renew_success | active | 续费任务 | 恢复 |
| past_due | grace_end | expired | 定时任务 | 宽限期结束 |
| cancel_at_period_end | period_end | expired | 定时任务 | 到期失效 |
| expired | — | — | — | 终态 |
「取消」不等于「立即失效」,这是订阅类产品最常见的投诉来源。取消只关闭自动续费,当前已付费周期仍然有效。
运营操作状态机
后台的写操作通常需要审批,状态机保证「谁申请、谁审批、谁执行」。
| 从 | 事件 | 到 | 触发者 | 说明 |
|---|---|---|---|---|
| draft | submit | pending | 运营 | 提交审批 |
| pending | approve | approved | 审批人 | 通过 |
| pending | reject | rejected | 审批人 | 驳回 |
| approved | execute | executed | 执行人/任务 | 幂等执行 |
| rejected | — | — | — | 终态,可复制为新单 |
| executed | — | — | — | 终态 |
执行必须幂等:重复点击「执行」不能重复扣库存、重复发券。用操作单 ID 作为幂等键,执行前先查是否已执行。
跨对象约束
单个状态机正确,不代表组合正确。下面这些约束必须在设计时明确。
| 约束 | 说明 | 违反后果 |
|---|---|---|
| 未支付订单不能履约 | 履约前检查订单状态 | 发货后收不到钱 |
| 已退款订单不能再次退款 | 累计退款不超过实付 | 多退钱 |
| 已关闭订单不能支付 | 支付回调时检查订单状态 | 订单状态与资金不一致 |
| 已核销券不能再次使用 | 券状态与订单绑定 | 重复优惠 |
| 已结束活动不能领奖 | 活动状态与发奖解耦 | 超发奖品 |
| 已回收权益不能使用 | 权益校验在消费时进行 | 用户继续享受已取消权益 |
跨对象约束最好用「业务流水 + 状态检查」实现,而不是分布式锁。锁能解决一时,流水能解释一生。
状态设计检查项
评审一个状态机时,可以逐条检查:
- 每个状态是否都有出口?终态是否明确?
- 每个迁移是否都有触发者与权限校验?
- 迁移是否幂等?重复事件会怎样?
- 并发迁移是否有条件更新或锁保护?
- 状态变更是否记录事件、操作者、时间?
- 跨对象约束写在哪里?是否可测试?
- 状态字段是否被多个服务直接修改?
- 是否有「临时状态」没有清理机制?
如果一张状态表里有两个以上的迁移指向同一个状态、却没有区分事件,通常意味着状态粒度太粗,需要用子状态或流水补充。
事件流水表
状态字段只保留「现在是什么」,事件流水记录「怎么变成这样」。两者缺一不可。
| 字段 | 类型 | 说明 |
|---|---|---|
| event_id | string | 全局唯一,建议「对象类型 + 对象 ID + 版本」 |
| object_type | string | order / payment / refund / coupon … |
| object_id | string | 业务对象 ID |
| from_state | string | 迁移前状态 |
| to_state | string | 迁移后状态 |
| event | string | 触发事件名,与迁移表一致 |
| operator | string | 用户 ID、服务名或任务名 |
| reason | string | 可空,人工操作必填 |
| created_at | int64 | Unix 毫秒 |
| trace_id | string | 关联调用链 |
流水表只追加,不更新、不删除。查询「这个订单为什么被关闭」时,看流水比看日志可靠,因为日志会过期、会被采样,流水不会。
流水还要能反推状态:给定对象 ID,按 created_at 排序重放事件,应该得到当前状态字段的值。这个校验可以做成离线任务,周期性比对,发现「状态与流水不一致」就告警。
三种实现方式
同一张状态表,可以用三种方式落地。选择取决于并发强度与审计要求。
| 方式 | 做法 | 优点 | 代价 |
|---|---|---|---|
| 条件更新 | UPDATE ... SET state=? WHERE id=? AND state=? | 简单、原子、无需额外组件 | 复杂迁移要写多条 SQL |
| 状态机库 | 用代码声明迁移表,统一执行 | 迁移集中、易测试 | 引入依赖,跨语言不一致 |
| 事件溯源 | 只存事件,状态由重放得出 | 审计完整、可回溯 | 查询成本高,需要快照 |
多数交易系统用「条件更新 + 事件流水」的组合:状态字段用于查询与约束,流水用于审计与排查。事件溯源适合账户、账本这类「每一笔变动都必须可解释」的对象,不适合所有对象。
不建议在状态迁移里做副作用。发消息、扣库存、发券应该在迁移成功之后,由独立步骤执行;否则迁移失败时副作用已经发生,回滚不了。
状态机的测试方法
状态机是少数可以穷举测试的对象。测试至少覆盖四类:
- 合法迁移:每个迁移都能从正确的前置状态到达目标状态。
- 非法迁移:从终态出发的每个事件都被拒绝。
- 重复事件:同一事件执行两次,第二次不改变状态、不产生副作用。
- 并发迁移:两个事件同时到达,只有一个成功,另一个得到明确的冲突错误。
用表驱动测试最合适:把迁移表本身作为测试数据,遍历所有「状态 × 事件」组合,断言结果与表一致。这样迁移表改了,测试自动跟着变,不会出现「文档更新、代码没改」的情况。
并发测试不一定要真起 goroutine。可以用两次顺序调用模拟「第二个请求看到的是旧状态」,只要条件更新写对了,顺序调用就能暴露问题。
退款与对账的状态衔接
退款单、支付单、订单三者的状态不能各自为政。一次退款至少要同时影响三处:
- 退款单进入
succeeded。 - 支付单的可退金额减少;全额退款时支付单标记为
refunded。 - 订单累计退款金额增加;全额退款时订单进入
refunded。
这三步要么在同一个本地事务里完成,要么用「退款单状态 + 补偿任务」保证最终一致。不能出现「退款单成功、订单还是 paid」的长期不一致。
对账发现的差异也要落到状态上。差异不是一种状态,而是一条差异记录;处理差异的动作(补单、冲正、人工确认)才是状态迁移。把差异做成状态,会让状态机被财务口径绑架。
常见状态设计错误
- 用布尔字段组合状态。
is_paid、is_closed、is_refunded可以组合出非法状态。 - 用状态字段表达金额。 「已部分退款」不是一个状态,是一个金额与流水的组合。
- 在回调里直接改订单状态。 回调可能重试、可能伪造,必须先验签、幂等、再迁移。
- 把「取消」当成「删除」。 取消是状态迁移,删除是数据操作,两者不能混。
- 状态机只写在文档里。 代码里用 if 分支实现,文档与实现迟早分叉。
- 没有终态。 所有状态都能互相迁移,等于没有状态机。
这些错误的共同点是:它们在正常路径上都能跑通,只在重试、并发、退款、对账时暴露。设计时把「重复执行会怎样」写进迁移表,能提前发现大部分问题。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。