《互联网产品的服务端架构·交易、增长与平台化》附录 A 交易与营销状态机

把卷三涉及的对象状态集中成表:订单、支付、退款、库存、优惠券、活动、权益、订阅、运营操作各自的合法迁移、触发者、并发约束与终态定义,再给出事件流水的字段设计、三种实现方式的对比、状态机测试方法、退款与对账的状态衔接,以及跨对象约束和设计检查项,方便对照代码逐条核对。

本附录把卷三各章的状态机集中在一起。每张表列出「从哪个状态、因为什么事件、到哪个状态、谁触发」,以及并发下最容易出问题的地方。

先说明三条通用规则:

  • 状态迁移必须由单一服务校验,不能让多个服务各自改同一个状态字段。
  • 每次迁移都要记录事件与操作者,不能只存最终状态。
  • 跨对象约束(例如订单与支付)要用同一条业务流水关联,不能靠时间戳猜。

订单状态机

订单状态覆盖从创建到结束的全过程。

从事件到触发者并发约束
createdpaypaid支付回调/查单与超时关闭互斥
createdcloseclosed超时任务/用户取消与支付回调互斥
paidfulfillfulfilled履约服务幂等,重复履约不重复发货
paidrefundrefunded退款服务累计退款不超过实付
fulfilledrefundrefunded退款服务需先确认可退金额
closed———终态,不接受任何事件
refunded———终态,不接受任何事件

并发重点是 created → paid 与 created → closed:两者必须通过条件更新(例如 UPDATE ... WHERE state='created')互斥。先关闭后支付,需要把支付记为「异常支付」并进入退款流程,而不是把订单改回 paid。

订单状态不建议加太多中间态。fulfilling、partially_refunded 这类状态会让迁移表迅速膨胀,通常用子状态或流水表达更清楚。

支付状态机

支付单描述「一次支付尝试」,与订单是一对多:一个订单可能因为超时、换渠道产生多次支付尝试。

从事件到触发者说明
initrequestpending支付服务已向渠道发起
pendingsuccesssucceeded回调/查单金额、币种、单号校验通过
pendingfailfailed回调/查单渠道明确失败
pendingexpireexpired超时任务渠道未返回,需查单确认
succeededrefundrefunded退款服务部分退款不改支付单状态
failed———终态
expired———终态,可重新发起新支付单

支付单的终态不是「订单终态」。订单可以因为一次支付失败而重新发起支付,但失败的支付单不能复用。

回调处理必须满足:验签、校验金额与币种、按渠道单号幂等、再更新订单。四步顺序不能换,先更新订单再验签会让伪造回调有机可乘。

退款状态机

退款是独立的资金流,不是支付单的状态回退。

从事件到触发者说明
createdsubmitprocessing退款服务已提交渠道
processingsuccesssucceeded回调/查单渠道确认退款成功
processingfailfailed回调/查单渠道明确失败
processingunknownpending查单渠道状态未知,继续查
pendingsuccesssucceeded查单最终确认
failedretryprocessing退款服务允许有限次重试
succeeded———终态

退款单要有唯一业务键(订单 + 退款批次),保证「同一笔退款请求」不会因为重试产生两条退款。部分退款时,累计金额校验必须在同一事务或同一把锁内完成。

「冲正」与「退款」不同:冲正用于纠正错误的记账,通常发生在对账发现差异后;退款是用户发起的资金退回。两者都要有独立流水,不能互相覆盖。

库存状态机

库存不是单一状态,而是三个量:可用、预留、已售。

操作可用预留已售触发
预扣−n+n0下单
确认0−n+n支付成功
释放+n−n0超时/取消
回补+n0−n退款/人工调整

约束:任何时刻三个量都非负;预扣与释放必须成对;确认只能在预扣之后。

库存回补要幂等。用「订单 ID + 操作类型」作为幂等键,避免对账任务重跑时重复回补。

优惠券状态机

券有模板与实例两层。模板控制发行规则,实例控制单张券的生命周期。

从事件到触发者说明
issuedlocklocked下单锁定,防止并发使用
lockeduseused支付成功核销
lockedunlockissued订单关闭释放回可用
issuedexpireexpired定时任务到期
usedrefundissued退款成功是否退券由规则决定
expired———终态

「锁定」这一步很关键。没有锁定,同一张券可能被两个并发订单同时使用;只有「使用」没有「解锁」,订单关闭后券会永久占用。

退款是否退券要在产品层定义清楚:全额退款通常退券,部分退款通常不退,活动券可能不退。规则写进状态迁移表,不要散落在代码里。

活动状态机

活动状态相对简单,但预热与库存回补容易出问题。

从事件到触发者说明
draftpublishpending运营审核通过后发布
pendingstartrunning定时任务到达开始时间
runningendended定时任务到达结束时间
runningstopended运营手动终止
endedsettlesettled结算任务库存与预算结算完成
pendingcancelcancelled运营开始前取消

活动库存要与商品库存分开记。预热是把活动库存加载到高速存储,不是扣减商品库存;活动结束后,未售出的活动库存要结算回商品库存,且结算要幂等。

权益状态机

权益包括会员折扣、免运费、专属客服等,按用户发放。

从事件到触发者说明
grantedactivateactive发放服务立即生效
activeexpireexpired定时任务到期
activerevokerevoked退款/风控回收,需原因
expired———终态
revoked———终态

到期与回收语义不同:到期是自然结束,回收是主动取消。回收必须记录原因与操作者,否则用户投诉时无法解释。

权益发放要幂等,用「用户 + 权益 + 来源单号」作为键。同一笔订单重复回调不能发两次权益。

订阅状态机

订阅是周期性权益,状态与权益不同。

从事件到触发者说明
activecancelcancel_at_period_end用户到期不续,当前周期仍有效
activerenew_successactive续费任务周期顺延
activerenew_failpast_due续费任务进入宽限期
past_duerenew_successactive续费任务恢复
past_duegrace_endexpired定时任务宽限期结束
cancel_at_period_endperiod_endexpired定时任务到期失效
expired———终态

「取消」不等于「立即失效」,这是订阅类产品最常见的投诉来源。取消只关闭自动续费,当前已付费周期仍然有效。

运营操作状态机

后台的写操作通常需要审批,状态机保证「谁申请、谁审批、谁执行」。

从事件到触发者说明
draftsubmitpending运营提交审批
pendingapproveapproved审批人通过
pendingrejectrejected审批人驳回
approvedexecuteexecuted执行人/任务幂等执行
rejected———终态,可复制为新单
executed———终态

执行必须幂等:重复点击「执行」不能重复扣库存、重复发券。用操作单 ID 作为幂等键,执行前先查是否已执行。

跨对象约束

单个状态机正确,不代表组合正确。下面这些约束必须在设计时明确。

约束说明违反后果
未支付订单不能履约履约前检查订单状态发货后收不到钱
已退款订单不能再次退款累计退款不超过实付多退钱
已关闭订单不能支付支付回调时检查订单状态订单状态与资金不一致
已核销券不能再次使用券状态与订单绑定重复优惠
已结束活动不能领奖活动状态与发奖解耦超发奖品
已回收权益不能使用权益校验在消费时进行用户继续享受已取消权益

跨对象约束最好用「业务流水 + 状态检查」实现,而不是分布式锁。锁能解决一时,流水能解释一生。

状态设计检查项

评审一个状态机时,可以逐条检查:

  • 每个状态是否都有出口?终态是否明确?
  • 每个迁移是否都有触发者与权限校验?
  • 迁移是否幂等?重复事件会怎样?
  • 并发迁移是否有条件更新或锁保护?
  • 状态变更是否记录事件、操作者、时间?
  • 跨对象约束写在哪里?是否可测试?
  • 状态字段是否被多个服务直接修改?
  • 是否有「临时状态」没有清理机制?

如果一张状态表里有两个以上的迁移指向同一个状态、却没有区分事件,通常意味着状态粒度太粗,需要用子状态或流水补充。

事件流水表

状态字段只保留「现在是什么」,事件流水记录「怎么变成这样」。两者缺一不可。

字段类型说明
event_idstring全局唯一,建议「对象类型 + 对象 ID + 版本」
object_typestringorder / payment / refund / coupon …
object_idstring业务对象 ID
from_statestring迁移前状态
to_statestring迁移后状态
eventstring触发事件名,与迁移表一致
operatorstring用户 ID、服务名或任务名
reasonstring可空,人工操作必填
created_atint64Unix 毫秒
trace_idstring关联调用链

流水表只追加,不更新、不删除。查询「这个订单为什么被关闭」时,看流水比看日志可靠,因为日志会过期、会被采样,流水不会。

流水还要能反推状态:给定对象 ID,按 created_at 排序重放事件,应该得到当前状态字段的值。这个校验可以做成离线任务,周期性比对,发现「状态与流水不一致」就告警。

三种实现方式

同一张状态表,可以用三种方式落地。选择取决于并发强度与审计要求。

方式做法优点代价
条件更新UPDATE ... SET state=? WHERE id=? AND state=?简单、原子、无需额外组件复杂迁移要写多条 SQL
状态机库用代码声明迁移表,统一执行迁移集中、易测试引入依赖,跨语言不一致
事件溯源只存事件,状态由重放得出审计完整、可回溯查询成本高,需要快照

多数交易系统用「条件更新 + 事件流水」的组合:状态字段用于查询与约束,流水用于审计与排查。事件溯源适合账户、账本这类「每一笔变动都必须可解释」的对象,不适合所有对象。

不建议在状态迁移里做副作用。发消息、扣库存、发券应该在迁移成功之后,由独立步骤执行;否则迁移失败时副作用已经发生,回滚不了。

状态机的测试方法

状态机是少数可以穷举测试的对象。测试至少覆盖四类:

  • 合法迁移:每个迁移都能从正确的前置状态到达目标状态。
  • 非法迁移:从终态出发的每个事件都被拒绝。
  • 重复事件:同一事件执行两次,第二次不改变状态、不产生副作用。
  • 并发迁移:两个事件同时到达,只有一个成功,另一个得到明确的冲突错误。

用表驱动测试最合适:把迁移表本身作为测试数据,遍历所有「状态 × 事件」组合,断言结果与表一致。这样迁移表改了,测试自动跟着变,不会出现「文档更新、代码没改」的情况。

并发测试不一定要真起 goroutine。可以用两次顺序调用模拟「第二个请求看到的是旧状态」,只要条件更新写对了,顺序调用就能暴露问题。

退款与对账的状态衔接

退款单、支付单、订单三者的状态不能各自为政。一次退款至少要同时影响三处:

  • 退款单进入 succeeded。
  • 支付单的可退金额减少;全额退款时支付单标记为 refunded。
  • 订单累计退款金额增加;全额退款时订单进入 refunded。

这三步要么在同一个本地事务里完成,要么用「退款单状态 + 补偿任务」保证最终一致。不能出现「退款单成功、订单还是 paid」的长期不一致。

对账发现的差异也要落到状态上。差异不是一种状态,而是一条差异记录;处理差异的动作(补单、冲正、人工确认)才是状态迁移。把差异做成状态,会让状态机被财务口径绑架。

常见状态设计错误

  • 用布尔字段组合状态。 is_paid、is_closed、is_refunded 可以组合出非法状态。
  • 用状态字段表达金额。 「已部分退款」不是一个状态,是一个金额与流水的组合。
  • 在回调里直接改订单状态。 回调可能重试、可能伪造,必须先验签、幂等、再迁移。
  • 把「取消」当成「删除」。 取消是状态迁移,删除是数据操作,两者不能混。
  • 状态机只写在文档里。 代码里用 if 分支实现,文档与实现迟早分叉。
  • 没有终态。 所有状态都能互相迁移,等于没有状态机。

这些错误的共同点是:它们在正常路径上都能跑通,只在重试、并发、退款、对账时暴露。设计时把「重复执行会怎样」写进迁移表,能提前发现大部分问题。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《互联网产品的 AI 架构·平台化与治理》附录 C 平台化评审清单
  2. 《互联网产品的 AI 架构·平台化与治理》附录 B 能力目录与配额字段速查
  3. 《互联网产品的 AI 架构·平台化与治理》附录 A 治理角色与职责速查