引言
普通服务的发版是「替换」:新版本起来、旧版本下线,进程状态随进程一起消失。工作流的发版是「叠加」:新版本部署上去时,线上还有几千个用旧版本定义的实例正在跑,有的已经跑了一半,有的要再等 30 天才结束。这些实例既不能被杀掉,也不能被新代码「顺手」改变行为。
这就是工作流版本管理的全部难点所在。它不是一个「打 tag」的问题,而是三个独立的问题叠在一起:定义版本(流程图的第几个版本)、代码版本(执行引擎读的是哪份实现)、数据版本(实例里持久化的状态是什么结构)。三者可以独立演进,但任意两者的错配都会导致运行中的实例行为异常。
最反直觉的一点是:在持久化执行引擎里,老实例重放时用的是新代码。这意味着「改代码」这件事对运行中的实例是隐式的、自动生效的,如果你没做兼容处理,它就会在某个凌晨以 NonDeterminismError 的形式炸出来。而在 BPMN 引擎里恰好相反:老实例继续用旧版本定义,新代码对它们完全不生效,于是「迁移」变成了一个必须显式发起的动作。
本文按「版本对象 → 兼容规则 → 引擎机制 → 灰度与回滚 → 治理流程」的顺序展开,重点讲清楚每种引擎的默认行为以及如何利用它。引擎的整体差异参见 工作流引擎全景与选型 ,Temporal 的重放模型细节参见 Temporal 与持久化执行 。
目录
- 版本问题的本质
- 三类版本对象:定义、代码、数据
- 版本号与标识的命名规范
- 不可变部署:新版本新定义
- 运行中实例的三条处置路线
- 加法兼容与破坏性变更
- 长驻实例的版本分支
- Camunda 的版本选择与迁移
- 状态机的版本演进
- 输入输出契约的兼容
- 灰度发布与流量切分
- 回滚:代码回滚与状态回滚
- 迁移的执行方式
- 双写与并行验证
- 破坏性变更的治理流程
- 版本与规则配置的联动
- 测试与演练
- 观测与审计
- 落地路线图
- 权衡取舍
- 常见坑清单
- 小结
1. 版本问题的本质
先看一个具体的失败场景。某个订单流程在 v3 版本里把「扣库存」和「扣款」的顺序调换了。代码发布后,一个在 v2 时期启动、正卡在「等支付回调」的实例收到了信号,引擎开始重放它的历史。历史记录的是「先扣款后扣库存」,而新代码走的是「先扣库存后扣款」,重放到第二步时引擎发现命令序列与历史对不上,抛出 NonDeterminismError,实例卡死。
这个场景说明了三件事。第一,运行中实例的生命周期可能远长于代码的发布周期,跨月的实例在业务上很常见(等审批、等对账、等宽限期)。第二,重放把「代码变更」变成了对历史实例的隐式影响,不需要任何迁移动作就会生效。第三,能安全变更的范围被历史严格约束:只有「在历史断点之后追加」的变更才是安全的。
因此在工作流领域,「能不能改」这个问题没有统一答案,必须先问「这个变更会影响哪类实例」:还没启动的实例不受影响,已结束的实例不受影响,只有「运行中且重放点在新旧代码行为不一致的位置」的实例会出问题。版本管理的一切机制,都是为了把这批实例识别出来并给出安全路径。
2. 三类版本对象:定义、代码、数据
把版本拆成三个独立维度,是理解所有引擎行为的前提:
| 版本对象 | 载体 | 变更影响 | 典型引擎 |
|---|---|---|---|
| 定义版本 | 流程定义文件(BPMN XML、DSL、图) | 影响新实例的流程结构 | Camunda、Zeebe、Argo |
| 代码版本 | 服务实现(Worker、Activity、Delegate) | 影响所有实例的执行行为 | Temporal、Cadence |
| 数据版本 | 实例状态、变量、事件历史 | 影响反序列化与状态迁移 | 全部 |
三种组合对应三种默认行为:定义驱动型(Camunda)老实例锁在旧定义上,代码变更只对新定义生效;代码驱动型(Temporal)所有实例共用同一份代码,靠代码内的版本分支区分;数据驱动型(状态机)状态本身是数据,迁移就是改数据。
选择哪类引擎,本质上是在选择「版本变更的责任落在谁身上」。定义驱动型把责任放在运维(要显式发起迁移),代码驱动型把责任放在开发(要写兼容分支),数据驱动型把责任放在数据迁移脚本上。没有免费的选项,只有成本分布不同的选项。
3. 版本号与标识的命名规范
版本标识混乱是迁移灾难的常见起点。三条硬规则:
1. 版本号单调递增且不可复用:v1, v2, v3 ...,禁止用 v1 覆盖发布(回滚也要用新号)
2. 版本号与业务语义解耦:不要用「双十一版」这种名字,用 v2026_10_07 或纯序号
3. 每个实例必须持久化它启动时的版本号,并在查询接口里暴露出来
第二条看起来是小事,但它决定了两件重要的事:能不能按版本号做灰度切流(「只让 v5 的实例走新逻辑」),以及能不能在事故时精确统计影响面(「有多少实例还在 v3」)。
在 Temporal 里,getVersion 的第一个参数是「变更标识」(change ID),它必须是全局唯一且永不复用的字符串,比如 add-risk-check-2026-10。用 add-risk-check 这种语义名会在第二次修改同一处逻辑时产生冲突:引擎按 change ID 查找历史中的版本记录,同名会被误判为同一次变更。
4. 不可变部署:新版本新定义
定义驱动型引擎的核心规则是流程定义不可变:修改流程图不是「编辑」而是「发布新版本」。Camunda 的做法是每次部署生成一条新的定义记录,带自增的 version 与唯一的 deploymentId:
# 部署新版本,引擎自动分配 version=4
curl -X POST "$CAMUNDA/engine-rest/deployment/create" \
-F "deployment-name=order-flow-v4" \
-F "order-flow.bpmn=@order-flow-v4.bpmn"
# 查询某定义的所有版本
curl "$CAMUNDA/engine-rest/process-definition?key=order-flow"
不可变的收益是可追溯与可回滚:任何历史实例都能查到它当时用的确切定义,回滚只需把旧版本标记为「新实例默认使用」,而不需要重新部署文件。
代价是版本堆积。每天发布一次、一年后同一个 key 下有 365 个版本,引擎的部署表与缓存都会被撑大。治理方式是定期清理「已无运行实例且已停用」的旧版本,清理前必须确认 running instance count == 0。Camunda 提供按 key 查询运行中实例数的接口,把它接进清理脚本的检查项里。
5. 运行中实例的三条处置路线
面对版本升级,运行中的实例只有三条路可走,必须逐条决策而不是默认「什么都不做」:
路线 A:留在旧版本(默认)
适用:变更只影响新增步骤,旧实例不需要新能力
代价:旧版本代码/定义必须一直保留,不能删
路线 B:迁移到新版本
适用:旧实例必须获得新能力(修了 bug、换了外部接口)
代价:需要写迁移逻辑,处理「迁移到一半」的中间态
路线 C:终止并重跑
适用:实例本身可以重来(幂等、无副作用、数据可重放)
代价:业务可见的中断,需要人工确认
路线 B 是最难的,因为迁移本身也要处理失败。一个常见的半成品方案是「批量调用迁移接口」,但它没考虑:迁移过程中实例同时在推进(并发冲突)、迁移到一半服务重启(部分迁移)、迁移失败后状态不明(需要回滚)。
正确做法是把迁移做成一个幂等且可续跑的批处理:每条记录标记 migrated_at,迁移成功后写入;迁移失败记录失败原因并继续下一条;整体可重入。迁移脚本本身就应该是一个工作流——它有重试、有状态、有失败分支,正好是该用工作流引擎来跑的东西。
-- 迁移进度表:每行一个待迁移实例,可断点续跑
CREATE TABLE migration_job (
instance_id VARCHAR(64) PRIMARY KEY,
job_name VARCHAR(64) NOT NULL,
status VARCHAR(16) NOT NULL, -- PENDING / DONE / FAILED / SKIPPED
attempts INT NOT NULL DEFAULT 0,
last_error TEXT,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- 取一批待迁移的,跳过已完成的
SELECT instance_id FROM migration_job
WHERE job_name = 'order-v3-to-v4' AND status = 'PENDING'
ORDER BY instance_id LIMIT 100;
三个字段不能省:attempts 用于限制重试次数(避免一条坏数据反复拖慢整批)、last_error 用于事后分析失败模式、status = 'SKIPPED' 用于显式标记「确认不需要迁移」的实例(比如已进入终态的),避免它们每次都出现在待处理列表里。
6. 加法兼容与破坏性变更
在代码驱动型引擎里,所有变更被分成两类,边界非常清晰:
| 变更类型 | 是否安全 | 说明 |
|---|---|---|
| 追加新步骤到末尾 | 安全 | 老实例历史里没有这一步,重放时不会执行 |
| 在已有步骤后加条件分支 | 需版本守卫 | 老实例走老分支,新实例走新分支 |
| 修改已有步骤的参数 | 危险 | 老实例重放会读到新参数 |
| 删除步骤 | 破坏性 | 历史里有该步骤的记录,重放对不上 |
| 调换步骤顺序 | 破坏性 | 命令序列与历史不匹配 |
| 修改循环/并行的结构 | 破坏性 | 命令数量与历史不匹配 |
判断标准只有一条:「老实例重放到这个位置时,新代码产生的命令序列是否与历史完全一致」。一致就安全,不一致就是破坏性变更。这条规则解释了为什么「加法」总是安全的——老实例的历史里根本没有新命令,新代码在重放时也不会产生它(因为有版本守卫)。
破坏性变更不是不能做,而是必须走「先兼容、再切换、后清理」的三阶段。以「删除一个已废弃的校验步骤」为例:
阶段 1(兼容):保留步骤代码,但用版本守卫让它对新实例不执行
阶段 2(切换):等所有老实例结束(查询确认运行中实例为 0)
阶段 3(清理):删除步骤代码与版本守卫分支
三个阶段之间必须留出足够的观察期。阶段 2 到阶段 3 之间最容易出错——只要有一个长驻实例还在跑,删掉分支就会让它重放失败。
7. 长驻实例的版本分支
代码驱动型引擎提供的版本守卫 API 是解决一切兼容问题的核心工具。Temporal 的 getVersion 用法:
// changeId 必须全局唯一且永不复用;defaultVersion 用于新实例
int version = Workflow.getVersion("add-risk-check-2026-10",
Workflow.DEFAULT_VERSION, 1);
if (version >= 1) {
activities.riskCheck(orderId); // 新实例执行
}
activities.charge(orderId, amount); // 所有实例都执行
重放时引擎查历史里的版本标记:老实例历史里没有这个 change ID 的记录,返回 DEFAULT_VERSION,跳过 riskCheck;新实例历史里有记录,返回 1,执行它。这个机制的关键性质是分支判定被持久化了,而不是每次重放重新计算,所以它不受代码后续改动影响。
三个使用纪律。一是分支只增不删:删掉 if 分支就是破坏性变更。二是 change ID 与分支一一对应:同一处逻辑改两次要用两个 change ID。三是必须规划清理时机,用查询确认没有运行中实例后再删除分支:
# 查询还有多少运行中实例(这些实例还依赖版本分支)
temporal workflow list --query "ExecutionStatus='Running'" --limit 1000
Workflow.patched 是 getVersion 的简化版,只区分「有/无」两个状态,适合纯粹的加法变更。它的语义更清晰,但同样有「不能删除」的约束。
8. Camunda 的版本选择与迁移
Camunda 的默认行为与 Temporal 相反:启动新实例时用「该 key 的最新版本」,而运行中的实例永远停留在它启动时的版本上,除非显式迁移。
// 启动时指定版本(默认是最新版本)
runtimeService.createProcessInstanceByKey("order-flow")
.processDefinitionVersion(3) // 显式锁到 v3
.setVariable("orderId", orderId)
.execute();
// 迁移运行中实例到新版本
runtimeService.createProcessInstanceMigrationBuilder()
.migrateToProcessDefinition("order-flow", 4)
.addMigrationInstruction(
MigrationInstructions.builder()
.sourceActivityId("waitPayment")
.targetActivityId("waitPaymentV2") // 活动 ID 必须显式映射
.build())
.migrateProcessInstances(instanceIds);
迁移的核心难点是活动 ID 映射。流程里每个节点都有 ID(BPMN XML 里的 id 属性),迁移时引擎需要知道「老版本的 A 节点对应新版本的哪个节点」。如果新旧版本用的是同一批 ID(只改了连线或参数),映射是自动的;一旦改了节点 ID,就必须手工提供映射,且目标节点必须与源节点类型兼容(用户任务不能迁到服务任务)。
Camunda 8(Zeebe)的机制不同:它用「流程版本 + 实例绑定」的方式,迁移通过 MigrateProcessInstance 命令支持,且限制更严格(只支持部分节点类型)。选型时要特别注意这一点,Camunda 7 到 8 的迁移本身就是一个独立的大工程。BPMN 侧的建模规范与版本管理细节见 BPMN 2.0 与 Camunda 实战
。
9. 状态机的版本演进
状态机引擎的版本问题最少,因为它的「流程定义」就是状态转移表,变更通常只涉及新增状态或新增转移,天然满足加法兼容:
-- 新增一个状态与两条转移,老实例不受影响
INSERT INTO state_transition (from_state, event, to_state, guard, version)
VALUES ('PAID', 'SHIP_FAILED', 'REFUNDING', 'not_refunded', 4),
('REFUNDING', 'REFUND_OK', 'REFUNDED', NULL, 4);
真正需要小心的是状态的语义变更(同一个状态名的含义变了)与删除状态。前者会让老实例的当前状态在新代码里被解释成别的意思,后者会让老实例的当前状态在新代码里找不到对应转移而卡死。
治理方式是给状态加「引入版本」与「废弃版本」两个字段,代码在启动时校验「当前状态是否在当前版本的合法集合里」,发现非法状态就路由到人工处理而不是抛异常。这与 状态机引擎与状态流转 里讲的非法流转防御是同一套思路——把不可预期的状态变成可观测的异常,而不是静默失败。
10. 输入输出契约的兼容
即使流程结构没变,输入输出数据的结构变更也会破坏老实例。这是最容易被忽略的一类破坏性变更,因为它不体现在流程图上。
三条兼容规则,与 API 设计的规则一致:
只加字段,不删字段 删除字段会让老实例反序列化失败
不改字段类型 字符串改数字、单值改数组都会失败
不改字段语义 同名不同义是最危险的情况,必须改名或加新字段
在 Temporal 里,序列化发生在重放时,反序列化失败会导致实例直接卡住。防护手段有两个:一是给输入类加 @JsonIgnoreProperties(ignoreUnknown = true),让新增字段不会导致老数据解析失败;二是用 Protobuf 而不是 JSON,它的字段编号机制天然支持「加字段、删字段(保留编号)、改字段名」的向后兼容。
message OrderInput {
string order_id = 1;
string amount = 2;
reserved 3; // 曾用字段,禁止复用编号
string channel = 4; // 新增字段,老数据默认为空
reserved "legacy_coupon_code"; // 曾用字段名,防止误用
}
reserved 关键字是 Protobuf 兼容性的关键,它防止后人复用一个已删除的编号——复用编号会让老数据被错误解析成新字段。这类「跨实例、跨版本的长期承诺」在 Temporal 与持久化执行
的 DataConverter 一节里也有对应的处理方式。
除了结构兼容,还有一个容易被忽略的维度是序列化格式本身的可演进性。JSON 的优点是自描述、易调试,缺点是它无法表达「字段编号」的概念,因此删除字段后编号会被后来的字段复用,语义漂移无法检测。Protobuf、Avro、Thrift 这类带 schema 的格式通过编号与 reserved 解决了这个问题,代价是需要维护 schema registry 与代码生成。选择标准是「实例的生命周期有多长」:跨月运行的实例必须用带编号的格式,秒级结束的实例用 JSON 足够。
11. 灰度发布与流量切分
版本管理不只是「怎么改」,还包括「改了多少人受影响」。灰度发布把变更的影响面从「全量」缩小到「一小部分」,是降低风险的关键手段。
工作流的灰度与普通服务不同:不能按请求比例切流,而要按实例切流。原因是同一实例在生命周期里会被多次调度,如果第一次调度用新版本、第二次用旧版本,行为会不一致。所以切流必须在实例启动时决定,并把决策持久化:
// 实例启动时决定版本,写入实例变量,后续所有调度都读它
int flowVersion = decideVersion(orderId); // 按 orderId 哈希取模,保证同单同版本
WorkflowOptions.newBuilder()
.setWorkflowId("order-" + orderId)
.setMemo(Map.of("flowVersion", flowVersion)) // 可查询、可审计
.build();
按业务键哈希取模(而不是随机)的好处是同一个订单的所有实例落在同一个版本上,避免「订单 A 用 v4 建的实例、订单 B 用 v3 建的实例互相发信号」这种跨版本交互。
灰度的推进节奏建议是「1% → 5% → 25% → 100%」,每一档至少观察一个完整的业务周期(比如一个对账周期),而不是几小时。工作流的故障往往有延迟:一个错误的分支可能在实例走到第 5 步、甚至 3 天后才暴露。
12. 回滚:代码回滚与状态回滚
工作流的回滚比普通服务难,因为状态无法回滚。普通服务回滚只需部署旧版本镜像;工作流回滚时,新版本已经写入的状态(新步骤的执行结果、新字段的值)还在那里。
因此回滚策略必须区分两个方向:
| 回滚方向 | 可行性 | 做法 |
|---|---|---|
| 代码回滚 | 高 | 部署旧版本代码,前提是旧代码能理解新状态 |
| 定义回滚 | 高 | 把旧版本标记为默认,新实例用旧定义 |
| 状态回滚 | 低 | 只能通过补偿动作(反向操作)实现,不是真回滚 |
「旧代码能理解新状态」这个前提极其重要。如果新版本给实例写了新字段、新状态,回滚到旧代码后旧代码不认识它们,实例会卡住。所以回滚预案必须在发布前设计:要么新版本不引入旧代码无法处理的状态,要么准备一个「降级适配层」。
最实用的规则是**「发布用新代码 + 兼容旧状态,回滚用旧代码 + 兼容新状态」的双向兼容**。这要求新旧版本之间有一个共同的「兼容窗口」,窗口内的状态双方都能处理。实践中这个窗口通常通过「新字段带默认值」「新状态能被旧代码路由到人工处理」来实现。
13. 迁移的执行方式
当确实需要迁移运行中实例时,三种执行方式的成本与风险差异很大:
惰性迁移(lazy):不改历史,在实例下次被调度时由代码判断并转换
优点:无需批量操作,天然幂等
缺点:逻辑分散在业务代码里,长期积累
批量迁移(batch):扫描所有运行中实例,逐个调用迁移接口
优点:一次完成,状态统一
缺点:需要处理并发推进、部分失败、限流
自然消亡(drain):不迁移,等老实例自己跑完
优点:零风险
缺点:只适用于「老实例能自己结束」且变更不紧急的情况
惰性迁移是最被低估的方案。它的实现是在实例入口处加一段「状态升级」逻辑:读到的状态是旧结构就转换成新结构,转换后立即持久化,下次进来就是新结构了。
// 惰性迁移:入口处统一升级状态,转换是幂等的
private OrderState upgrade(OrderState state) {
if (state.schemaVersion() < 4) {
state = state.toBuilder()
.schemaVersion(4)
.channel(state.channel().isEmpty() ? "unknown" : state.channel())
.build();
}
return state;
}
惰性迁移要求「转换逻辑幂等且向前兼容」,一旦写下就不能删除(因为可能还有更老的实例在跑)。它的成本是代码里长期存在升级分支,收益是完全没有批量操作的运维风险。
14. 双写与并行验证
对风险极高的变更(比如换了计费逻辑),最稳妥的方式是双跑:新旧逻辑同时执行,但只采用其中一方作为结果,另一方仅记录差异。
def settle(order):
old_result = legacy_settle(order) # 旧逻辑
new_result = new_settle(order) # 新逻辑
if old_result != new_result:
metrics.increment("settle.mismatch")
log_diff(order.id, old_result, new_result) # 落库供对账
return old_result # 先用旧结果,观察期后再切换
双跑的成本是执行两次(资源翻倍)与副作用隔离:新逻辑绝不能产生真实副作用(不能真扣款、真发消息),只能做「计算」并把结果落库对比。因此双跑只适合「纯计算」类的变更,任何涉及外部副作用的变更都只能靠灰度切流。
双跑的价值在于它能在零业务风险的前提下暴露逻辑差异。观察期建议覆盖一个完整的业务周期(含月末、含异常场景),并设定明确的切换判据(比如「连续 7 天 mismatch 率为 0」)。
15. 破坏性变更的治理流程
把前面的规则固化成流程,避免每次发版靠人记忆:
1. 变更分类:提交时标注是「加法」「修改」还是「破坏性」
2. 评审门禁:破坏性变更必须附带兼容方案与清理计划
3. 版本守卫:破坏性变更必须使用 getVersion / patched
4. 影响面评估:查询当前有多少运行中实例会受影响
5. 灰度发布:按业务键哈希切流,观察期不少于一个业务周期
6. 清理确认:删除旧分支前,确认运行中实例数为 0
7. 审计留痕:记录每次变更的 change ID、影响实例数、清理时间
第 4 步「影响面评估」应该自动化。最实用的做法是提供一个查询脚本,输入 change ID 或版本号,输出「受影响的运行中实例数 + 最早的实例启动时间」,让评审者有量化依据判断「能不能等它自然消亡」。
第 6 步是最容易跳过的一步,也是事故最集中的一步。建议把它做成 CI 检查项:如果提交里删除了 getVersion 分支,CI 自动查询线上实例数,非 0 则阻止合并。这类「把运维规则变成代码门禁」的做法,与 DevOps 专题索引
里 CI/CD 质量门禁的思路一致。
16. 版本与规则配置的联动
流程版本之外,还有两类「软版本」需要一起管理:业务规则与流程配置。它们的特点是变更频繁、无需重新部署,因此更容易失控。
规则引擎的版本化通常独立于流程版本(决策表有自己的版本号),但两者的组合必须可追溯:一个实例在某个时刻用了「流程 v4 + 规则 v7」,事后复盘时要能还原这个组合。做法是把两者的版本号一起写进实例变量:
variables.put("_flowVersion", 4);
variables.put("_ruleVersion", rules.currentVersion("discount-policy"));
配置的变更(超时时间、重试次数、并发上限)建议尽量走配置中心而不是版本分支,因为配置变更不需要重放兼容。但要区分「影响重放确定性的配置」与「不影响重放确定性的配置」:前者(比如分支条件依赖的开关)必须作为实例变量持久化,后者(比如超时时间)可以从配置中心实时读。规则与流程的集成细节见 规则引擎与决策表 。
17. 测试与演练
版本兼容问题的特殊性在于它只在「老实例 + 新代码」的组合下暴露,常规的单元测试与集成测试都覆盖不到。因此需要专门的测试手段:
- 重放回归测试:把生产环境的真实事件历史导出,用新代码重放,断言不抛
NonDeterminismError。这是唯一能提前发现破坏性变更的自动化手段。 - 旧版本实例模拟:在测试环境启动一个老版本实例,暂停在中间步骤,再部署新版本并唤醒它,验证行为。
- 迁移脚本演练:在预发环境跑一次完整迁移,记录耗时、失败率、需要人工介入的实例比例。
- 回滚演练:部署新版本、让部分实例进入新状态,然后回滚代码,验证旧代码能否正常处理这些实例。
重放回归测试的价值最高,实现成本也最低:只要把历史 JSON 存下来,用引擎的 replay 工具跑一遍即可。建议把它接进 CI,任何修改了工作流代码的提交都必须通过。
// Temporal 的 Replayer:用真实历史离线重放,不产生任何副作用
@Test
void replayAllProductionHistories() throws Exception {
Replayer replayer = new Replayer(OrderWorkflowImpl.class,
new ReplayerOptions());
for (Path file : listHistories("src/test/resources/histories")) {
History history = History.parseFrom(Files.readAllBytes(file));
replayer.replayWorkflowExecution(history); // 不一致会抛异常
}
}
历史样本要覆盖「有信号等待」「有重试」「有并行分支」这几类结构,而不是随便抓几条。样本库建议每次发版时从生产环境补充最新的实例历史,让它随业务演进保持代表性。这个测试跑在 CI 里只需要几秒,却能挡住绝大多数破坏性变更。
18. 观测与审计
版本相关的指标有五个,缺一不可:
| 指标 | 含义 | 用途 |
|---|---|---|
| 各版本运行实例数 | 按版本分组的实例计数 | 判断能否清理旧版本 |
| 重放失败次数 | NonDeterminismError 计数 | 破坏性变更的直接信号 |
| 版本分布变化 | 新版本实例占比曲线 | 灰度推进是否正常 |
| 迁移成功率 | 批量迁移的成功比例 | 迁移脚本健康度 |
| 卡住实例数 | 长时间无状态变化的实例 | 迁移失败或状态不兼容 |
「重放失败次数」必须零容忍告警。这个指标一旦非零,说明有实例的历史与新代码不匹配,且它会持续失败(每次调度都失败),影响面会随时间扩大。
审计方面,每次版本发布都应该记录「发布时刻、影响版本、运行中实例数快照」,事后复盘时才能回答「这个 bug 影响了哪些实例」。这些记录建议落在一张专门的 flow_release_log 表里,与 工作流可观测与调试
里讲的实例级追溯形成互补:一个回答「改了什么」,一个回答「影响了谁」。
19. 落地路线图
- 第 1 周:盘点现有流程的版本对象,确认每个实例是否持久化了版本号。没有的话先补上,这是所有后续工作的基础。
- 第 2 周:制定变更分类规范(加法/修改/破坏性)与评审门禁,把「破坏性变更必须带兼容方案」写进 PR 模板。
- 第 3 周:实现重放回归测试,把生产历史导出并接进 CI。
- 第 4 周:设计灰度方案(按业务键哈希)与回滚预案,做一次灰度发布演练。
- 第 5 周:写影响面评估脚本与旧版本清理脚本(带「运行中实例为 0」的检查)。
顺序不能颠倒:没有版本号持久化,灰度与清理都无从下手;没有重放回归测试,破坏性变更只能靠人评审。
20. 权衡取舍
| 选择 | 收益 | 代价 |
|---|---|---|
| 定义驱动(Camunda) | 老实例行为稳定,可精确追溯 | 迁移需显式操作,版本堆积 |
| 代码驱动(Temporal) | 无需迁移,代码即真相 | 必须写版本分支,纪律要求高 |
| 数据驱动(状态机) | 变更简单,天然加法 | 状态语义变更难处理 |
| 惰性迁移 | 无批量风险,天然幂等 | 升级分支长期留存 |
| 批量迁移 | 一次完成,状态统一 | 并发冲突、部分失败、限流 |
| 自然消亡 | 零风险 | 受老实例生命周期限制 |
| 双跑验证 | 零业务风险暴露差异 | 资源翻倍,仅限纯计算 |
| 灰度切流 | 影响面可控 | 需要版本决策持久化 |
| 保留旧版本分支 | 兼容性最好 | 代码长期复杂,需清理计划 |
| 立即删除旧分支 | 代码干净 | 极可能导致老实例重放失败 |
21. 常见坑清单
- 修改已有步骤的参数或顺序,老实例重放时命令序列不匹配,抛
NonDeterminismError。 - 用语义名(如
add-risk-check)做 change ID,第二次修改同一处逻辑时产生冲突。 - 实例没有持久化版本号,出事后无法统计影响面,也无法做灰度切流。
- 按请求比例而不是按实例切流,同一实例在生命周期里被两个版本交替执行。
- 灰度用随机而不是按业务键哈希,同一订单的实例落在不同版本上互相发信号。
- 删除旧版本分支前没有确认运行中实例数,长驻实例第二天重放失败。
- 输入类删字段或改类型,老实例反序列化失败后卡住,且报错信息不指向字段变更。
- Protobuf 删字段后复用编号,老数据被错误解析成新字段。
- 回滚只考虑代码,没考虑新版本已写入的新状态,旧代码处理不了导致实例卡死。
- 批量迁移脚本不幂等,重跑时把已迁移的实例再迁一次,产生重复补偿。
- 双跑时新逻辑产生了真实副作用(真扣款),造成资损。
- 规则或配置的版本没有随实例记录,事后无法还原「当时用的是哪套规则」。
22. 小结
工作流版本管理的核心认知是「代码变更对运行中实例是隐式生效的」——在持久化执行引擎里它通过重放自动发生,在定义驱动引擎里它被定义版本隔离。理解你所用引擎的默认行为,才能知道风险落在哪里。
工程上的三条底线是:每个实例持久化版本号(否则无法评估影响面)、破坏性变更必须带版本守卫与清理计划(否则事故只是延迟发生)、重放回归测试接进 CI(这是唯一能自动发现破坏性变更的手段)。
如果变更范围已经大到「兼容分支写不下」,正确的选择往往不是硬迁移,而是开一个新的流程定义(Temporal 里用新的 WorkflowType,Camunda 里用新的 process key),让老实例自然消亡。这条路的代价是流程定义分裂,收益是彻底摆脱历史约束。引擎级别的版本机制差异参见 工作流引擎全景与选型 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。