单体到分布式:遗留系统迁移与绞杀者模式

遗留系统迁移与绞杀者模式:从大爆炸重写的失败原因讲起,剖析绞杀者(Strangler Fig)的渐进替换机制、反向代理流量切分与灰度判据、CDC 数据同步与影子流量校验,给出数据库拆分五阶段、API 契约兼容、幂等重放与团队组织配套的落地方法及常见踩坑清单

几乎每个有一定历史的系统都会遇到同一个时刻:单体应用已经无法安全地继续演进——一次发布要停服、一个模块的改动会牵连全站回归、数据库表被几十个模块共用、新同事需要半年才能上手。此时团队通常面临两个选择:推倒重写,或者渐进替换。

推倒重写(Big Bang Rewrite)的失败率高得惊人,原因不在技术,而在业务不会停下来等你:重写期间所有新需求都要么被冻结、要么在旧系统上继续打补丁,结果是新旧两套系统同时演进,最终新系统一上线就落后。绞杀者模式(Strangler Fig Pattern)给出了另一条路:让新系统像榕树一样,从外围一点点包住旧系统,直到旧系统自然枯萎。

一句话:不要重写系统,要在旧系统旁边长出新的,再把流量一格格搬过去。

1. 遗留系统的判定

1.1 什么算「遗留」

「遗留(Legacy)」不是指技术栈老,而是指改动成本超过了它继续存在的价值:

信号表现根因
发布即事故每次上线都要全量回归模块间隐式耦合
无人敢改核心逻辑没有测试覆盖知识随人员流失
无法横向扩展加机器收益递减有状态逻辑与数据库瓶颈
交付周期长一个字段改动两周上线构建、部署、审批链路冗长
技术债锁定依赖已停止维护的库升级路径被截断

用 Java 还是用 Go 不是判定标准。一个用现代框架写的、但模块耦合到无人敢动的系统,同样是遗留系统。

1.2 迁移的四种触发

触发典型场景迁移策略偏好
容量瓶颈单库无法承载写入先拆数据库,再拆服务
交付效率发布周期拖慢业务按业务域切服务
技术栈风险语言/框架停止维护绞杀者逐个替换模块
合规与安全无法满足审计要求优先隔离高风险模块

不同触发对应不同的第一优先级。最忌讳的是「因为想用新技术所以迁移」——这种动机支撑不了迁移过程中的全部投入。

2. 绞杀者模式

2.1 核心机制

绞杀者模式的三个组成部分:

1. 代理层(Facade / Proxy)
   所有请求先到代理,由代理决定转发给旧系统还是新系统
   这一层是迁移的总开关

2. 新系统(New Implementation)
   每次只实现一个能力(一个 API、一个业务域)
   与旧系统共享或同步数据

3. 绞杀过程(Strangulation)
   逐个能力把流量从旧系统切到新系统
   当某能力流量 100% 迁移后,删除旧系统中的对应实现

关键在于代理层是唯一的切换点。没有代理层,就只能改客户端或改旧系统代码来切流,前者成本高(客户端升级不可控),后者会污染即将被删除的代码。

2.2 为什么不重写

维度大爆炸重写绞杀者模式
交付节奏一次性,风险集中持续小步,风险分散
业务需求冻结或双线开发新需求直接在新系统实现
回滚几乎不可能切流开关,秒级回滚
价值验证上线才知道对不对每切一个能力就验证一次
失败模式项目取消,投入归零停在中间,仍保有已迁移部分
团队学习需要一次理解全部逐个模块理解

绞杀者最大的隐性收益是**「可停」**:迁移到 60% 时如果战略调整,已经迁移的 60% 仍然在产生价值,而不是像重写那样全部沉没。

2.3 实施步骤

第 0 步:梳理能力清单
  把旧系统的对外能力列成清单(API、定时任务、消息消费者、报表)
  每个能力标注:调用量、数据依赖、改动频率

第 1 步:搭代理层
  反向代理(Nginx / Envoy)或 API 网关,按路径/头/参数路由
  此时 100% 流量仍指向旧系统

第 2 步:选第一个能力
  选标准:调用量中等、依赖清晰、价值明确
  不要选"最简单"的(学不到东西),也不要选"最核心"的(风险过大)

第 3 步:实现 + 影子验证
  新实现上线但不接流量,通过影子流量比对结果

第 4 步:灰度切流
  1% -> 5% -> 20% -> 50% -> 100%,每档观察错误率与延迟

第 5 步:清理
  删除旧实现、删除兼容代码、删除影子流量通道

第 5 步最容易被跳过。不清理的绞杀者会退化成「两套系统长期并存」,维护成本翻倍,这是绞杀者模式失败最常见的原因。

2.4 组织与协作配套

绞杀者是技术方案,但成败往往取决于组织安排。三个必须明确的约定:

约定内容缺失后果
需求归属新需求一律在新系统实现,旧系统只修 bug旧系统持续膨胀,迁移永远追不上
双人知识每个迁移的能力至少两人熟悉新旧实现关键人离职即停摆
冻结窗口迁移某能力期间,旧实现禁止重构边改边迁,回归范围失控

第一条最关键。如果新需求还在旧系统上开发,那么「迁移完成」这个终点会被无限推迟——因为你一边搬走一间房,一边又加盖两间。

2.5 迁移进度可视化

迁移是长周期工程,必须有客观进度度量,否则容易「感觉快完成了」但实际卡在中段:

建议跟踪的四个指标
  1. 能力迁移率 = 已迁移能力数 / 总能力数
  2. 流量迁移率 = 走新系统的 QPS / 总 QPS(比能力数更真实)
  3. 旧系统代码删除量 = 已删除的旧模块代码行数(清理进度的唯一证据)
  4. 双跑成本 = 迁移期额外的基础设施与运维开销

健康信号:流量迁移率与代码删除量同步上升
危险信号:流量迁移率上升但代码删除量长期为 0(只切流不清理)

3. 流量切分

3.1 代理层路由

# 按路径切流:/api/orders 已迁移到新系统,其余仍走旧系统
upstream legacy_backend { server 10.0.1.10:8080; }
upstream new_backend    { server 10.0.2.10:8080; }

server {
    listen 80;

    # 灰度:按请求头切 5% 流量到新系统
    location /api/orders {
        if ($http_x_gray = "new") {
            proxy_pass http://new_backend;
        }
        proxy_pass http://legacy_backend;
    }

    location / {
        proxy_pass http://legacy_backend;
    }
}

路由维度可以组合:路径、请求头、用户 ID 哈希、租户 ID。按用户维度切分比按请求维度切分更安全,因为同一用户的数据不会在两个系统间来回跳。

3.2 影子流量

影子流量(Shadow Traffic / Dark Launch)是新系统上线前最重要的验证手段:

影子流量流程
  1. 代理层把真实请求复制一份(异步、不阻塞主请求)
  2. 副本发送到新系统,标记为 shadow,禁止产生副作用
  3. 新系统的写操作重定向到影子库或直接丢弃
  4. 比对两者的响应(字段级 diff)与耗时
  5. 差异率超过阈值则阻断切流

关键约束
  - 影子请求不得触发真实支付、短信、外部回调
  - 比对要区分"字段顺序不同"与"值不同"
  - 时间敏感字段(时间戳、随机 ID)需归一化后再比

影子流量的价值在于用真实流量覆盖测试用例想不到的分支。生产流量的参数组合复杂度远超任何测试集。

3.3 数据同步:CDC

迁移期新旧系统需要共享数据,最稳妥的方式是变更数据捕获(CDC,Change Data Capture):

方案机制优点缺点
双写应用同时写两个库实现简单一致性难保证,任一侧失败即分叉
触发器数据库触发器写变更表与业务解耦影响数据库性能
日志解析(CDC)解析 binlog/WAL无侵入、性能好需要额外组件与运维
定时同步按时间戳批量拉取实现最简单延迟高,删除难捕获

CDC 是目前的主流选择:它从数据库日志中解析出变更,投递到消息队列,由下游消费。它天然是「先写库再发消息」的可靠模式,不会像应用层双写那样出现「库写成功但消息没发」的裂缝。事件如何在新旧系统间流动,可延伸阅读事件驱动架构 。

4. 数据迁移

4.1 共享数据库是反模式

迁移期最常见也最危险的做法是「新服务直接读旧库」:

反模式:新服务 -> 旧库表(直接读写)
  后果 1:新服务被旧库表结构绑死,无法独立演进
  后果 2:旧系统改表 -> 新服务无声崩溃
  后果 3:两套系统争抢数据库连接与锁
  后果 4:"迁移完成"永远无法定义,因为数据还在旧库

正确做法是新服务拥有自己的数据库,旧库的数据通过 CDC 单向同步过来,迁移期允许短时不一致但必须有对账。

4.2 数据库拆分的五个阶段

阶段 1:只读影子库
  新库存在,CDC 持续同步,新服务只读新库
  校验:数据一致性对账,差异率必须为 0

阶段 2:写新库 + 双读校验
  新服务写新库,同时异步比对旧库状态
  校验:写入结果一致

阶段 3:切换读
  读流量切到新库,旧库降级为只读备份
  校验:业务指标无异常

阶段 4:停写旧库
  旧系统不再写旧库,旧库进入只读观察期
  校验:观察期内无回滚需求

阶段 5:下线旧库
  备份后下线,释放资源

每个阶段之间必须有明确的对账工具与回滚开关。对账工具的核心是「按主键比对 + 按时间窗比对」两条路径,前者抓值差异,后者抓漏同步。

4.3 迁移期的一致性

迁移期两套系统并存,跨系统的操作会打破原有的事务边界:

原单体事务迁移后处理方式
本地数据库事务跨两个库Saga / TCC 补偿
同库两表更新分属两服务领域事件 + 最终一致
唯一性约束分属两库全局发号器或中心化校验

跨系统的写必须设计成幂等,否则补偿重试会产生重复数据。幂等键的设计与重放防护,见 https://plumephp.com/distributed-idempotency-reliability/。

5. 契约与兼容

5.1 API 版本演进

代理层能切流,前提是新旧系统对外契约一致。契约演进的三条路径:

策略机制适用
向后兼容只增字段,不删不改首选,成本最低
版本并行/v1 与 /v2 并存语义有破坏性变更
适配层代理层做请求/响应转换无法改客户端时

优先选向后兼容:新增字段不破坏老客户端,老字段保留但标记废弃,等调用量归零再删除。破坏性变更要留足迁移窗口(通常按「客户端最长升级周期 × 2」估算)。

5.2 消费者驱动契约

判断「能不能删掉旧接口」不能靠猜,要靠数据:

消费者驱动契约(Consumer-Driven Contract)
  1. 所有消费者声明自己用到的字段与调用频率
  2. 生产者按契约跑测试,破坏契约即构建失败
  3. 删除字段前查询实际调用量,确认为 0 再删
  4. 调用量监控保留至少一个完整的业务周期(如一个季度)

缺少第 3、4 步的团队,删除字段时几乎必然踩到「某个没人知道的定时任务还在用这个字段」。

5.3 网关层的统一入口

迁移完成后,代理层通常保留下来演进为 API 网关,承担认证、限流、路由、灰度等横切职责。这是绞杀者模式的正向副产品:迁移过程顺带把横切关注点从业务代码里剥离出来了。网关的分层设计与路由策略,见 https://plumephp.com/api-gateway-design/。

5.4 迁移完成后的收敛

全部能力迁移完不等于结束,还有一轮「收尾」工作:

收尾项动作判定标准
旧系统下线停止进程、回收机器、归档代码监控中无任何调用来源
数据归档旧库冷数据迁到归档存储归档可查、可恢复演练通过
临时通道清理影子流量、双写、兼容分支代码中无 shadow/legacy 标记
文档更新架构图、部署手册、值班手册新人按文档能独立排障
复盘沉淀记录踩坑与决策依据形成可复用的迁移清单

数据归档最容易被忽略:旧库一停,历史数据就失去了查询入口,而合规审计往往要求保留数年。归档方案要在迁移开始前就确定,而不是下线前一天才想。

6. 常见坑

坑后果修法
没有代理层就切流只能改客户端,切流不可控先搭代理层再动手
新旧系统共享数据库迁移永无止境,耦合依旧新服务独立库 + CDC 同步
跳过清理阶段两套系统长期并存,成本翻倍每个能力迁移完立即删旧代码
双写代替 CDC一致性裂缝,数据分叉用日志解析型 CDC
影子流量有副作用重复支付、重复发短信影子模式强制禁用外部调用
无对账工具数据差异靠业务发现上线前先写好对账脚本
一次迁移太大风险集中,回滚困难单个能力粒度,随时可停

总结

主题关键内容
遗留判定改动成本超过存在价值,与语言新旧无关
绞杀者代理层为切换点,逐个能力替换,随时可停
流量切分按路径/用户灰度,影子流量做上线前验证
数据同步CDC 优于双写,单向同步 + 对账兜底
数据库拆分五阶段推进,每阶段有对账与回滚开关
契约兼容向后兼容优先,消费者驱动契约决定何时删字段

遗留系统迁移的本质是把一次高风险的大手术,拆成一串可回滚的小手术。技术难点其实不多——代理路由、CDC、影子流量都有成熟组件;真正的难点在于纪律:坚持单个能力粒度、坚持每个阶段都有对账、坚持迁移完就清理。反过来,迁移失败的项目几乎都败在这三条纪律上,而不是败在技术上。配合 https://plumephp.com/distributed-event-driven-architecture/ 理解事件如何在新旧系统间流动,可以把「数据同步」这层从「定时抽数」升级为「可靠的事件流」。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「distributed-systems」更多文章

  1. 边缘计算架构与就近接入
  2. 分布式系统成本优化与容量治理
  3. Quorum 复制协议:读写多数派与 Paxos 变体