引言
不是所有流程都值得引入工作流引擎。一个只有「待支付 → 已支付 → 已发货 → 已完成」四个状态的订单系统,用 BPMN 或 Temporal 都是杀鸡用牛刀。但「直接改数据库字段」又会带来另一个问题:非法流转悄悄写进库、并发更新互相覆盖、没人说得清「已取消的订单为什么变成了已发货」。
状态机引擎填补的正是这个空档:它比工作流引擎轻,比裸写状态字段严谨。它的核心承诺是「所有状态变更必须经过显式的转移定义,且并发下不会丢失更新」。
它的复杂度不在实现上(一张转移表加一个校验函数就能跑),而在设计上:状态怎么划分才不爆炸、转移的条件(守卫)放哪里、外部副作用(发消息、调 API)在转移前还是转移后执行、失败时如何回滚状态。这些决策一旦定错,后面很难改。
本文先从「状态机与工作流引擎的边界」讲起,再拆解有限状态机的五个要素与表驱动实现,重点讲持久化与并发控制,然后覆盖审计、非法流转防御、超时推进与状态爆炸的治理,接着给出 Spring StateMachine 与 Step Functions 的实战代码,最后讲测试与落地路线。想先看整体选型框架的读者,可以从 工作流引擎全景与选型 开始。
目录
- 状态机与工作流引擎的边界
- 有限状态机的五个要素
- 状态的定义与命名
- 事件与转移的设计
- 守卫与动作的职责划分
- 表驱动状态机的实现
- 状态持久化与并发控制
- 乐观锁、版本号与幂等
- 状态流转的审计日志
- 非法流转的防御
- 超时与定时推进
- Spring StateMachine 实战
- AWS Step Functions 的 ASL
- 状态爆炸与状态压缩
- 测试策略
- 落地路线图
- 权衡取舍
- 常见坑清单
- 小结
1. 状态机与工作流引擎的边界
状态机与工作流引擎都能表达「多步骤流转」,但它们的重心不同:
| 维度 | 状态机 | 工作流引擎 |
|---|---|---|
| 流程定义 | 状态 + 转移表 | 图形或代码 |
| 控制流 | 单线程线性 | 支持并行、子流程、循环 |
| 等待 | 靠外部事件推进 | 原生定时器与等待 |
| 可视化 | 状态图(简单) | 流程图(复杂) |
| 人工任务 | 需自建 | BPMN 原生支持 |
| 补偿 | 需自建 | 部分原生支持 |
| 适用规模 | 状态 10 个以内 | 步骤数十个 |
| 实现成本 | 一天到一周 | 一周到一月 |
判断标准是「控制流的复杂度」而不是「业务的重要性」。如果一个流程里存在并行分支、子流程嵌套、或者需要跨天等待,那它应该用工作流引擎。如果流转是线性的、状态可枚举、等待靠外部事件(比如支付回调)推进,状态机就够。
一个实用的组合是「状态机做局部、工作流做全局」:工作流引擎负责跨服务的编排与长等待,每个服务内部用一个状态机管理自己的业务实体状态。这样状态机的边界与服务的边界一致,事务边界也清晰。
2. 有限状态机的五个要素
一个状态机由五个要素定义:
- 状态(State):系统可能处于的稳定情形,比如
PENDING、PAID、SHIPPED。 - 事件(Event):触发状态变更的外部输入,比如
PAY、SHIP、CANCEL。 - 转移(Transition):
(源状态, 事件) → 目标状态的映射。 - 守卫(Guard):转移的前置条件,返回布尔值,决定转移是否允许。
- 动作(Action):转移时执行的副作用,比如写日志、发消息。
用一张表就能描述全部语义:
源状态 事件 守卫 目标状态 动作
PENDING PAY amount > 0 PAID 记流水
PENDING CANCEL 无 CANCELED 释放库存
PAID SHIP stock 可用 SHIPPED 通知物流
PAID REFUND 无 REFUNDED 退款
SHIPPED CONFIRM 无 COMPLETED 结算
这张表有两个价值:它是可执行的规范(代码直接由它生成),也是可评审的文档(业务方能读懂)。建议把它作为设计的中心产物,代码与文档都从它派生。
3. 状态的定义与命名
状态定义有三条实用规则。
第一,状态必须是「可观察的稳定情形」。PROCESSING 这种「正在进行中」的中间态通常不该是状态,而应该是「没有状态」(比如数据库里的锁或者任务队列里的记录)。把瞬时中间态建成状态,会引入大量「卡在 PROCESSING」的运维问题。
第二,状态名用名词或过去分词,不用动词。PAID 比 PAYING 好,SHIPPED 比 SHIP 好,因为状态描述的是「已经怎样」,事件才描述「要做什么」。
第三,状态集合要能覆盖「终态」与「异常态」。终态(COMPLETED、CANCELED、REFUNDED)不能再有出边;异常态(FAILED、MANUAL_REVIEW)通常需要人工干预才能离开。
public enum OrderStatus {
PENDING, PAID, SHIPPED, COMPLETED, // 正常路径
CANCELED, REFUNDED, // 正常终态
MANUAL_REVIEW // 异常态,需人工
}
枚举与数据库字段要一一对应,且建议在数据库层加 CHECK 约束或枚举类型,避免有人绕过代码直接写库。
4. 事件与转移的设计
事件是「为什么变」,转移是「变成什么」。设计时的关键是「一个事件只表达一个业务意图」,不要把多个意图塞进一个事件。
# 反例:一个事件承担多个语义
ORDER_UPDATE -> 根据 payload 里的字段决定目标状态
# 正例:每个事件语义单一
PAY / SHIP / CANCEL / REFUND / CONFIRM
反例的问题是守卫逻辑会变得复杂且难以测试,而且审计日志里看不出「到底发生了什么」。事件驱动设计里有一条通用原则:事件是已经发生的事实,命名用过去式或名词(PaymentReceived、OrderCanceled),而不是命令式。
转移的设计要注意「不可逆性」。CANCELED 应该是终态,从它出发不应有任何转移。如果业务上允许「取消后恢复」,那应该是「取消 → 重新下单」而不是「CANCELED → PENDING」,因为后者会让状态失去「这个订单曾被取消」的信息。
5. 守卫与动作的职责划分
守卫是纯函数,只做判断,不做副作用。动作可以有副作用,但必须幂等。
public class ShipGuard implements Guard<OrderStatus, OrderEvent> {
@Override
public boolean evaluate(StateContext<OrderStatus, OrderEvent> ctx) {
Order order = ctx.getMessage().getHeaders().get("order", Order.class);
return stockService.available(order.getSku()) >= order.getQty();
}
}
守卫里调用远程服务是有风险的:守卫会被反复评估,如果它调用了有副作用的接口就会出问题。推荐的守卫只读本地状态(从数据库或上下文里读出来的快照)。
动作的执行时机有两种选择,各有权衡:
- 转移前执行动作(pre-action):动作失败则转移不发生,状态保持原样。适合「状态变更依赖于动作成功」的场景。
- 转移后执行动作(post-action):状态先变更,动作异步执行,失败靠重试或对账。适合「状态变更是本地事实、外部通知可延迟」的场景。
大多数业务应该选后者,因为「状态」描述的是本地事实,外部通知失败不应该让本地状态回退。这也是 Saga 与分布式事务补偿 里「先提交本地事务、再发消息」的思路。
6. 表驱动状态机的实现
最简洁的实现是把转移表放在代码里,用一个校验函数统一入口:
public final class OrderStateMachine {
private static final Map<TransitionKey, OrderStatus> TRANSITIONS = Map.of(
new TransitionKey(PENDING, PAY), PAID,
new TransitionKey(PENDING, CANCEL), CANCELED,
new TransitionKey(PAID, SHIP), SHIPPED,
new TransitionKey(PAID, REFUND), REFUNDED,
new TransitionKey(SHIPPED, CONFIRM), COMPLETED
);
public static OrderStatus next(OrderStatus from, OrderEvent event) {
OrderStatus to = TRANSITIONS.get(new TransitionKey(from, event));
if (to == null) {
throw new IllegalTransitionException(from, event);
}
return to;
}
}
TransitionKey 是 (state, event) 的记录类型,用 Map 做 O(1) 查找。这种实现的优点是「转移表就是唯一真相」,任何非法流转都会在入口被拒绝。
如果需要支持守卫,把值换成「目标状态 + 守卫 + 动作」的三元组:
record Transition(OrderStatus target, Predicate<Order> guard, Consumer<Order> action) {}
private static final Map<TransitionKey, Transition> TABLE = Map.of(
new TransitionKey(PENDING, PAY),
new Transition(PAID, o -> o.getAmount() > 0, o -> ledger.record(o))
);
当转移表超过 30 条时,建议用外部配置(YAML 或数据库表)驱动,这样新增流转不需要改代码。但要注意配置化会让类型安全与编译期检查失效,需要用启动时的校验(检查所有状态都有出口、所有事件都有定义)来弥补。
7. 状态持久化与并发控制
状态必须持久化,且更新必须是原子的。最朴素也最可靠的做法是把状态放在业务表的一个字段里,用带前置条件校验的更新语句:
UPDATE orders
SET status = 'PAID', version = version + 1, updated_at = NOW()
WHERE id = ? AND status = 'PENDING' AND version = ?;
这条语句同时完成了三件事:校验当前状态是 PENDING(防止非法流转)、校验版本号(防止并发覆盖)、原子更新。返回影响行数为 0 就说明并发冲突或状态不符,需要重新读取后决定是重试还是报错。
一个常见错误是把校验与更新分成两条语句:
-- 反例:两条语句之间存在竞态窗口
SELECT status FROM orders WHERE id = ?;
-- 应用层判断 status == 'PENDING'
UPDATE orders SET status = 'PAID' WHERE id = ?;
并发下两个请求会同时通过 SELECT 的检查,然后都执行 UPDATE,后一个覆盖前一个。这就是「丢失更新」,在支付场景里会直接造成资金问题。
8. 乐观锁、版本号与幂等
乐观锁(版本号)适合冲突率低的场景,比如订单状态流转(同一个订单极少被并发操作)。冲突率高的场景(比如库存扣减)应该用悲观锁或原子操作。
@Transactional
public void transition(String orderId, OrderEvent event, long expectedVersion) {
Order order = orderRepo.findById(orderId).orElseThrow();
OrderStatus target = OrderStateMachine.next(order.getStatus(), event);
int updated = orderRepo.compareAndSetStatus(
orderId, order.getStatus(), target, expectedVersion);
if (updated == 0) {
throw new OptimisticLockException("状态已变更,请重试");
}
eventPublisher.publish(new OrderStatusChanged(orderId, order.getStatus(), target));
}
版本号不一定要额外维护一个字段:如果状态机是「状态单调推进」的(只会往前进,不会回退),那么用当前状态本身作为「前置条件」就够了,因为状态值就是隐式的版本号。只有当存在「同一状态可以多次进入」的情况(比如重试计数)时,才需要显式的版本字段。
幂等性要单独处理。同一个事件被投递两次(消息重投、用户重复点击),第二次应该被忽略而不是报错。做法是给每次事件处理生成幂等键(orderId + event + 请求ID),用唯一索引去重:
CREATE TABLE state_transition_log (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
biz_id VARCHAR(64) NOT NULL,
from_state VARCHAR(32) NOT NULL,
to_state VARCHAR(32) NOT NULL,
event VARCHAR(32) NOT NULL,
idem_key VARCHAR(128) NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uk_idem (idem_key)
);
插入成功说明是首次处理,插入冲突说明是重复,直接返回成功即可。这张表同时充当审计日志,一举两得。更完整的幂等设计见 重试幂等与补偿设计 。
9. 状态流转的审计日志
审计日志要能回答「谁、在什么时间、因为什么、把状态从 A 改成了 B」。上面的 state_transition_log 表再加三个字段就够:
ALTER TABLE state_transition_log
ADD COLUMN operator_id VARCHAR(64) NULL,
ADD COLUMN operator_type VARCHAR(16) NOT NULL DEFAULT 'SYSTEM',
ADD COLUMN reason VARCHAR(255) NULL;
operator_type 区分「用户操作」与「系统触发」,这对排查问题很重要:一个状态莫名变化时,先看是人工改的还是任务改的。
审计日志的另一个用途是「状态回放」:按时间顺序读出一个实体的所有流转,能重建出它的完整生命周期,也能发现异常模式(比如短时间内反复 PAID → REFUNDED → PAID)。在合规场景里,审计日志往往是监管检查的第一份材料,保留期要与业务约定明确。
日志写入要与状态更新在同一个事务里,否则会出现「状态改了但没日志」或「有日志但状态没改」的不一致。
10. 非法流转的防御
非法流转的防御要分三层:
- 应用层:状态机入口校验(
next()抛异常)。 - 数据库层:
CHECK约束或触发器,防止绕过应用直接改库。 - 监控层:告警非法流转尝试,因为它通常意味着有 bug 或有人在手工操作。
ALTER TABLE orders
ADD CONSTRAINT chk_order_status
CHECK (status IN ('PENDING','PAID','SHIPPED','COMPLETED',
'CANCELED','REFUNDED','MANUAL_REVIEW'));
数据库层的 CHECK 只能约束「值是合法的状态」,无法约束「流转是合法的」(那需要触发器或存储过程,代价大且难维护)。所以实用做法是:CHECK 管值域,应用层管流转,监控层兜底。
监控的实现很简单:状态机抛 IllegalTransitionException 时打一条带业务 ID 与事件类型的结构化日志,并对它做计数告警。正常运行时这个计数应该是 0,任何非零值都值得查。
11. 超时与定时推进
状态机本身不处理时间,但业务上常需要「超时自动推进」,比如「待支付超过 30 分钟自动取消」。两种实现方式:
- 定时扫描:一个任务周期性扫描「处于某状态且超过阈值」的记录,触发对应事件。实现简单,但延迟取决于扫描周期,且大表扫描成本高。
- 延迟队列:进入状态时投递一条延迟消息(比如 RabbitMQ 的延迟队列、Kafka 的时间轮、Redis 的 ZSET),到期后触发事件。延迟精确,但需要处理「状态已经变了,消息才到」的情况。
-- 定时扫描的查询要建索引,否则大表会全扫
SELECT id FROM orders
WHERE status = 'PENDING'
AND created_at < NOW() - INTERVAL 30 MINUTE
LIMIT 1000;
无论哪种方式,触发超时事件时都必须重新校验当前状态(「状态已经变了」是常态),并保证事件幂等。延迟消息到达时,如果订单已经支付,超时取消事件应该被忽略而不是强制取消。
用 Temporal 之类的引擎可以直接用 Workflow.await 加超时,把这件事变成框架能力,参考 Temporal 与持久化执行
。
12. Spring StateMachine 实战
Spring StateMachine 是 JVM 生态里最完整的状态机实现,支持分层、正交、守卫、动作、持久化。
@Configuration
@EnableStateMachineFactory
public class OrderStateMachineConfig
extends StateMachineConfigurerAdapter<OrderStatus, OrderEvent> {
@Override
public void configure(StateMachineStateConfigurer<OrderStatus, OrderEvent> states)
throws Exception {
states.withStates()
.initial(OrderStatus.PENDING)
.end(OrderStatus.COMPLETED)
.end(OrderStatus.CANCELED)
.state(OrderStatus.MANUAL_REVIEW);
}
@Override
public void configure(StateMachineTransitionConfigurer<OrderStatus, OrderEvent> t)
throws Exception {
t.withExternal().source(PENDING).target(PAID).event(PAY)
.guard(amountGuard()).action(recordLedger())
.and()
.withExternal().source(PAID).target(SHIPPED).event(SHIP)
.guard(stockGuard())
.and()
.withExternal().source(PAID).target(REFUNDED).event(REFUND);
}
}
使用时通过 StateMachineFactory 创建实例、恢复状态、发送事件:
StateMachine<OrderStatus, OrderEvent> sm = factory.getStateMachine(orderId);
sm.getStateMachineAccessor().doWithAllRegions(a -> a.resetStateMachine(
new DefaultStateMachineContext<>(order.getStatus(), null, null, null)));
sm.sendEvent(Mono.just(MessageBuilder.withPayload(PAY)
.setHeader("order", order).build())).subscribe();
要注意 Spring StateMachine 的实例状态在内存里,必须显式持久化(StateMachinePersister)或在每次操作时从数据库恢复。很多团队踩的坑是「重启后状态机回到初始态」,因为忘了持久化。对多数业务来说,直接维护数据库状态字段加表驱动校验,比引入 Spring StateMachine 更简单可控。
13. AWS Step Functions 的 ASL
Step Functions 是云托管的「状态机即服务」,用 Amazon States Language(ASL)声明。它的状态类型比传统状态机丰富:Task(执行工作)、Choice(分支)、Wait(等待)、Parallel(并行)、Map(遍历)、Pass(数据变换)。
{
"Comment": "订单状态机",
"StartAt": "Pending",
"States": {
"Pending": {
"Type": "Choice",
"Choices": [{"Variable": "$.event", "StringEquals": "PAY", "Next": "Paid"}],
"Default": "Canceled"
},
"Paid": {
"Type": "Task",
"Resource": "arn:aws:lambda:cn-north-1:123:function:ship",
"Retry": [{"ErrorEquals": ["States.TaskFailed"], "MaxAttempts": 3}],
"Catch": [{"ErrorEquals": ["States.ALL"], "Next": "ManualReview"}],
"Next": "Shipped"
},
"Shipped": {"Type": "Wait", "Seconds": 3600, "Next": "Confirm"},
"Confirm": {"Type": "Succeed"},
"Canceled": {"Type": "Succeed"},
"ManualReview": {"Type": "Fail", "Error": "NeedsHuman"}
}
}
Step Functions 的优势是「零运维 + 内建重试与超时 + 完整执行历史」。它的状态转换是按次数计费的,一个高频短流程(每秒上千次)成本会很高,此时应该用 Express 模式或干脆不用它。
它还有一个约束值得注意:状态之间传递的数据($. 引用)默认限制在 256 KB,大对象要存 S3 传引用。这与工作流引擎里的「变量大小控制」是同一条经验。
14. 状态爆炸与状态压缩
状态爆炸有三个来源,对应三种解法:
| 来源 | 表现 | 解法 |
|---|---|---|
| 多维度揉在一起 | PAID_NOT_SHIPPED 这类组合态 | 拆成多个正交字段,各自状态机 |
| 中间过程建成状态 | PROCESSING、RETRYING | 用任务队列或锁表达中间态 |
| 状态带了参数 | APPROVED_BY_MANAGER_L1 | 参数放字段,状态只表达阶段 |
-- 反例:状态里编码了参数
status IN ('APPROVED_L1', 'APPROVED_L2', 'APPROVED_L3')
-- 正例:状态表达阶段,参数放字段
status = 'APPROVED', approval_level = 2
经验阈值:状态数超过 20 个就该审视建模了;超过 50 个几乎肯定有可以拆出去的维度。状态爆炸的直接代价是转移表变成二维矩阵(状态数 × 事件数),维护成本指数上升。
15. 测试策略
状态机的测试应该覆盖三类:
@Test
void 所有合法流转都能成功() {
for (var e : OrderEvent.values()) {
var to = OrderStateMachine.next(PENDING, e); // 不抛异常即通过
assertNotNull(to);
}
}
@Test
void 非法流转被拒绝() {
assertThrows(IllegalTransitionException.class,
() -> OrderStateMachine.next(COMPLETED, PAY));
}
@Test
void 终态没有出边() {
for (var s : List.of(COMPLETED, CANCELED, REFUNDED)) {
for (var e : OrderEvent.values()) {
assertThrows(IllegalTransitionException.class,
() -> OrderStateMachine.next(s, e));
}
}
}
第三类测试最有价值,它把「终态不可离开」这条不变量变成可执行的断言。类似的可以断言「每个非终态至少有一个出边」(避免死状态)、「每个状态都从初始状态可达」(避免孤儿状态)。
除了单元测试,还应该有一个「状态图一致性测试」:从代码里的转移表生成状态图,与设计文档里的图做对比。在 CI 里跑这个对比,能防止「代码改了但文档没改」的漂移。
16. 落地路线图
- 第 1 天:把业务实体的所有状态与事件列成一张表,与业务方逐条确认,特别确认「哪些状态是终态」。
- 第 2 天:实现表驱动的状态机(转移表 +
next()函数),加上非法流转的单元测试。 - 第 3 天:把状态更新改成带前置条件的原子更新语句,加上版本号或状态前置校验。
- 第 4 天:加上流转审计表与幂等键唯一索引。
- 第 5 天:接入监控,对非法流转与冲突失败做计数告警。
这套流程的核心产物是那张转移表。它应该放在代码里(可执行)与文档里(可评审)各一份,并在 CI 里校验两者一致。做到这一点,状态机的长期维护成本会显著低于裸写状态字段。
17. 权衡取舍
| 选择 | 收益 | 代价 |
|---|---|---|
| 表驱动状态机 | 转移表是唯一真相,易测试 | 表达力有限,不支持并行与子流程 |
| 引入 Spring StateMachine | 分层、正交、持久化开箱可用 | 学习成本高,内存态易失,配置繁琐 |
| 状态存业务表字段 | 与业务数据同事务,简单 | 表变大,状态查询要加索引 |
| 独立状态表 | 状态与业务解耦,可多实体复用 | 需要额外事务保证一致性 |
| 乐观锁 | 无锁开销,冲突率低时高效 | 冲突率高时重试放大 |
| 前置状态校验(不用版本号) | 无需额外字段 | 只适用于状态单调推进的场景 |
| 用 Step Functions | 零运维、执行历史完整 | 按转换计费,厂商锁定 |
| 用延迟队列做超时 | 触发精确 | 需处理「状态已变」的过期消息 |
18. 常见坑清单
- 先
SELECT判断状态再UPDATE,并发下丢失更新,应该用带前置条件的原子更新。 - 把瞬时中间态(
PROCESSING)建成正式状态,大量实例卡在该状态且无人清理。 - 状态里编码参数(
APPROVED_L2),状态数膨胀成笛卡尔积。 - 终态没有出边保护,出现「已完成后又被退款」这类逻辑矛盾。
- 状态变更与审计日志不在同一事务,出现状态与日志不一致。
- 事件处理没有幂等键,重复投递导致状态被推进两次(比如重复发货)。
- 守卫里调用有副作用的远程接口,守卫被反复评估时产生重复操作。
- 用 Spring StateMachine 但忘了持久化,应用重启后状态回到初始态。
- 超时扫描没有索引,大表全表扫描,把数据库拖慢。
- 延迟队列的过期消息不校验当前状态,强制推进已变化的订单。
- 前后端状态定义不同步,前端显示可操作但后端拒绝,体验割裂。
- 直接改数据库状态字段做「修复」,绕过状态机,破坏审计链路。
19. 小结
状态机引擎的价值是「用一张可评审的转移表,换取状态流转的严谨性」。它的成本远低于完整的工作流引擎,但能挡住最危险的一类 bug:并发下的状态覆盖与非法流转。判断该不该用它,看「流转是否是线性的、状态是否可枚举、等待是否靠外部事件」。
实现上记住三条底线:状态更新必须是带前置条件的原子操作;每次流转必须有审计记录;事件处理必须有幂等键。这三条做到了,剩下的复杂度就只是「状态怎么划分」这个设计问题,而它可以通过「拆维度、去掉中间态、参数不入状态」三个手法控制住。
如果流程开始出现并行分支、子流程嵌套、跨天等待,那就到了该换工作流引擎的临界点,可以读 BPMN 2.0 与 Camunda 实战 或 Temporal 与持久化执行 ;如果流程里有大量人工审批,则应该看 人工任务与审批流表单 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。