几乎每个有一定历史的系统都会遇到同一个时刻:单体应用已经无法安全地继续演进——一次发布要停服、一个模块的改动会牵连全站回归、数据库表被几十个模块共用、新同事需要半年才能上手。此时团队通常面临两个选择:推倒重写,或者渐进替换。
推倒重写(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/ 理解事件如何在新旧系统间流动,可以把「数据同步」这层从「定时抽数」升级为「可靠的事件流」。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。