把状态分支变成可验证模型:状态机、命令与工作流
活动报名的状态一开始只有「待确认」「已确认」「已取消」,每加一个需求就在某个方法里补一个
if。等到有了候补和签到,没有人能说清楚「签到之后还能不能取消」。状态机的价值不在于用了哪种模式,而在于让「允许哪些转移」变成一张可以穷举检查的表。
把按需求逐步加上的条件分支,和一张集中的转换表逐个组合对比,实测 25 个组合里有 6 处不一致:
CHECKED_IN + CANCEL → 表:拒绝 / 分支:CANCELLED 签到后还能取消
CANCELLED + PROMOTE → 表:拒绝 / 分支:CONFIRMED 取消后还能被候补转正
WAITLISTED + CONFIRM → 表:拒绝 / 分支:CONFIRMED 候补绕过了转正流程
CONFIRMED + WAITLIST → 表:拒绝 / 分支:WAITLISTED
……每一条单独看都像是「顺手多放行了一种情况」,合在一起就是一个没有人设计过的状态模型。本文用 JDK 21 的实测,说明转换表怎样检查、并发和副作用怎样处理,以及什么时候才需要状态模式或工作流引擎。
一、先说结论
- 把状态和命令写成一张表。 活动报名 5 个状态 × 5 个命令共 25 个组合,只有 7 个合法;表可以穷举,散落的分支不能,实测分支写法放行了 6 个非法转移。
- 表写好之后,检查图的性质。 实测所有状态都能从 PENDING 到达,没有出口的只有 CANCELLED 与 CHECKED_IN;新增状态后跑一遍,就能发现走不到或出不去的状态。
- 并发修改必须带版本号。 实测 CANCEL 与 CHECK_IN 同时作用于一条已确认的报名,不带版本号时 1000 轮里两个命令都「成功」;带版本号比较并交换时,每轮只有一个成功。
- 状态变化和它的副作用一起提交。 实测状态改完再调通知服务,通知失败后重试被状态机拒绝,通知永远丢了;把「待发送通知」和状态一起写入,由投递器重试,最终送达。
- 实现形式按复杂度选。 状态少、规则清楚用
enum加转换表;每个状态行为很多用密封类型;转移由配置、人工审批和长时间等待驱动,才考虑工作流引擎。
二、先画出来,再写成表
enum State { PENDING, WAITLISTED, CONFIRMED, CANCELLED, CHECKED_IN }
enum Command { CONFIRM, WAITLIST, PROMOTE, CANCEL, CHECK_IN }
static final Map<State, Map<Command, State>> TABLE = new EnumMap<>(State.class);
static {
for (State s : State.values()) TABLE.put(s, new EnumMap<>(Command.class));
TABLE.get(PENDING).put(CONFIRM, CONFIRMED);
TABLE.get(PENDING).put(WAITLIST, WAITLISTED);
TABLE.get(PENDING).put(CANCEL, CANCELLED);
TABLE.get(WAITLISTED).put(PROMOTE, CONFIRMED);
TABLE.get(WAITLISTED).put(CANCEL, CANCELLED);
TABLE.get(CONFIRMED).put(CANCEL, CANCELLED);
TABLE.get(CONFIRMED).put(CHECK_IN, CHECKED_IN);
}
static Optional<State> next(State s, Command c) {
return Optional.ofNullable(TABLE.get(s).get(c)); // 空表示非法转移
}表的好处是它本身就是数据。实测用两层循环打印出完整的 5 × 5 矩阵,和散落的分支逐格对比,找出了开头那 6 处不一致;这张矩阵也可以直接放进设计评审,由产品确认每一格。
三、检查表的性质
有了表,就能像检查图一样检查它:
- 可达性:从初始状态出发,广度优先遍历所有转移。实测没有不可达的状态。
- 终态:没有任何出口的状态。实测只有 CANCELLED 与 CHECKED_IN,都是有意设计的终态;如果出现一个意外的终态(比如新加的「待支付」忘了写超时取消),报名就会永远卡在那里。
- 完整性:每个状态对每个命令都有明确结论。表里没有的就是拒绝,这比分支写法里的「默认放行」安全。
这些检查写成单元测试,每次改表都跑一遍。
四、并发:同一条报名上的两个命令
用户点了取消,同一时刻前台扫码签到。两个命令各自读出 CONFIRMED,各自判断合法,各自写回。实测 1000 轮(读出与写回之间有一次 0.2ms 的存储往返):
| 写法 | 两个命令都「成功」的轮数 | 被拒绝的命令 |
|---|---|---|
| 读出 → 判断 → 写回 | 1000 | 0 |
| 比较并交换 (状态, 版本) | 0 | 1000 |
不带版本号时,最后写入的那个覆盖另一个,但两个调用方都以为自己成功了:用户看到取消成功,前台看到签到成功。带版本号时,后到的一方发现版本已变,重新读取后按新状态判断(签到之后不能取消)。
在数据库里就是一条条件更新:
UPDATE registration SET status = 'CANCELLED', version = version + 1
WHERE id = ? AND status = 'CONFIRMED' AND version = ?影响行数为 0 就是冲突。MySQL 上先查后改与条件更新的对比实测,见 订单、库存与数据一致性 和 从统一语言到限界上下文。
五、副作用:状态变了,通知没发出去
确认报名之后要给用户发通知。通知服务前两次调用都超时,实测两种写法:
状态改完直接调用通知:
第 1 次:状态已变为 CONFIRMED,通知失败
第 2 次:CONFIRM 在 CONFIRMED 下非法
第 3 次:CONFIRM 在 CONFIRMED 下非法 → 最终送达 0 封
状态与「待发送通知」一起写入,由投递器重试:
投递器第 3 轮送达,调用通知服务 3 次 → 最终送达 1 封第一种写法的问题在于:状态机正确地拒绝了重复的 CONFIRM,但副作用和状态不在同一个事务里,重试整个命令这条路被状态机自己堵死了。第二种写法把副作用变成了数据,状态变化和待发送记录要么一起提交、要么一起回滚,投递器负责重试,接收方负责幂等。这就是事件篇里的 Outbox,见 一次状态变化怎样通知多方。
六、实现形式怎么选
| 情况 | 实现 | 理由 |
|---|---|---|
| 状态少(十个以内)、转移规则清楚 | enum + 转换表 | 一张表看全,可穷举检查 |
| 每个状态有很多行为、状态集合封闭 | 密封类型 + 穷举 switch | 编译器检查每个状态都被处理,见 封装、组合与多态 |
| 状态对象需要携带不同的数据(候补排名、签到时间) | 状态模式或密封类型的 record | 数据跟着状态走,不用一堆可空字段 |
| 转移由配置决定、有人工审批、要等几天、要可视化与审计 | 工作流引擎 | 持久化、定时、补偿和运维界面都现成 |
状态机管的是一个对象的状态怎样迁移。多个步骤之间的先后依赖(资格校验、名额预留之后几件事并行,再提交)是另一个问题,见 依赖任务怎样安全地跑起来。
经典的「状态模式」要求每个状态一个类,状态少的时候这是负担,不是设计。决定用哪种实现的,是状态数、每个状态的行为多少、转移是否需要配置、是否需要长时间等待和人工介入,而不是哪种写法「更面向对象」。
七、常见误区
- 「状态少,用 if 就够了」:实测逐步加上的分支放行了 6 个非法转移,而且没有办法穷举检查。
- 「状态机校验了转移,所以并发安全」:实测不带版本号时两个互斥的命令都成功了。
- 「转移成功之后再发通知就行」:通知失败后重试被状态机拒绝,通知永远丢失。
- 「状态模式就是每个状态一个类」:状态少、集合封闭时,转换表或密封类型更清楚。
小结
状态机首先是一张表:状态 × 命令 → 下一个状态或拒绝。有了表就能穷举检查、画图评审、生成测试;表之外还要处理两件事:并发时用版本号保证只有一个转移成功,副作用和状态一起提交、由投递器重试。实现形式是最后才决定的事。
配套实验
- codesphere-labs/design/registration-state-machine:散落分支与转换表的全矩阵对比、可达性与终态、并发命令与版本号、副作用失败与待发送记录(验证记录)
参考资料
- Gamma 等,《Design Patterns: Elements of Reusable Object-Oriented Software》(State、Command)
- Java Language Specification 21:Sealed Classes
- JDK 21 API:EnumMap