新旧功能的柔性切换:灰度路由、影子比较与读写迁移
新实现已经上线,旧实现仍在服务。只改一个开关,就能让少量请求试用新实现;同一份输入还可以交给另一侧执行,比较结果,却不改变用户看到的响应。试用扩大后,主次可以反过来:新实现负责响应,旧实现继续提供比较样本,并在符合条件时兜底。
这类能力发生在应用内部。需要切换的可能是一套规则、一份配置的读取方式、一个查询接口、一段任务流程,或者整个数据模型。蓝绿、金丝雀和滚动发布可以帮助放量,但发布单元的流量比例没有回答:谁有权返回结果,谁可以写数据,哪种失败允许回退,以及回退的数据是否还可信。
以目录查询为例,旧存储 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 | |
这是指标设计示例,统计单位必须统一为请求或实体。若一方失败、任务被拒绝或没有执行,不能算成两侧一致;若双方都返回空,也应区分“合同允许的空”与“几乎没有有效数据”。
异步影子还需要独立线程或执行配额、有界队列、采样、速率上限与停止开关。队列满后应记录观察缺口,不能通过 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 的在线切换与回滚 则集中讨论数据搬迁。应用内迁移可以同时使用这些抓手,但每次都应明确当前请求的响应来源、副作用提交者,以及旧实现何时真正失去接管资格。



