工作流版本管理与迁移

本文系统讲解工作流的版本管理与运行中实例迁移,回答新版本上线后老实例怎么办、哪些变更算破坏性、灰度与回滚怎么做。覆盖定义/代码/数据三类版本对象、不可变部署与加法兼容、Temporal getVersion 与 Camunda 版本机制、实例状态迁移与双跑验证、灰度切流、回滚预案、破坏性变更治理与审计观测,并给出可落地的代码与配置片段。

引言

普通服务的发版是「替换」:新版本起来、旧版本下线,进程状态随进程一起消失。工作流的发版是「叠加」:新版本部署上去时,线上还有几千个用旧版本定义的实例正在跑,有的已经跑了一半,有的要再等 30 天才结束。这些实例既不能被杀掉,也不能被新代码「顺手」改变行为。

这就是工作流版本管理的全部难点所在。它不是一个「打 tag」的问题,而是三个独立的问题叠在一起:定义版本(流程图的第几个版本)、代码版本(执行引擎读的是哪份实现)、数据版本(实例里持久化的状态是什么结构)。三者可以独立演进,但任意两者的错配都会导致运行中的实例行为异常。

最反直觉的一点是:在持久化执行引擎里,老实例重放时用的是新代码。这意味着「改代码」这件事对运行中的实例是隐式的、自动生效的,如果你没做兼容处理,它就会在某个凌晨以 NonDeterminismError 的形式炸出来。而在 BPMN 引擎里恰好相反:老实例继续用旧版本定义,新代码对它们完全不生效,于是「迁移」变成了一个必须显式发起的动作。

本文按「版本对象 → 兼容规则 → 引擎机制 → 灰度与回滚 → 治理流程」的顺序展开,重点讲清楚每种引擎的默认行为以及如何利用它。引擎的整体差异参见 工作流引擎全景与选型 ,Temporal 的重放模型细节参见 Temporal 与持久化执行 。

目录

  1. 版本问题的本质
  2. 三类版本对象:定义、代码、数据
  3. 版本号与标识的命名规范
  4. 不可变部署:新版本新定义
  5. 运行中实例的三条处置路线
  6. 加法兼容与破坏性变更
  7. 长驻实例的版本分支
  8. Camunda 的版本选择与迁移
  9. 状态机的版本演进
  10. 输入输出契约的兼容
  11. 灰度发布与流量切分
  12. 回滚:代码回滚与状态回滚
  13. 迁移的执行方式
  14. 双写与并行验证
  15. 破坏性变更的治理流程
  16. 版本与规则配置的联动
  17. 测试与演练
  18. 观测与审计
  19. 落地路线图
  20. 权衡取舍
  21. 常见坑清单
  22. 小结

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. 常见坑清单

  1. 修改已有步骤的参数或顺序,老实例重放时命令序列不匹配,抛 NonDeterminismError。
  2. 用语义名(如 add-risk-check)做 change ID,第二次修改同一处逻辑时产生冲突。
  3. 实例没有持久化版本号,出事后无法统计影响面,也无法做灰度切流。
  4. 按请求比例而不是按实例切流,同一实例在生命周期里被两个版本交替执行。
  5. 灰度用随机而不是按业务键哈希,同一订单的实例落在不同版本上互相发信号。
  6. 删除旧版本分支前没有确认运行中实例数,长驻实例第二天重放失败。
  7. 输入类删字段或改类型,老实例反序列化失败后卡住,且报错信息不指向字段变更。
  8. Protobuf 删字段后复用编号,老数据被错误解析成新字段。
  9. 回滚只考虑代码,没考虑新版本已写入的新状态,旧代码处理不了导致实例卡死。
  10. 批量迁移脚本不幂等,重跑时把已迁移的实例再迁一次,产生重复补偿。
  11. 双跑时新逻辑产生了真实副作用(真扣款),造成资损。
  12. 规则或配置的版本没有随实例记录,事后无法还原「当时用的是哪套规则」。

22. 小结

工作流版本管理的核心认知是「代码变更对运行中实例是隐式生效的」——在持久化执行引擎里它通过重放自动发生,在定义驱动引擎里它被定义版本隔离。理解你所用引擎的默认行为,才能知道风险落在哪里。

工程上的三条底线是:每个实例持久化版本号(否则无法评估影响面)、破坏性变更必须带版本守卫与清理计划(否则事故只是延迟发生)、重放回归测试接进 CI(这是唯一能自动发现破坏性变更的手段)。

如果变更范围已经大到「兼容分支写不下」,正确的选择往往不是硬迁移,而是开一个新的流程定义(Temporal 里用新的 WorkflowType,Camunda 里用新的 process key),让老实例自然消亡。这条路的代价是流程定义分裂,收益是彻底摆脱历史约束。引擎级别的版本机制差异参见 工作流引擎全景与选型 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「工作流引擎」更多文章

  1. 工作流成本优化
  2. 执行器与资源隔离
  3. 调度、回填与补数