3. 事件状态机基础模型
订单的“新增—撤销—成交”看似只是三个业务动作,但在工程实现中,它本质上是一台有限状态机(FSM)。要把它写对、测对、查对,第一步不是急着写代码,而是把状态机的五个基本要素用形式化的方式定义清楚:状态(State)、事件(Event)、转移(Transition)、动作(Action)、守卫条件(Guard)。本节先给出这五个要素的严格定义,再逐一映射到订单场景,最后用一个可执行的判定示例说明它们如何协同工作。
一、五个要素的形式化定义
一个确定性有限状态机可记为五元组 M = (S, E, T, s₀, A),其中:
- 状态 S:系统在某一时刻的稳定快照,是对“当前处于什么阶段”的完整描述。状态必须是互斥且可枚举的,任意时刻只能处于其中一个。
- 事件 E:来自外部或内部的一次触发信号,它本身不改变状态,只是“请求改变”。事件通常带有载荷(如订单号、操作人、时间戳)。
- 转移 T:形如
(源状态, 事件, 目标状态)的三元组,表示“在源状态下收到某事件后,允许迁移到目标状态”。 - 守卫条件 G:附着在转移上的布尔表达式,只有为真时该转移才被允许执行。它把“能转移”细化为“满足业务前提才能转移”。
- 动作 A:转移执行时伴随的副作用,如写库、发消息、记流水。动作分为转移前(before)、转移中(on)、转移后(after)三类。
需要强调一个常见误区:事件不是状态。“支付成功”是一个事件,而“已支付”才是状态。把事件名直接当状态名,会导致状态爆炸和语义混乱,这是订单状态机设计中最典型的错误之一。
二、订单场景的要素映射
以“订单新增撤销成交”为主线,我们定义如下状态集合:待创建(DRAFT)、已创建(CREATED)、已撤销(CANCELLED)、已成交(FILLED)。其中 CANCELLED 与 FILLED 为终态,进入后不再接受任何转移。
事件集合包括:SUBMIT(提交新增)、CANCEL(撤销)、FILL(成交回报)、REJECT(风控拒绝)。守卫条件则承载业务规则,例如:撤销仅允许在 CREATED 且未产生任何成交时执行;成交回报必须携带与订单匹配的成交数量,且累计成交量不得超过订单总量。
下面这张状态转移图把状态、事件、守卫条件和动作放在同一张图上,便于对照阅读:
三、守卫条件如何决定“能不能转”
图中每条转移旁的方括号就是守卫条件。以撤销为例,业务上要求“已创建且无成交”才可撤销,形式化写作:
guard(CANCEL) = (state == CREATED) && (filledQty == 0) && (orderId != null)
再看成交回报,守卫条件需要防止超量成交:
guard(FILL) = (state == CREATED)
&& (fillQty > 0)
&& (filledQty + fillQty <= orderQty)
这里 orderQty 是订单总量,filledQty 是已累计成交量。假设订单总量为 1000 股,已成交 600 股,此时收到一笔 500 股的成交回报,则 600 + 500 = 1100 > 1000,守卫为假,该转移被拒绝,系统应记录一条“超量成交”异常日志而不是静默丢弃。这个判断必须在状态转移前完成,否则一旦先改状态再校验,就会出现“状态已成交但数量越界”的脏数据。
四、一次完整转移的执行顺序
当事件到达时,状态机的标准处理顺序是:定位当前状态 → 匹配事件 → 求值守卫 → 执行转移前动作 → 切换状态 → 执行转移后动作。以“已创建订单收到撤销请求”为例,实际执行过程如下:
- 读取订单当前状态为
CREATED,filledQty = 0; - 匹配到事件
CANCEL,找到候选转移(CREATED, CANCEL, CANCELLED); - 求值守卫
filledQty == 0为真,允许转移; - 执行转移前动作:校验操作人权限、检查是否已过撤单截止时间;
- 将状态由
CREATED改为CANCELLED; - 执行转移后动作:释放冻结资金、写入撤销流水、推送撤单成功消息。
如果第 3 步守卫为假(例如已有部分成交),则整条转移被拒绝,状态保持不变,系统返回“当前订单不可撤销”的业务错误码。注意此时不应执行任何动作,也不应留下半成品数据。
五、用一张判定表固化规则
形式化定义的价值在于可被直接转化为判定表,供开发和测试共同使用。下表把状态、事件、守卫和结果放在一起,是本节最实用的交付物:
| 当前状态 | 事件 | 守卫条件 | 目标状态 | 动作 |
|---|---|---|---|---|
| DRAFT | SUBMIT | 必填字段完整 | CREATED | 写订单、发风控 |
| CREATED | CANCEL | filledQty = 0 且未过截止时间 | CANCELLED | 释放额度、记流水 |
| CREATED | FILL | fillQty > 0 且累计不超量 | FILLED | 更新成交量、结算 |
| CREATED | REJECT | 风控返回拒绝 | CANCELLED | 记录拒绝原因 |
| CANCELLED | 任意 | — | CANCELLED | 拒绝并告警 |
| FILLED | 任意 | — | FILLED | 拒绝并告警 |
这张表有两个工程意义。第一,它明确了终态的“吸收性”:CANCELLED 和 FILLED 收到任何事件都不改变状态,只做拒绝处理,这能有效防止重复撤单、撤已成交单等异常。第二,它把守卫条件从代码里“提”到了文档层,测试人员可以逐行设计用例,例如专门构造 filledQty = 1 的订单去触发撤销,验证守卫是否真的拦截。
六、建模时的三个实践要点
其一,状态命名用“结果”而非“动作”。 用 CREATED 而不是 CREATE,用 CANCELLED 而不是 CANCEL,避免与事件名混淆。
其二,守卫条件要可观测。 每个守卫涉及的字段(如 filledQty、orderQty)都应在订单快照中可查,否则线上排障时无法复现“为什么这次转移被拒绝”。建议在拒绝日志中同时打印状态、事件、守卫表达式和各字段实际值。
其三,动作要幂等。 消息重投、接口重试在订单场景中很常见,转移后动作如“释放冻结额度”必须具备幂等键(如订单号 + 动作类型),否则一次撤销可能释放两次额度。守卫条件保证“该不该转”,幂等动作保证“转一次和转多次结果一致”,两者缺一不可。
把以上五要素定义清楚后,订单状态机就从一段模糊的业务描述,变成了一张可评审、可实现、可测试的规则集合。后续无论是新增“部分成交”状态,还是引入“撤单审批”子流程,都可以在这套形式化框架上做增量扩展,而不必推倒重来。