事务不归引擎管:ITransactionTemplate,聚合一致性的最后一块拼图

前三篇都在引擎里面打转:状态机住进实体、一致性交给聚合、状态写入收进命令面。这一篇跨出引擎半步,处理两个世界交界处的问题------

你的业务代码提交一张请假单,然后调用引擎发起流程。这两件事中间断一下,会发生什么?

一、幽灵单是怎么炼成的

假设引擎方法自己带着事务注解,看起来很贴心。但请想清楚它意味着什么:请假单的 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系列 专栏可查)
相关推荐
IT_陈寒6 小时前
Java中equals方法比了个寂寞?原来这才是正确的重写姿势
前端·人工智能·后端
Thneonl6 小时前
Celery 生产踩坑:1000 任务积压与 acks_late 双重执行
后端·python
卷福同学6 小时前
第一次当面试官有感
后端·面试
苏三说技术6 小时前
为什么越来越多人用 OnlyOffice?
后端
知守观6 小时前
@Transactional 事务失效排查,try-catch 吞异常导致回滚失败(附源码分析)
后端·spring
羑悻6 小时前
Codex + Seed-2.1-pro 实测:多模态理解 + Coding Agent 能扛住真实仓库吗?
后端
CopyCode6 小时前
用 AI 迁项目有多爽?我把 Webpack 迁 Vite 的全过程记下来了
前端·架构
美好世界6 小时前
Codex 源码导读:第五部分——事件出口与多入口适配
架构
颜进强6 小时前
14 · NestJS ExecutionContext 执行上下文:守卫、拦截器、过滤器拿到的"同一个 context",为什么能力不一样?
前端·后端·ai编程