做过工作流引擎的,都遇到过这么一个瞬间:
周一把审批流程从"两级审批"改成"三级审批",发布了。周二早上,业务同学问:昨天那 20 张还在审批中的单子,现在走两级还是三级?
如果你的引擎回答不了这个问题,或者回答得很随意------那多半是设计时就没想过"定义"和"正在用这份定义跑着的实例"是两回事。
这篇文章做的事,就是把这个问题在 jeeflow 里拆到底:一条在跑的审批单,它到底"记着"流程定义的什么;改了定义之后,下一次它走到引擎时,引擎读到的到底是新版还是旧版。拆完你会发现一个反直觉的结论------答案不是"都走新版",也不是"都走旧版",而是取决于你当时点的是哪个按钮。
一、先分清两个东西:设计稿,和流程定义
jeeflow 里,"一份流程"在数据库里其实分住在两张表里,对应两个完全不同的东西:
- 设计稿(草稿) :
wf_process_design+wf_process_design_his。设计器里画的东西、改了又改的东西,都存在这里。his表是快照历史,你每保存一次就留一个版本。 - 流程定义(发布产物) :
wf_process_define。引擎真正拿去跑审批的东西,带一个version列。
规范里这句话是硬约定(06-facade.md):
设计稿(草稿)≠ 定义(发布产物):
save/updateDefine存草稿快照(历史表),deploy才生成流程定义。设计稿内容变更后isDeployed自动置 0(防误用旧定义)。
翻译成人话:你在设计器里画得再欢,不点"发布",引擎一无所知。 而且你一旦在设计器里改了内容,那份"已发布"的标记会自动被抹掉------这是防"你以为改了已经生效"的护栏,isDeployed=0 意味着"当前草稿和线上跑的那版对不上了"。
java
// JeeflowFacade.java:897 processDesign/updateDefine
// 内容快照变更 → 置为未部署(对齐 boot3 updateDefine 语义)
if (contentBytes(args) != null) design.setIsDeployed(0);
所以"设计稿 → 定义"是一次发布动作,不是自动同步。这一步是后文所有版本行为的分界。
二、两个按钮,两种改法:deploy 和 redeploy
发布有两条路,它们对"版本号"和"定义 id"的处理不一样,这正是后文在跑的单子走向不同的根源。
deploy:发一个新版本,拿到一个新 id
deploy 的语义是"按流程名(name)找最新那条定义,有就把版本号 +1 插一条新记录,没有就从 0 起":
java
// JeeflowFacade.java:1202 deploy 版本管理(对齐 boot3)
private Long saveDeployedDefine(ProcessModel model, byte[] bytes) {
// 按 name 查最新定义
PageResult<...> page = repository.pageDefines(query);
int version = 0;
if (page.getRows() != null && !page.getRows().isEmpty()) {
Integer latest = page.getRows().get(0).getVersion();
version = (latest != null ? latest : 0) + 1; // ← version+1
}
def.setVersion(version);
repository.saveDefine(def); // ← 插一条新记录,新 id
return def.getId(); // ← 返回新 id
}
Go 侧同一语义(facade.go:275):version = latest.Version + 1。
关键在最后一行:deploy 返回的是一条新记录的 id 。旧记录还在库里躺着(所以 wf_process_define 表里同一个 name 会有 v0、v1、v2... 多行),新版本是一个全新的 id。
redeploy:原地改内容,id 和版本号都不动
redeploy(重新发布)的语义完全反过来------它不换行,就在原来那条记录 上把内容(content)覆盖掉,版本号保持原样:
java
// JeeflowFacade.java:1005 designRedeploy 按 name 取最新定义
IProcessRepository.DefineRow last = page.getRows().get(0);
ProcessInstance.ProcessDefine def = new ProcessInstance.ProcessDefine();
def.setId(last.getId()); // ← 还是原来那条 id
def.setContent(bytes); // ← 只覆盖内容
// issues/59:保留原 version(替换语义,不递增)
def.setVersion(last.getVersion());
repository.updateDefine(def); // ← UPDATE 原记录,不插新行
这里那行注释值得单独看:issues/59。这是引擎里一个真实修过的 bug------redeploy 本该"原地替换、版本号不变",但某个版本漏设了 version 字段,被 JDBC 兜底逻辑误写成了 1,导致一次原地重发把版本号从 3 打回了 1。这个 bug 本身说明一件事:redeploy 的设计意图是"换内容、不换身份",版本号和 id 都是"身份"的一部分,不能被顺带改掉。
两个按钮一句话对比:
deploy |
redeploy |
|
|---|---|---|
| 记录 | 插新行 | 覆盖原行 |
| 版本号 | +1 | 不变 |
| 定义 id | 新的 | 不变 |
| 语义 | 发新版本 | 热改当前版内容 |
三、一条在跑的单子,"记着"流程定义的什么
要回答"在跑的单子走哪版",得先知道它记了定义的什么。
答案有点反直觉:它不记版本号,只记 id。 看实例表的建表语句(规范 01-data-model.md,各语言建表都从这一份派生):
sql
CREATE TABLE wf_process_instance (
id BIGINT PRIMARY KEY,
process_define_id BIGINT NOT NULL COMMENT '流程定义ID', -- 只有 id
state INT DEFAULT 10,
...
) COMMENT='流程实例表';
对比一下定义表,version 列只在 wf_process_define 上:
sql
CREATE TABLE wf_process_define (
id BIGINT PRIMARY KEY,
name VARCHAR(100) NOT NULL,
content BLOB,
version INT DEFAULT 0, -- 版本号只在定义表
...
);
实例表里压根没有 version 这一列。 一条在跑的审批单,跟流程定义的全部关系就是 process_define_id 这一个外键。它不快照版本号,不快照流程内容------它只记"我这条单是按哪条定义发起的"。
那引擎执行的时候拿什么跑?答案是:每次执行,都按这个 id 现查定义、现解析。 Java 引擎在发起时(JeeflowEngineImpl:72):
java
ProcessInstance.ProcessDefine define = repository.findDefineById(defineId);
ProcessModel model = ModelParser.parse(define.getContent()); // 解析内容
而在每一次办理任务 时(JeeflowEngineImpl:244,这才是关键的循环):
java
ProcessInstance instance = repository.findInstanceById(task.getProcessInstanceId());
ProcessInstance.ProcessDefine define = repository.findDefineById(instance.getDefineId());
ProcessModel model = ModelParser.parse(define.getContent());
注意是 findDefineById(instance.getDefineId())------按 id 查,不是按"我发起时那个版本"查 。Go 引擎一模一样(engine_impl.go:257):
go
def, _ := e.repo.FindDefineByID(ctx, inst.DefineID)
把这两条事实放在一起,结论就自己浮出来了。
四、所以,在跑的单子到底走哪一版
引擎每次执行都按实例记的那个 define_id 现查定义内容------那么"走哪一版"完全取决于:你改定义的动作,有没有换掉这个 id。

- 你用
redeploy改了流程 :id 不变、版本号不变,只有content变了。在跑的单子记的 id 没变,但引擎下一次按这个 id 现查,读到的是你刚改过的内容 。→ 在跑的单子跟着变。 - 你用
deploy发了新版本 :新记录、新 id、版本号 +1。在跑的单子记的还是旧 id ,引擎按旧 id 查,读到的还是旧内容。→ 在跑的单子不受影响,走旧版。
用开头那个场景收个尾:周一你把"两级审批"改成"三级审批"------
- 若你走的是
redeploy(原地改那一条):昨天那 20 张在跑的单子,今天批到下一步时,会开始按三级审批走。业务同学昨天看到的待办,今天多了一个审批环节。 - 若你走的是
deploy(发 v1 新版本):昨天那 20 张单子继续按两级审批跑完,只有今天之后新发起的单子才走三级。
同一次改动,两个按钮,在跑的单子走向相反。 这就是"有的跟着变、有的不变"的完整机理,而且它不是 bug------是"引擎按 id 现查定义"这个设计 + "两个按钮对 id 的不同处理"叠加出来的必然结果。
五、redeploy 是能力,也是刀口
上面的结论顺带回答了一个更尖锐的问题:redeploy 让"改流程不用等单子跑完"成了可能------这既是工作流引擎里很受欢迎的热更能力,也是一把刀口。
它危险的地方在于:你 redeploy 改完内容的那一刻,所有钉在这个 id 上的在跑实例,下一次执行都会读到新结构。如果你的新结构把某个审批节点删了、把审批人换了、把会签比例改了------那些正在半路的单子会按新规则继续走。有的场景你正想要这个效果(线上紧急加一个审批人),有的场景这是事故(在跑的报销单突然多了个环节,财务懵了)。
反过来,deploy 发新版本因为换了 id,天然把"在跑的"和"新发起的"隔开了------老单子安安稳稳按老版跑完,新单子走新版。这是更接近"发布 = 不可变快照"的心智模型,代价是你无法热改在跑的单子。
两个按钮其实对应两种工程取舍,选哪个取决于你对"改动影响面"的偏好:
- 想让改动立刻作用到存量在跑的单子 (热更)→
redeploy,但要清楚你在改所有存量的运行轨迹。 - 想让改动只影响新发起的单子 (版本隔离)→
deploy,老单子按老版自然走完。
六、还有两件事,容易被忽略
1. "按名字取最新"是另一条隐线。 引擎里多处用 getLastByName(按流程名取最新版本)来决定"新发起的单子用哪条定义"。也就是说,发起路径认的是"这个名字的最新定义",而运行路径认的是"这条实例钉住的那个 id"。这两条线在大多数时候指向同一条记录(最新且未被替换),但一旦你 deploy 出新版本,发起会指到新 id、存量还在跑旧 id------"新发起的"和"在跑的"从此分叉,这正是上一节说的版本隔离的由来。
2. 定义被禁用(upAndDown state=0)会拦住发起,但不拦在跑的。 processDefine/upAndDown 把定义 state 置 0 后,前端"发起申请"页靠 listByType 返回的 processDefineState 控制发起按钮(state=0 时不可发起)------这是发起路径的硬依赖。但一条已经发起、正在跑的实例,它的运行路径是按 id 现查定义内容来执行的,并不会因为定义被停用而停下。所以"停用"拦的是新单,不是老单------这也是同一个"发起认 name、运行认 id"设计的延伸。
写在最后
把这条链捋一遍:
bash
设计稿(his 快照)
└─ deploy ──→ 定义(新行、version+1、新 id)
└─ redeploy ─→ 定义(原行、version 不变、内容覆盖)
│
实例只记 process_define_id(不记 version)
│
引擎每次执行:findDefineById(实例.id) 现查现解析
│
redeploy 改过 → 在跑的单子跟着变
deploy 发新版 → 在跑的单子走旧版
一条在跑的审批单,它记的永远只是"我按哪条定义发起的"这一个 id;而引擎每次执行都按这个 id 现查内容。所以改了定义之后它走哪一版,不由"版本号"决定,而由"你的改动有没有换掉这个 id"决定 ------redeploy 换内容不换 id,所以在跑的单子跟着变;deploy 换新 id,所以在跑的单子被隔在旧版里。
如果你的引擎被问"改了流程,在跑的单子走哪版"时答不上来,多半是"定义"和"实例"在它的模型里还没分开------这条 process_define_id 外键,就是那个分界的物理体现。
参考资料(往期相关篇目与链接):
- jeeflow 文档站:jeeflow-doc.mldong.com
- Java 参考实现:github.com/mldong/jeef...
- Go 实现:github.com/mldong/jeef...
- 前端工作台:github.com/mldong/jeef...
- 在线演示:开源演示站 jeeflow-demo.mldong.com · 集成演示站 jeeflow-pro.mldong.com
- 往期:《流程定义设计:一份 LogicFlow JSON 全解》(定义结构)·《用 DDD 设计工作流引擎:聚合根与充血模型》(实例/任务聚合根)·《一条命令,十分钟:jeeflow 工作流应用的六语言一键部署》(发布那条路)