前三篇都在引擎里面打转:状态机住进实体、一致性交给聚合、状态写入收进命令面。这一篇跨出引擎半步,处理两个世界交界处的问题------
你的业务代码提交一张请假单,然后调用引擎发起流程。这两件事中间断一下,会发生什么?
一、幽灵单是怎么炼成的

假设引擎方法自己带着事务注解,看起来很贴心。但请想清楚它意味着什么:请假单的 insert 走你的事务,流程发起走引擎的事务------两个事务。于是提交请假单只有两种死法,而且都不报错:
- 死法 A:单存了,流程没发。 用户看着提交成功,审批人的待办里却永远不会出现这张单------它成了一张"看不到的单子",等月底对账才被发现。
- 死法 B:流程发了,单回滚了。 审批人收到待办,点进去什么业务数据都没有------一张"幽灵审批单",批的是不存在的业务。
两种死法的根源是同一个:业务单据和流程发起没有共同的事务边界。这个问题不是引擎自己能解决的------恰恰相反,如果引擎自作主张开了自己的事务,它就是在制造问题。
二、jeeflow 的答案:引擎里那道 19 行的口子

jeeflow-core 对事务的全部认知,浓缩在一个 19 行的私有方法里:
java
// JeeflowEngineImpl(等价摘录)
private <T> T runInTx(ITransactionTemplate.Supplier<T> action) {
ITransactionTemplate tx = ServiceContext.find(ITransactionTemplate.class);
if (tx != null) {
return tx.execute(action); // 宿主注册过 → 在宿主的事务边界内执行
}
return action.get(); // 未注册 → 裸执行,内存测试直通
}
ITransactionTemplate 是引擎的可选 SPI,javadoc 原话:"如果注册,引擎的每个命令(启动/完成任务等)在此事务边界内执行。若不注册,引擎裸执行(适合内存测试)。"引擎的五个命令入口------发起、办理、跳转、直达终点、回退首节点------全部经这一个口子执行。
读这段代码有三个要点:
① 它是可选的。 拿不到实现就裸跑。引擎的内存测试、各语言的仓内快测,零事务配置直接用------事务对测试环境不是负担。
② 查找发生在运行时。 core 模块没有 import 任何事务框架,SPI 接口自己只依赖 JDK。引擎对事务的依赖被压缩成"知道有这么个东西"。
③ 异常原样穿透。 runInTx 不吞不包(受检异常转运行时抛出),回调抛什么、要不要回滚,完全由宿主的模板决定。引擎知道事务"存在",不知道事务"是什么"------这就是"不归引擎管"的全部含义。
三、宿主侧:十行代码焊死两个世界
引擎留了口子,宿主来兑现。Java 生态的兑现是一个独立的适配模块(spring-boot-autoconfigure,core 依然零依赖),自动配置里就一行:
java
@Bean
public ITransactionTemplate jeeflowTransactionTemplate(PlatformTransactionManager tm) {
return new SpringTransactionTemplate(tm);
}
SpringTransactionTemplate 把引擎的 SPI 翻译成宿主事务模板的调用,默认传播级别 REQUIRED------这正是焊死两个世界的关键:如果你的业务代码已经在事务里,引擎命令会自动并入当前事务;如果不在,它为自己开一个。于是"请假单 insert + 流程发起"可以这样写:
java
transactionTemplate.execute(status -> {
leaveService.insert(form); // 宿主业务
engine.startProcessInstanceById(defineId, operator, args); // 引擎命令
return null;
});
// 出了这个代码块:要么全提交,要么全回滚------幽灵单失去了存在论基础
事务策略从此分了工:用不用事务、跟谁共用事务、什么传播行为,是宿主的策略;引擎只保证一件事------你把命令放进来,它就在你的边界里老老实实执行。
四、八栈一个模子:同一个 SPI 的三种活法
事务边界收在一个 SPI 里还有一个红利:八种语言的引擎,事务的"形状"完全一致,只是各语言按自己的生态兑现:

Java:Spring 适配模块。 上面讲的方案------core 零依赖不动,Spring 的传播、数据源绑定这些语义整体关在适配模块里。
C#:环境连接(AsyncLocal)。 MySql 版的模板实现最"显式":checkout 一条连接、开启事务,回调期间通过 AsyncLocal 把连接放进环境,仓储所有方法透明共用;出函数 commit 或 rollback。类注释里写着这条契约不变量:"同事务内所有仓储方法同一连接;仓储签名不带事务参数"------事务通过环境传递,仓储的签名永远干净。
PHP:NoOp 兜底。 NoOpTransactionTemplate------什么都不做的模板。可选 SPI 的礼貌之处在于:不关心事务的宿主拿到的不是一个异常,而是一个空实现。
而且这不是口头承诺。C# 的行为测试套件里专门有一条(§6.2):事务回调抛错 → BEGIN 之后的所有写入回滚干净,无半完成实例------第二篇讲过的半状态,在这里被测试钉死。
五、三层责任,各归其主
把这一系列的稳定性话题收个口。jeeflow 里"事务"其实是三层,每层的责任人不同:
| 层 | 内容 | 责任人 | 机制 |
|---|---|---|---|
| ① 单条 SQL | 一行 insert/update | 仓储自己 | 连接即取即用 |
| ② 聚合内多行 | 实例+任务+参与者一起变 | 仓储自己 | 同连接级联(第二篇的"一个入口搬完聚合") |
| ③ 命令+宿主业务 | 流程命令和业务单据同生共死 | 宿主 | ITransactionTemplate 注入 |
注意②和③的分界:聚合的原子性是引擎的事(仓储机制兜住),跨到宿主业务表的原子性是宿主的事(SPI 注入)。引擎对三层都不装糊涂------留好口子,但绝不越界替宿主做决定。
有一种常见误区值得点名:在引擎命令上糊一层"自带事务"的注解,觉得这样宿主就不用操心了。代价 immediately 就来------引擎被绑死在某一种事务实现上,宿主的业务事务和引擎事务变成两个平行世界(第一节的那两种死法原样奉还),而八种语言要各自找注解的等价物。引擎"不带事务"不是偷懒,是这条线画对了。
结尾
至此,这个系列讲完了一致性的完整链条:聚合根守住单据内部的状态机(②③篇),仓储守住聚合的原子搬运(②篇),事务模板守住引擎与宿主世界的接缝(本篇)。三层各自独立,又层层咬合。
回头看这个 SPI 的设计,最值得学的不是接口长什么样,而是那句判断:**遇到"两边都有理"的横切关注点,把它做成可选的口子,让真正拥有策略的一方来做主。**引擎没有丢掉一致性------它只是把"怎么一致"还给了最懂业务的人。
引擎八语言实现全部开源,三层责任的每一层都能在源码里找到对应的守护者。
参考资料
- mldong 官网(框架 / 在线演示):www.mldong.com/
- jeeflow 文档站(规范 / 快速上手 / 多语言 demo):jeeflow-doc.mldong.com/
- jeeflow 演示站:jeeflow-demo.mldong.com/
- 系列往篇:《同一个审批流引擎,我写了两次》《聚合边界:为什么 ProcessTask 没有自己的 Repository》《引擎里没有 setStatus》(DDD 系列 ①②③,掘金 jeeflow系列 专栏可查)