Skip to main content

BLOGE 0.9.8-RC1 在线版 · 事实校验 2026-09-15 · English

第 15 章 —— 状态机

承诺: 读完本章后,你将知道什么时候一个工作流已经超出了单张 DAG 的表达能力,需要用 state_machine 来描述;你也会学会如何建模事件驱动迁移、超时和守卫条件,并让生命周期状态显式可见,而不是藏在一堆分支和标志位里。


学习目标

  1. 识别什么时候问题更适合用命名状态和迁移来表达,而不是继续堆分支 graph。
  2. 定义 initial、普通和 terminal 状态,并把状态内部的本地工作放进嵌入式 graph。
  3. 分清事件迁移、守卫迁移、自动迁移和超时迁移各自的职责。
  4. 在上线前就设置好 max_transitionsmax_state_visits 这类安全上限,避免坏循环变成生产事故。
  5. 知道什么时候该用状态机,什么时候 graph 或 session 仍然更简单。

前置条件

源示例

文件展示内容
ch14/order-lifecycle-state-machine.blogedraft → review → processing → completed 的订单生命周期,并带超时回退
ch14/ticket-state-machine.bloge带 assign / escalate / resolve / close 路径的客服工单生命周期
OrderLifecycleStateMachineExample.java使用 StateMachineBuilder 的 Java Fluent API 版本
bloge-state-ext/README.md状态机运行时模型、超时语义和嵌套 session 说明
ReviewStateMachineWithSessionExample.java一个“状态机内嵌 session”的预告,下一章会展开

为什么这很重要

Graph 最擅长回答的问题是:在当前依赖关系下,什么现在可以执行?

状态机擅长回答的是另一个问题:这个业务对象现在处于什么状态?哪个事件会把它推进到下一个状态?

当流程存在回退、反复进入某个状态,或者必须让非实现人员也能一眼读懂生命周期时,这个区别就会变得非常关键。

例如:

  • 一个订单可以从 draft 进入 pendingReview,又因为拒绝回到 draft
  • 一张工单可以从 open 进入 triaging,再决定去 assignedescalated
  • 一个暂停中的 review 状态可以因为超时自动回退到前一个状态

这些规则也可以用分支、标志位和循环编码在一张 graph 中,但生命周期会隐含在 node 名称和条件表达式里。state_machine 则直接表示命名状态和状态迁移。


心智模型

状态机本质上是一个围绕每个状态局部工作而建立的事件驱动外壳

组成部分责任
StateMachineDef整个状态机的不可变定义
state一个命名的生命周期步骤,例如 draftreviewprocessing
嵌入式 graph在该状态内部要执行的本地工作
迁移在事件、超时或自动条件满足时前往另一个状态的规则
StateMachineExecutor启动状态机并递送外部事件
StateMachineCheckpoint可序列化快照,用于恢复与持久化

四种迁移样式最重要:

迁移样式语法适合什么
事件迁移on approve -> processing外部事件应该推动生命周期前进
守卫迁移on * when ... -> approved当前状态输出本身决定接下来去哪
自动迁移on * -> completed当前状态完成后应立即前往下一个状态
超时迁移timeout = 24h + on_timeout -> draft长时间无响应时需要自动回退、关闭或失败转移

状态机负责让生命周期状态可见;状态内部的 graph 负责完成该状态下的局部工作。

Diagram: 15-state-machines figure 1


第一个可运行的示例

orderLifecycle 是最适合入门的第一个状态机:

这 44 行 definition 保留完整,因为读者需要把全部 state、event、timeout 和终止 迁移看成同一个生命周期;拆成孤立片段会重新制造本章要消除的 ownership 歧义。

state_machine orderLifecycle {
max_transitions = 25
max_state_visits = 5
timeout = 72h

state draft [initial] {
graph {
node initOrder : InitOrderOperator {
input {
orderId = ctx.orderId
customerId = ctx.customerId
}
}
}
on submit -> pendingReview
}

state pendingReview {
graph {
node reviewOrder : ReviewOrderOperator {
input {
orderId = ctx.draft.output.initOrder.orderId
}
}
}
on approve -> processing
on reject -> draft
timeout = 24h
on_timeout -> draft
}

state processing {
graph {
node fulfillOrder : FulfillmentOperator {
input {
orderId = ctx.pendingReview.output.reviewOrder.orderId
}
}
}
on * -> completed
}

state completed [terminal] { }
}

用生命周期的方式读它,而不是用 DAG 的方式读它:

  • draft 开始
  • 等待 submit
  • 迁移到 pendingReview
  • 再根据 approvereject 或超时决定去向
  • processing 状态内部工作结束后,自动迁移到 completed

共享 definition 不是正在运行的订单

orderLifecycle 定义每笔订单可以做什么。order-42 是一个当前停在 pendingReview 的 instance;另一实例可以同时停在 processing。混淆这两 个概念,是状态泄漏最直接的来源。

图:状态机 definition 与单实例轨迹

把一个 instance 读成被接受的事件序列

时间事件接受后的 instance 事实
t0createcurrentState=draft,draft visits = 1
t1submitcurrentState=pendingReview,review graph output 已保留
t2approvecurrentState=processing,transition count = 2
t3wildcard completioncurrentState=completed,terminal = true

definition 包含 state id、graph、guard、timeout 与允许的 transition。instance 包含 current state、visit/transition count、state-local output、已接受事件历史 和 checkpoint identity。definition 可以缓存共享;instance 必须按订单隔离。

修改 definition 后,要追问旧 instance 怎么办

在 definition 中增加 manualReview,不会让 order-42 自动移动过去。必须有 事件选中允许的 transition;durable restore 还必须判断旧 checkpoint 是否与 新 definition 兼容。因此,definition versioning 是迁移决策,不是给 live state 直接赋值。


拆解分析

状态负责局部工作,迁移负责生命周期规则

尽量保持下面的职责边界:

  • graph { ... } 只描述当前状态内部要做什么
  • on ... -> target 只描述什么时候切换到下一个状态

一旦这两个职责混在一起,定义就会迅速变得难懂。一个简单判断标准是:

  • 如果它是在描述一次进入状态后,数据如何依赖与流动,就放进状态内部 graph
  • 如果它是在描述什么时候改变生命周期状态,就放进迁移规则

Diagram: 15-state-machines figure 2

事件迁移、自动迁移、守卫迁移和超时迁移

state_machine 提供了不止一种前进方式:

  • 事件迁移: on approve -> processing
  • 自动迁移: on * -> completed
  • 守卫迁移: on * when ctx.review.output... -> approved
  • 超时迁移: timeout = 24hon_timeout -> draft

状态级超时底层会通过一个合成的超时事件来实现。若执行器使用了专门的 TimerService,等待中的实例可以异步过期;如果没有,超时仍然会在下一次 execute(...)signal(...) 时被强制检查。

输出是按状态命名空间组织的

状态输出不是被全局摊平,而是挂在状态 ID 下。

这就是为什么订单示例里要这样写:

orderId = ctx.draft.output.initOrder.orderId

而不是:

orderId = ctx.initOrder.output.orderId

这种按状态命名空间组织输出的方式,会把“这个数据是哪个生命周期状态产生的”保留下来。

安全上限是设计的一部分,不是补丁

运行时内建了显式的失控保护:

保护项默认值作用
max_transitions100限制总迁移次数
max_state_visits10限制单个状态最多被访问多少次
顶层 timeout限制整个状态机的总墙钟时间
状态 timeout限制当前等待状态能挂起多久

对于 reject -> draft 这样的回退迁移,这些保护尤其重要。没有它们,错误的事件流或错误的守卫条件就可能让机器无声地永远转下去。

什么时候 graph 仍然更合适

遇到下面这些情况时,继续使用 graph:

  • 本质上仍然是一条单次穿越依赖的执行路径
  • 回到之前的命名状态并不是业务模型的一部分
  • 不需要通过外部事件来驱动生命周期切换

如果核心问题是多轮对话,优先考虑 session;如果核心问题是显式生命周期状态,优先考虑状态机。


全局迁移(Global Transitions)

到目前为止,示例中的每条迁移规则都定义在某个具体状态内部。但有些事件应该在任何状态下都能被处理 —— 取消、错误恢复或全局超时。在每个非终态状态里都重复写一遍 on "CANCEL" -> cancelled 既冗余又容易遗漏。

global_transitions 块正是解决这个问题的:

state_machine orderLifecycle {

global_transitions {
on "CANCEL" -> cancelled
on "ERROR" -> error_state
on "TIMEOUT" -> timed_out
}

initial_state = pending

state pending {
on "PAYMENT_RECEIVED" -> processing
on "EXPIRED" -> expired
}

state processing {
on "FULFILLED" -> completed
on "OUT_OF_STOCK" -> backordered
}

state completed { terminal = true }
state cancelled { terminal = true }
state error_state { terminal = true }
state timed_out { terminal = true }
state expired { terminal = true }
state backordered {
on "RESTOCKED" -> processing
}
}

全局迁移的工作方式

全局迁移会被应用到状态机中每一个非终态状态。它们的行为与状态级迁移完全一致,只是只需要写一次。

如果某个状态为同一事件定义了自己的迁移,那么状态级迁移优先。这样你就可以在某个状态需要不同处理时覆盖全局行为。

什么时候该用全局迁移,什么时候该用状态级迁移

用全局迁移用状态级迁移
在任何状态都应该生效的取消事件只在特定生命周期阶段才有意义的事件
错误兜底处理器基于状态内部输出的守卫迁移
全局超时升级自动迁移(on *

判断标准:如果从某个状态中移除这条迁移会是一个 bug,那它就应该放进 global_transitions


常见陷阱

❌ 把一条笔直的 DAG 硬包成状态机

如果你的流程本质上只是:

validate -> price -> confirm

那么再额外包出 validatingpricingconfirming 这些状态,并不会让模型更清晰,只会增加样板结构。

只有当状态名称真的能表达出 DAG 本身表达不出的生命周期含义时,状态机才值得引入。


常见故障

回退迁移把你烧进了 max_state_visits

假设 pendingReview 可以因为 reject 回到 draft,而某个错误的集成系统又不断发布 reject。这时 max_state_visits 的意义就会立刻体现出来:运行时会主动停下,而不是让状态机静默地永远兜圈。

修复思路不是“把上限再调大一点”,而是先追问:

  • 是守卫条件写错了吗?
  • 是外部事件流有问题吗?
  • 还是业务生命周期本来就缺了一个中间状态?

上线前一定要专门测试这种回退循环。


引导式重写

打开 ticket-state-machine.bloge

假设你最初写的是一张“大 graph + 很多 branch 节点”的版本,那么重写成状态机的思路通常是:

  1. 先命名稳定的生命周期状态。 opentriagingassignedescalatedresolvedclosed
  2. 把局部工作挪进状态内部 graph。 接单属于 open,分类属于 triaging
  3. 把事件边界挪到迁移规则。 assignescalateresolveclose 都是生命周期事件,而不是普通 graph 分支。
  4. 在“沉默有意义”的地方增加超时。 例如 resolved 状态等待太久后自动关闭。

这样重写的价值在于:即使是刚加入项目的人,也能先通过状态名称读出生命周期,再去关心 operator 细节。


思维检查

  1. 状态机比单张分支 graph 更擅长解决什么问题?

    (显式生命周期建模:命名状态、外部事件驱动和跨状态迁移。)

  2. on * -> completed 代表什么?

    (自动迁移:当前状态内部 graph 完成后,立刻进入 completed。)

  3. 为什么示例要从 ctx.draft.output.initOrder.orderId 读取数据?

    (因为输出是按状态命名空间组织的,这样数据来源的生命周期位置是显式可见的。)

  4. 顶层 timeout 和状态级 timeout 的区别是什么?

    (前者限制整个状态机的总生命周期,后者限制某个等待状态最多能挂起多久。)

  5. 什么时候不应该引入状态机?

    (当流程本质上仍然是一条单次 DAG,没有真正重要的命名状态,也不需要跨状态事件驱动时。)

  6. 为什么 reject -> draft 这类迁移尤其依赖 max_state_visits

    (因为回退迁移最容易在坏事件或坏守卫条件下形成无限循环。)


实验

目标: 把一个真实审批流程建模成状态机。

  1. 定义 draftreviewapprovedrejected 四个状态。
  2. 把加载数据的步骤放进 draft
  3. 把评审富化逻辑放进 review
  4. 增加这些迁移:
    • on submit -> review
    • on approve -> approved
    • on reject -> draft
  5. 显式设置 max_transitionsmax_state_visits
  6. 如果 review 可能长时间卡住,再为该状态加上 timeouton_timeout

进阶: 再加一条 on * when ... -> approved 守卫迁移,让状态输出本身就能决定自动结束生命周期。


持久状态机(Durable State Machines)

内存中的状态机在进程重启后会丢失当前状态。bloge-state-durable 模块提供的 DurableStateMachineManager 会把每一次迁移都持久化为检查点。

Maven 依赖

<dependency>
<groupId>com.leanowtech.bloge</groupId>
<artifactId>bloge-state-durable</artifactId>
</dependency>

构建与使用

Map<String, StateMachineDef> definitions = Map.of(
stateMachineDef.name(), stateMachineDef
);

DurableStateMachineManager durableSm = DurableStateMachineManager.builder()
.executionStore(executionStore)
.checkpointStore(checkpointStore)
.graphEngine(graphEngine)
.definitionLookup(definitions::get)
.recoveryConfig(StateMachineRecoveryConfig.defaultConfig())
.build();

durableSm.startup();
StateMachineResult waiting = durableSm.start(
"orderLifecycle",
Map.of("orderId", "ORD-123")
);
String executionId = waiting.instance().instanceId();

StateMachineResult result = durableSm.signal(
executionId,
"PAYMENT_RECEIVED",
Map.of("providerRef", "PAY-9")
);

start(...) 会创建并 claim 一次 durable execution。状态机进入 WAITING_EVENT 时,manager 保存 checkpoint,并把 execution 标为 suspended。 signal(...) 会 claim 这次 execution、加载 checkpoint、用 definitionLookup 选择定义恢复,然后投递 event。

调用 startup() 后,recovery loop 会扫描过期 claim,并从最近一次已提交 checkpoint 恢复。checkpoint 之后的工作可能被重试,因此外部 effect 仍要满足 幂等。checkpoint/status 是否原子提交,还取决于是否接入 transactional persistence coordinator;默认 direct coordinator 会分别执行两次写入。

这与第 13 章 —— 持久执行中介绍的检查点基础设施是同一套机制,只是应用到了状态机这一层抽象上。


实验验收卡

  • 预期与观察: 共享 definition 不共享 current state、visits、output 或 checkpoint。
  • 失败与恢复: 两个实例误用同一 ID;恢复唯一身份并分别重放。
  • 证明边界: 证明 definition/instance 隔离,不证明事件来源可信。
  • 练习合同: 两个工单实例;只改 instance ID;交付并排轨迹;互不污染即停止。

回顾

  • graph 建模的是依赖顺序;状态机建模的是生命周期状态。
  • 每个状态都可以运行自己的局部 graph,但迁移才是这个抽象的主角。
  • 输出按状态命名空间组织,让生命周期来源保持可见。
  • 事件迁移、自动迁移、守卫迁移和超时迁移分别解决不同问题。
  • 全局迁移可以处理任意状态下的事件 —— 取消、错误、超时 —— 无需重复定义。
  • max_transitionsmax_state_visits 不是多余配置,而是回退流程的安全底线。
  • DurableStateMachineManager 持久化迁移,使状态机能在重启后恢复。
  • 当你需要的是“显式状态”,而不只是更多分支时,才应该引入状态机。

下一步

第 16 章 —— 组合 Session 与状态机 中,你将把两种编排模型组合在一起:有些流程是“对话里嵌一个生命周期”,有些则是“状态里嵌一个短小交互”。


参考链接

Coding Agent: Open the versioned task guide.