Skip to content

把状态分支变成可验证模型:状态机、命令与工作流 ​

活动报名的状态一开始只有「待确认」「已确认」「已取消」,每加一个需求就在某个方法里补一个 if。等到有了候补和签到,没有人能说清楚「签到之后还能不能取消」。状态机的价值不在于用了哪种模式,而在于让「允许哪些转移」变成一张可以穷举检查的表。

把按需求逐步加上的条件分支,和一张集中的转换表逐个组合对比,实测 25 个组合里有 6 处不一致:

text
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 加转换表;每个状态行为很多用密封类型;转移由配置、人工审批和长时间等待驱动,才考虑工作流引擎。

二、先画出来,再写成表 ​

PENDING待确认WAITLISTED候补CONFIRMED已确认CANCELLED已取消CHECKED_IN已签到WAITLISTCONFIRMPROMOTECANCELCANCELCHECK_IN终态
图 1 · 5 个状态、5 个命令共 25 个组合,只有 7 个合法;把它们集中写成一张转换表,非法组合就不需要在各个方法里重复判断
java
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,都是有意设计的终态;如果出现一个意外的终态(比如新加的「待支付」忘了写超时取消),报名就会永远卡在那里。
  • 完整性:每个状态对每个命令都有明确结论。表里没有的就是拒绝,这比分支写法里的「默认放行」安全。

这些检查写成单元测试,每次改表都跑一遍。

四、并发:同一条报名上的两个命令 ​

散落分支 vs 转换表(25 个组合)CHECKED_IN + CANCEL → 分支放行CANCELLED + PROMOTE → 分支放行WAITLISTED + CONFIRM → 分支放行CONFIRMED + WAITLIST → 分支放行……共 6 处不一致并发:CANCEL ‖ CHECK_IN,1000 轮不带版本号:读 → 判断 → 写两个命令都「成功」1000 轮带版本号:比较并交换 (状态, 版本)每轮一个成功、一个被拒绝存储层对应条件更新 UPDATE … WHERE version = ?
图 2 · 左:逐步加上的条件分支与转换表对比,签到后还能取消、取消后还能转正;右:CANCEL 与 CHECK_IN 并发作用于同一条已确认的报名,不带版本号时 1000 轮都两个成功,带版本号时每轮只有一个成功

用户点了取消,同一时刻前台扫码签到。两个命令各自读出 CONFIRMED,各自判断合法,各自写回。实测 1000 轮(读出与写回之间有一次 0.2ms 的存储往返):

写法两个命令都「成功」的轮数被拒绝的命令
读出 → 判断 → 写回10000
比较并交换 (状态, 版本)01000

不带版本号时,最后写入的那个覆盖另一个,但两个调用方都以为自己成功了:用户看到取消成功,前台看到签到成功。带版本号时,后到的一方发现版本已变,重新读取后按新状态判断(签到之后不能取消)。

在数据库里就是一条条件更新:

sql
UPDATE registration SET status = 'CANCELLED', version = version + 1
WHERE id = ? AND status = 'CONFIRMED' AND version = ?

影响行数为 0 就是冲突。MySQL 上先查后改与条件更新的对比实测,见 订单、库存与数据一致性 和 从统一语言到限界上下文。

五、副作用:状态变了,通知没发出去 ​

确认报名之后要给用户发通知。通知服务前两次调用都超时,实测两种写法:

text
状态改完直接调用通知:
  第 1 次:状态已变为 CONFIRMED,通知失败
  第 2 次:CONFIRM 在 CONFIRMED 下非法
  第 3 次:CONFIRM 在 CONFIRMED 下非法          → 最终送达 0 封

状态与「待发送通知」一起写入,由投递器重试:
  投递器第 3 轮送达,调用通知服务 3 次          → 最终送达 1 封

第一种写法的问题在于:状态机正确地拒绝了重复的 CONFIRM,但副作用和状态不在同一个事务里,重试整个命令这条路被状态机自己堵死了。第二种写法把副作用变成了数据,状态变化和待发送记录要么一起提交、要么一起回滚,投递器负责重试,接收方负责幂等。这就是事件篇里的 Outbox,见 一次状态变化怎样通知多方。

六、实现形式怎么选 ​

情况实现理由
状态少(十个以内)、转移规则清楚enum + 转换表一张表看全,可穷举检查
每个状态有很多行为、状态集合封闭密封类型 + 穷举 switch编译器检查每个状态都被处理,见 封装、组合与多态
状态对象需要携带不同的数据(候补排名、签到时间)状态模式或密封类型的 record数据跟着状态走,不用一堆可空字段
转移由配置决定、有人工审批、要等几天、要可视化与审计工作流引擎持久化、定时、补偿和运维界面都现成

状态机管的是一个对象的状态怎样迁移。多个步骤之间的先后依赖(资格校验、名额预留之后几件事并行,再提交)是另一个问题,见 依赖任务怎样安全地跑起来。

经典的「状态模式」要求每个状态一个类,状态少的时候这是负担,不是设计。决定用哪种实现的,是状态数、每个状态的行为多少、转移是否需要配置、是否需要长时间等待和人工介入,而不是哪种写法「更面向对象」。

七、常见误区 ​

  • 「状态少,用 if 就够了」:实测逐步加上的分支放行了 6 个非法转移,而且没有办法穷举检查。
  • 「状态机校验了转移,所以并发安全」:实测不带版本号时两个互斥的命令都成功了。
  • 「转移成功之后再发通知就行」:通知失败后重试被状态机拒绝,通知永远丢失。
  • 「状态模式就是每个状态一个类」:状态少、集合封闭时,转换表或密封类型更清楚。

小结 ​

状态机首先是一张表:状态 × 命令 → 下一个状态或拒绝。有了表就能穷举检查、画图评审、生成测试;表之外还要处理两件事:并发时用版本号保证只有一个转移成功,副作用和状态一起提交、由投递器重试。实现形式是最后才决定的事。


配套实验

参考资料

文章以 CC BY-NC-SA 4.0 授权 · 代码片段以 MIT 授权