新实现已经上线,旧实现仍在服务。只改一个开关,就能让少量请求试用新实现;同一份输入还可以交给另一侧执行,比较结果,却不改变用户看到的响应。试用扩大后,主次可以反过来:新实现负责响应,旧实现继续提供比较样本,并在符合条件时兜底。

这类能力发生在应用内部。需要切换的可能是一套规则、一份配置的读取方式、一个查询接口、一段任务流程,或者整个数据模型。蓝绿、金丝雀和滚动发布可以帮助放量,但发布单元的流量比例没有回答:谁有权返回结果,谁可以写数据,哪种失败允许回退,以及回退的数据是否还可信。

以目录查询为例,旧存储 O 正在服务,新存储 N 逐步接入。迁移先让 N 观察真实查询,再让已完成数据准备的人群使用新读,之后才转移写入权威。停止回退和停止维护旧数据又是两次不同的决定。模板计算和任务处理也可以沿这条路径分析,只是比较合同和副作用边界各不相同。

从一次查询看清四种角色

设旧实现为 O,新实现为 N。请求被路由到 N,不代表最终一定由 N 返回:N 可能失败,系统按照策略使用 O 的结果。与此同时,O 也可能只是被调用来比较,完全没有接管响应的资格。

因此至少需要分别表达主执行、影子执行、回退和副作用提交四种角色。

角色 决定什么 不能据此推断什么
主执行 primary 正常情况下采用谁的结果 实际响应一定来自它
影子 shadow 谁额外执行以取得证据 可以自动拿它的结果救援
回退 fallback 哪种失败允许转向谁 另一侧的数据必然新鲜、合法
提交者 effect owner 谁获准落库、扣款或发通知 计算过候选就等于允许提交

目录查询可以由 N 返回、O 保持写入权威,再通过复制更新 N;读流量切新不等于写入权威已经转移。一个获权的业务提交路径也可以负责向两处存储写入,但不能据此让两套任务处理器分别扣款或发通知。

flowchart TD
    subgraph request["一次请求中的主执行、回退与影子观察"]
    R[请求与配置快照] --> P[选定主实现]
    P --> M[执行主实现并保留原始结果]
    M --> Q{结果可采用吗}
    Q -->|是| S[确定响应]
    Q -->|否| F{允许回退且满足条件吗}
    F -->|是| B[调用另一侧并按结果合同处理]
    F -->|否| E[按失败合同终止]
    M -.-> O[有界观察任务]
    B -.-> O
    O --> C[比较新旧原始结果]
    end

观察任务可以在主路结束前启动,也可以在结果确定后提交。图中的虚线表示独立的观察职责,不承诺远程调用已经被硬隔离或硬取消。

工程中三种容易复用的结构

按比例选主,反向获取另一套结果

模板迁移可以先按场景与比例选择一套模板,再用相同输入运行另一套模板。主模板来自 O,比较侧就是 N;主模板来自 N,比较侧就变成 O。

这里可以比较两个层次。配置比较回答“模板结构是否变化”,业务结果比较回答“在这份输入上,输出是否变化”。两份写法不同的表达式可能产生相同输出;配置看起来近似,也可能在空值、默认值或数值精度上产生差异。

一个值得复用的细节是把实际来源随计算上下文传下去。比较阶段据此选择另一侧,不再随机选一次,也不把“旧”写死为主路。这支持主次反转后继续观察。

结构可写为 primary = route(context); compare(evaluate(primary, x), evaluate(other(primary), x))。模板计算、规则引擎和无副作用的定价试算都可采用同一结构。前提是输入可复用、候选不会偷偷发消息或写缓存,比较函数也有明确的输出合同。

主读、比较和回退分开控制

目录查询的迁移更适合把三个问题拆开:是否以新读为主,是否比较两侧,以及新侧不可用时是否允许回旧。

旧主新影子阶段只返回旧结果。切新后仍保留旧影子,既能观察差异,也能在约定的失败条件下回旧。新侧缺少某个实体时,可以按实体补旧;新侧已有完整列表时则保留整份列表,不能未经约定把两边做并集。

例如,列表第一个元素决定默认展示项。[A, B] 与 [B, A] 即使集合相同,行为也不相同。把旧列表接在新列表后面、排序或去重,都可能改变这条合同。

更关键的是保留三份不同的东西:新侧原始结果、旧侧原始结果,以及最终响应。回退得到的完整响应适合服务用户,却不适合冒充新侧的原生结果参与一致性验收。否则新侧缺失的内容刚被旧侧补齐,就会在报表中消失。

两路计算候选,单路提交副作用

任务迁移不能默认把完整处理函数运行两遍。若它同时负责计算和落库,所谓影子流量可能变成重复订单、重复通知或重复扣费。

一种结构是两条路径都构造候选任务、记录可比较的意图,但只有命中当前所有权的一侧提交。旧路可以进入只记录的模式,新路接管持久化;也可以保持旧路提交,让新路只试算。

oldPlan = computeOld(x); newPlan = computeNew(x); commit(selectedOwner, selectedPlan) 是职责拆分的示意,不是可直接用于支付的执行代码。副作用提交还要处理幂等键、提交结果不明和重试。分布在两个消费者中的互补开关也不能提供跨进程原子性,尤其在延迟消息遇上配置切换时。

用已有模式组织这些结构

Feature Toggle:把决策与调用位置分开

Feature Toggle 允许在代码已经存在时改变应用行为。调用点只询问当前请求采用哪条路径,策略层再根据场景、租户、人群或操作状态给出答案。将判断集中起来,便于测试和删除临时开关。Feature Toggles

迁移开关与 A/B 实验经常共用分流机制,但验收目标不同。实验可以有意让两个群体看到不同体验;等价迁移通常先要求合同保持一致。若新需求本来就要改变行为,旧实现只能作为参考,不能被当成唯一正确答案。

比例也有两种含义。逐请求随机适合短小、无状态的试算;确定性分桶适合希望同一实体持续命中的情况。概念上可使用 hash(flag, stableKey, salt) < threshold,具体算法应采用所选平台的定义。稳定身份缺失、分组标识变化或键改变都会影响归属。Unleash Stickiness

稳定分桶仍不等于流程版本固定。全局比例缩小后,同一实体可能退出新路径。一个订单、审批或长任务若不能中途换语义,应在创建时持久化决策,后续步骤沿用,直到业务允许的升级边界。

Branch by Abstraction:在代码内建立替换接缝

Branch by Abstraction 先用抽象边界包住旧实现,再接入新实现,逐步替换调用方。接口可以很小,只覆盖当前确实要迁移的能力;完成后旧实现和过渡代码都可以退出。Branch By Abstraction

它适合把旧规则库、新规则服务或两套查询适配器放到可替换边界后面。抽象应统一输入输出的业务含义,而不是把两侧字段机械拼成一个越来越大的 DTO。无法兼容的行为必须在合同上显式处理。

Strangler Fig:按能力逐块替换

Strangler Fig 关注渐进替换的范围。识别业务接缝后,逐块把能力迁到新实现,让旧系统逐步缩小。接缝可以是入口路由,也可以是模块调用、事件处理或数据访问边界;不要求必须拆微服务。Strangler Fig

它与 Branch by Abstraction 可以组合:前者安排哪些能力先替换,后者让某个能力在代码内可替换。迁移过程中的适配器、转发器和比较逻辑属于过渡架构,是否值得保留要在迁移结束后重新判断。

Parallel Change:先兼容,再迁移,最后收缩

Parallel Change 的 expand、migrate、contract 三个阶段解决不兼容变更:先让系统同时接受新旧形式,迁移调用方或数据,最后删除旧形式。它适用于接口、事件格式和数据库 schema 的兼容窗口。Parallel Change

例如,先增加新字段和兼容读取,完成存量转换与调用方迁移后,再移除旧字段。这个顺序提供演进空间,但不自动提供跨存储事务,也不能保证已经写入的新格式仍可被旧程序理解。

这些模式的组合关系可以写成:能力分解 + 可替换接缝 + 兼容窗口 + 请求决策。搜索服务、规则引擎和目录数据迁移都能沿此组合展开;它们分别决定迁移范围、调用边界、共存条件和执行归属。

影子执行、流量镜像与差分验证

Shadow execution 保留主结果,让另一侧额外运行;Differential testing 比较多个实现对同一输入的表现。两者经常一起使用,但影子请求发出后若没有收集和对齐响应,就还没有完成差分验证。

GitHub Scientist 是直接的实现例子:control 提供返回值,candidate 用于比较。其文档也说明了两侧顺序执行、超时、可变输入和副作用的边界。因此接入实验库并不自动消除候选对请求延迟的影响。GitHub Scientist

观察链路还包括比较、清洗与上报。Scientist 的 publish、compare、clean 等回调发生异常时会进入 raised,默认实现会重新抛出异常。接入时需要为这些回调定义异常边界,保留观察失败记录,避免主计算已经成功却因上报故障使请求失败。Scientist callback errors

网络层 mirroring 则复制请求、丢弃镜像响应。它适合验证新服务能否处理真实请求,却不会自动比较业务输出。由此还可推导:丢弃响应没有撤销镜像端写入,副作用必须另行隔离。Istio Mirroring

对于高副作用或高成本逻辑,可以记录脱敏输入与必要上下文,在隔离环境重放。重放便于重复定位,却需要冻结时钟、随机源、依赖版本或外部查询结果,否则同一输入并不代表相同执行条件。它也不能直接替代在线时延和真实依赖容量验证。

比较前先定义等价关系:列表是否保留顺序与重复,缺失与 null 是否相同,数值允许多大误差,权限拒绝是否需要单独比较。忽略字段需要说明原因和范围,屏蔽整棵对象可能把有效性证据一起删掉。

相同请求输入还不代表读取了相同数据。旧目录读到实体 v41,更新发生后,新目录读到 v42,两套查询都正确也会产生差异。能够取得实体版本或源端序号时,应先判断两份结果是否满足约定的时态,再比较业务输出;相同版本也只有在投影和转换合同一致时才具有可比性。

观察情况 记录方式 验收处理
满足时态与输出合同,结果不同 语义差异 定位差异,不能归入一致样本
版本不同,或无法证明时态对齐 版本偏差、暂不可比 保留覆盖缺口,按规则复核
一侧失败、观察被拒绝或未执行 执行失败、观察缺口 单列失败与缺口,不能算一致

无法固定读取版本时,可以限定可比较样本,或约定最终一致性窗口并在有界时间内复核。首次差异和复核结果应同时保留,不能不断重试直到一致,再覆盖首次记录。比较管线接入新实现前,还可以先运行“旧对旧”的 A/A 比较,识别时态变化与依赖噪声;这也不是可以从新旧差异率中直接扣除的误差额度。Scientist noise and error rates

不同状态还要有不同的分母。建议把观察情况拆成以下三项,并单列双方空结果与回退率:

1
2
3
观察接纳率 = 已接纳的观察任务 / 计划观察任务
有效比较覆盖率 = 符合比较合同的样本 / 计划观察样本
差异率 = 不等价样本 / 符合比较合同的样本

这是指标设计示例,统计单位必须统一为请求或实体。若一方失败、任务被拒绝或没有执行,不能算成两侧一致;若双方都返回空,也应区分“合同允许的空”与“几乎没有有效数据”。

异步影子还需要独立线程或执行配额、有界队列、采样、速率上限与停止开关。队列满后应记录观察缺口,不能通过 CallerRuns 把慢工作压回主请求。调用前检查 deadline 只能停止尚未发出的调用;要限制在途操作,还依赖下游超时与真实取消能力。

主次反转时,主与影子的角色可以交换,但 diff 的业务方向应保持清楚。例如“新缺失、旧存在”不能因为新侧成了主路就改名成相反含义。日志同时记录计划主路、实际返回来源和两侧状态,能避免这种歧义。

读写迁移需要单独的状态机

六阶段模型把权威反转写了出来

LaunchDarkly 的迁移模型明确区分读取来源、写入目标与权威来源,六阶段安排如下。它是一个现成的参照,不要求每次迁移都经过全部阶段。Migrations

阶段 读 写 权威来源
off 旧 旧 旧
dualwrite 旧 旧、新 旧
shadow 两侧 旧、新 旧
live 两侧 新、旧 新
rampdown 新 新、旧 新
complete 新 新 新

shadow → live 最值得注意:两侧仍然执行,变化的是谁承担权威责任。rampdown → complete 则停止继续维护旧侧;从此旧数据是否还能接管,必须重新证明。

表里的“双写”不保证两个系统原子提交。以 LaunchDarkly Go SDK v7 的内置 migrator 为例,权威写先执行,失败时跳过非权威写;权威写成功、次写失败后的业务确认、修复与重试,仍由应用处理。自行编排读写时,也应显式定义这些合同。MigrationWriteResult

flowchart LR
    subgraph stages["读写迁移中切读与停止维护旧侧是不同动作"]
    A[旧读旧写] --> B[旧读双写]
    B --> C[双读双写 旧权威]
    C --> D[双读双写 新权威]
    D --> E[新读 双写保留旧侧]
    E --> F[新读新写]
    C -.-> G[按数据和容量条件决定回退]
    D -.-> G
    E -.-> G
    F --> H[回旧前重新确认数据完整性]
    end

同一时刻,不同租户可以处于不同阶段,但路由单位必须服从数据迁移边界。若某租户尚未回填完整,不能因为随机命中一次新读就假定数据可用。LaunchDarkly 的迁移 targeting 也把 cohort 与数据分区联系起来,并提示人群调整带来的安全问题。Migration targeting

双写、回填与增量同步各自解决一部分问题

先双写只能覆盖启用后的新变更,历史数据仍需回填。Stripe 的在线迁移实践将增量写、存量迁移、验证和读取切换分开处理,并逐步增加迁移负载。Online migrations at scale

回填与实时写并行时还可能发生旧快照覆盖新数据。目标端应依据同一实体内可比较的版本,原子地判断是否接受更新;先查版本再无条件写入,仍然存在并发覆盖窗口。删除状态也要保留相应版本,避免迟到的回填恢复旧记录。同版本却内容不同,需要明确冲突处理,不能静默当成重复。

普通更新时间只有在生成方式、单调性、精度及同值冲突规则得到保证时,才能充当版本。例如 PostgreSQL 的 now() 和 CURRENT_TIMESTAMP 取事务开始时间,不能直接代表提交顺序。源端序号也需要明确所属日志流、分区和世代,不能假定任意两个序号都可比较。PostgreSQL current date/time

Change Data Capture,简称 CDC,可以从源存储的变更日志传播增量。它减少业务代码中的同步双写,但仍需处理复制滞后、恢复后的重复、删除以及目标端应用顺序。以 Debezium PostgreSQL connector 为例,其文档描述快照、WAL 捕获和故障恢复行为;CDC 不能被理解为两个系统之间的原子事务。Debezium PostgreSQL connector

Transactional Outbox 将业务更新与待发事件记录放进同一个本地事务,再由 relay 发布。它修补业务提交与发消息之间的缺口,不能使新存储立即可读,也不自动给接收方提供 exactly-once。Transactional Outbox

Outbox 和 CDC 还可以组合:CDC 负责读取待发事件,事件 ID 用于去重,聚合键用于组织同一实体的事件。Debezium 提供了具体的 Outbox Event Router,但冲突处理与业务补偿仍需应用定义。Outbox Event Router

回滚依赖旧侧持续具备接管条件

切新后若还需要快速回旧,就必须继续维护旧侧可理解的状态。方案可能是保留双写、建立受控反向同步,或保留可重放的变更记录。反向同步不能简单把两条复制链接成环,还要处理来源标记、冲突和重复传播。

双写也有成功定义的问题。旧权威阶段,旧写成功、新写失败,可以按合同确认并异步修复新侧;转为新权威后,新写成功、旧写失败,则会降低回滚准备度,即使新侧成功率完全正常。要求两侧都成功,同样需要面对“一侧已提交、另一侧失败”的部分成功。调用超时不能直接等同于未提交。对非幂等写操作,切换另一侧重试之前必须查询或恢复原操作状态。

对要求无损的单权威存储切换,仅检查一次“已经追平”还不够:检查完成后,原权威可能又确认了一笔写入。需要先阻止原权威继续接收新的业务写入,处理在途操作,确定最终提交边界 W;接管侧应用到 W、没有跳过的修复缺口后,才转移提交权并开放新写入。暂停写入是一种实现,也可以使用提供相应保证的所有权协议。AWS RDS 的蓝绿切换就包含停写、断开连接、等待复制追平后再开放写入的过程。RDS switchover actions

这里的“应用到 W”指接管侧的业务状态已经提交并满足所需可读条件。connector 已读取日志、消息已投递,都不能代替目标端应用完成。分区复制还需要检查对应分区的边界。若故障期间无法证明接管侧完整,就应按业务约定等待、限写,或接受明确的数据损失范围,不能承诺无损回滚。保留可重放日志,也不等于已经具备即时接管能力。

可迁移的组合是 存量回填 + 增量传播 + 等价验证 + 权威切换 + 回滚窗口。目录存储替换、搜索索引重建和数据模型升级都需要这些问题;各场景采用的复制工具与等价规则可以不同。

fallback 怎样实现弹性,怎样掩盖问题

双向 fallback 指先选择当前主实现,再允许它在规定条件下使用另一侧。它应是一层编排策略:primary → optional alternate → terminal outcome,最多尝试规定次数。把两套适配器写成互相调用,会产生循环,也难以计算总延迟。

回退调用和两侧各自的重试应共享请求总时限与尝试预算。主路已经耗尽时限时,继续启动旧读只会增加后台负载;备用侧满足业务条件,也还需要有剩余时间与容量才能接管。

双向能力不代表两侧始终对称。旧实现可能不理解新字段,新实现可能尚未完成回填。授权拒绝、明确删除和合同允许的空结果,也不能统一归为“失败,换一边查”。尤其是权限治理,另一侧恰好有数据不能构成继续返回的理由。

还需要确认备用容量。假设新路径承担了大部分流量,新服务整体异常时,这些请求同时回旧;旧服务若只保留少量容量,回退就会扩大故障。AWS Builders’ Library 对 fallback 的复杂性、测试困难与故障时负载风险有专门分析。Avoiding fallback in distributed systems

Circuit Breaker 可以在依赖故障时减少继续调用,并通过探测恢复。它回答是否还应访问该依赖;另一侧能否合法接管,仍由数据和业务合同决定。Circuit Breaker

因此应分别统计“新侧原生成功”“旧侧救援成功”和最终用户成功。最终成功率接近正常而回退率持续上升,说明新实现仍在依赖旧侧。需要验证新实现独立运行时,可对具备条件的小人群暂时关闭回退,并设置停止条件。

配置与长流程也有一致性边界

多个 Boolean 可能生成无人设计过的组合。更稳妥的做法是先定义合法状态,再由状态生成执行计划;不适用的读、写或比较能力就不加入计划。配置发布时完整解析、完整校验,再替换有效快照,避免读到半份更新。

单次请求捕获快照后,分页、分批和重试应沿用相同决策。观察任务可以另读一个最新停止信号,以便取消未发出的候选调用。停止观察的能力与修改业务主路的能力分开,便于解释同一请求为何采取某个方向。

本地原子替换不等于全系统同时切换。多实例接收配置的时间不同,跨服务和跨消息消费也可能出现版本交错。涉及副作用归属时,要把版本或所有权放入业务记录或消息上下文,不能假定同名开关天然同步。

携带所有权还需要提交端校验。旧消费者读取所有权后暂停,新消费者接管并提交,旧消费者恢复后不能继续凭先前快照写入。对共享存储,可以在原子提交时校验有效所有权代次,或在交接时阻止新写、排空旧写后再开放。所有权代次解决过期执行者问题,业务幂等键解决同一操作的重复;两者不能互相替代。外部扣款或通知仍需相应服务提供幂等合同。Fencing tokens

长流程尤其如此。Temporal 的 Worker Versioning 区分固定在启动版本的执行与可升级执行,并把重放兼容作为约束。这个例子说明版本决策需要有生命周期;即使不用工作流引擎,也可以把流程版本持久化,并在新流程实例或明确检查点重新选择。Worker Versioning

从观察走到退场

迁移验收需要同时观察正确性、覆盖率和依赖。一个足够具体的阶段门槛可以包含目标人群与样本量、不可比率上限、未解释差异、回退率、尾延迟和次写修复积压。数值由业务事先约定,本文不提供通用的“99.9% 就能切”阈值。

目录查询还应按普通查询、删除、权限拒绝、空结果等关键场景分层验收。大量普通查询一致,不能抵消少量权限请求全部出错。各场景需要自己的有效样本量和观察窗口;窗口还应覆盖高峰或任务生命周期等可能暴露问题的条件。

放量时应比较同期新旧人群的指标,并同时检查绝对服务目标 SLO。共享依赖可能让新旧两组一起恶化,仅看组间差值会漏报;共享缓存也可能人为提高命中率,使性能比较失真。观察流量的影响需要计入依赖负载。Google SRE: Canarying Releases

等价验证通过后,仍需检查新旧是否共享同一个错误源;有意改变语义时,还要用新业务合同验收。比较工具擅长发现不同,不负责替系统决定哪一边正确。

退场也分成不同动作:停止影子、停止回退、停止维护旧数据、排空旧任务、删除旧代码与临时开关。只要任一用途还在产生旧调用,就不能由“主流量已经全新”推导出旧依赖可以删除。停止更新旧数据后,恢复旧读需要重新确认可接管条件。

把目录查询从接入到退场连起来,可以得到以下推进过程。表中假定新存储已通过双写或增量传播接收更新;候选查询不提交业务副作用。每个阶段的分流单位都服从数据准备边界。

阶段 响应与影子 写入权威与回退 推进证据
旧主新影子 O 响应,N 参与比较 O 权威,N 接收复制;不因影子失败改变响应 比较合同、观察覆盖与时态噪声已明确
有限新主 已准备人群由 N 响应,O 参与比较 仍可由 O 保持写入权威;仅按合同回旧 该人群回填与增量应用满足要求,新读原生成功和尾延迟达标
新权威保留回旧 N 响应,O 继续观察 按交接协议转为 N 权威,并维护 O;符合条件时回旧 提交边界可证明,旧侧修复缺口受控,具备接管容量
停止回退 N 独立服务,影子可按验收需要保留 N 权威;回滚窗口内仍可维护 O 分层样本与观察窗口通过,无回退条件下满足 SLO
停止维护旧侧 N 服务,停止旧影子 N 独立写;不能直接恢复旧读 回滚窗口结束,旧任务与旧调用已排空,旧依赖可退出

阶段之外,还要预先约定单次请求遇到异常时的行为,避免值班时临时决定哪一种“失败”可以回退。

事件 响应或提交行为 保留的证据
新读超时且符合回旧条件 在剩余总时限与容量预算内尝试旧读 新读原始失败、实际返回来源与总耗时
权限拒绝或明确删除 遵循安全与删除合同,不凭另一侧有数据覆盖 拒绝或删除状态及判定依据
权威写成功、次写失败 按业务确认合同处理,持久化修复责任 两侧结果、操作标识与修复状态
提交结果不明 查询或恢复同一业务操作状态 操作标识、未知状态与后续处理
观察队列满或比较回调抛错 按隔离策略处理观察失败,不冒充一致 观察缺口与失败原因
遇到的问题 适合采用的模式组合 必须补上的边界
算法或规则替换 Branch by Abstraction、Feature Toggle、差分验证 同输入条件、输出等价与候选副作用
按业务能力逐块替换 Strangler Fig、兼容适配、场景路由 实际接缝与旧调用排空
接口或 schema 演进 Parallel Change、契约验证 旧使用方与旧数据格式的退出
查询服务替换 主影子反转、受控 fallback 原始结果、空值合同、时间偏差、备用容量
状态存储或任务流程迁移 分阶段读写、Outbox / CDC、版本固定 回填、幂等、顺序、权威归属与回滚窗口

已有的 Strangler 与 Branch by Abstraction 文章可用于进一步理解代码接缝;数据库 Resharding 的在线切换与回滚 则集中讨论数据搬迁。应用内迁移可以同时使用这些抓手,但每次都应明确当前请求的响应来源、副作用提交者,以及旧实现何时真正失去接管资格。