绞杀者模式
2015 年,澳大利亚一家零售公司决定重构他们运行了 10 年的电商平台。这是一个典型的巨石应用:ASP.NET 单体,连接 SQL Server 数据库,代码量超过 100 万行。
技术团队估算,完全重写需要 3 年时间。但业务等不了这么久——每年电商平台都有大促,改版计划排得很紧。
他们没有选择大爆炸重构,而是在原有系统旁边构建新服务,逐步把流量从旧系统切换到新系统。整个过程用了 18 个月,最终完成了从巨石到微服务的迁移,而业务几乎没有中断。
这套方法叫做「绞杀者模式」(Strangler Pattern)。
绞杀者模式的核心理念
绞杀者模式的名字来源于一种澳大利亚植物——绞杀榕。它在宿主树的枝干上扎根,随着时间推移,根系越来越发达,最终宿主树死亡,只剩下绞杀榕独立生长。
绞杀者模式示意图:
旧系统(宿主) 新系统(绞杀者)
┌──────────────┐ ┌──────────────┐
│ │ │ │
│ ┌────────┐ │ │ 新功能 A │
│ │老功能 A │ │ └──────────────┘
│ └────────┘ │ ┌──────────────┐
│ ┌────────┐ │ │ 新功能 B │
│ │老功能 B │ │ └──────────────┘
│ └────────┘ │ ┌──────────────┐
│ ┌────────┐ │ │ 新功能 C │
│ │老功能 C │ │ └──────────────┘
│ └────────┘ │ │
└──────┬───────┘ │
│ │
▼ ▼
┌──────────────────────────────────────┐
│ API 网关(流量入口) │
│ 路由规则决定流量走旧系统还是新系统 │
└──────────────────────────────────────┘
核心思想
绞杀者模式的核心是「渐进式替代」:
- 不破坏现有系统:旧系统在迁移过程中继续运行
- 逐功能切换:每次只迁移一小部分功能
- 可回退:任何时候出问题,都可以切回旧系统
- 持续验证:新功能验证通过后,才废弃旧功能
这与大爆炸重构形成鲜明对比:大爆炸是「先拆再建」,绞杀者是「边建边拆」。
为什么大爆炸重构往往失败
大爆炸重构(Big Bang Rewrite)听起来很美好——把旧系统全部推倒重来,一步到位用新架构。现实是,90% 的大爆炸重构都以失败告终。
大爆炸重构的典型失败模式
失败案例时间线:
第 1 个月:热情高涨,新架构设计完成
第 3 个月:新系统开发中,旧系统继续维护(两倍工作量)
第 6 个月:新系统完成 30%,团队疲惫
第 9 个月:新系统完成 50%,业务需求已经堆积如山
第 12 个月:新系统完成 70%,但旧系统已经积攒了大量 bug
第 18 个月:新系统上线,但大量 bug,业务崩溃
第 24 个月:回滚到旧系统,损失惨重
大爆炸重构的三大死穴
死穴一:时间窗口不可控
软件复杂度是指数级的。一个 50 万行代码的系统,重写往往需要原计划时间的 3~5 倍。因为:
- 重写过程中,业务需求不会停止
- 旧系统的 bug 修复需要同步到新系统
- 新系统发现的设计问题需要返工
死穴二:新旧系统不一致
重写完成后,切换的一瞬间会暴露大量差异:
- 用户习惯的操作方式改变了
- 数据格式不一致(空值、边界值、编码)
- 集成系统的接口响应格式变了
- 性能表现不同(往往新系统更慢)
死穴三:团队士气崩溃
两倍的工作量、遥遥无期的完成时间、业务方的压力——团队在重写中后期往往士气低落,错误率上升,质量下降。
大爆炸重构 vs 绞杀者模式对比:
| 维度 | 大爆炸重构 | 绞杀者模式 |
| --- | --- | --- |
| 风险 | 高(一次失败全盘皆输) | 低(每次只冒小风险) |
| 业务中断 | 有(切换窗口) | 无(灰度切换) |
| 团队压力 | 高(长期高压) | 低(分阶段完成) |
| 价值交付 | 晚(完成后才有价值) | 早(每完成一个功能就交付) |
| 回退成本 | 高(可能回滚整个系统) | 低(切回单个功能) |
结论:大爆炸是「高风险低回报」,绞杀者是「低风险持续回报」。
绞杀者模式的三步实施
绞杀者模式的实施分为三个阶段:路由层配置、数据双写、全量切换。
第一步:路由层配置
在 API 网关层配置路由规则,实现流量的可控制切换。这是绞杀者模式的基础设施。
路由层架构:
┌──────────────────────────────────────────────────────────┐
│ API 网关 │
│ │
│ 请求 ──► 路由规则 ──► 目标服务 │
│ │ │
│ ┌───────────┼───────────┐ │
│ ▼ ▼ ▼ │
│ /api/users /api/orders /api/products │
│ │ │ │ │
│ ▼ ▼ ▼ │
│ 旧系统 A 新系统 B 新系统 C │
│ │
└──────────────────────────────────────────────────────────┘
路由策略:
- 按路径路由:/api/v1/* 走旧系统,/api/v2/* 走新系统
- 按用户路由:白名单用户走新系统
- 按流量比例:5% 走新系统,95% 走旧系统
路由配置示例(以 Kong 为例):
# Kong 路由配置
services:
# 旧系统服务
- name: legacy-service
url: http://legacy-app:8080
routes:
- name: legacy-route
paths:
- /api/v1
# 新系统服务
- name: new-service
url: http://new-app:8080
routes:
- name: new-route
paths:
- /api/v2
plugins:
# 流量分割插件(新系统 10%,旧系统 90%)
- name: traffic-split
config:
plugins:
- name: rate-limiting
config:
minute: 100
第二步:数据双写阶段
新功能上线后,数据写入需要同时操作新旧两个系统。这是迁移中最关键的阶段。
数据双写架构:
写入操作流程:
1. 请求进入 API 网关
2. 网关将请求同时发送给旧系统和新系统
3. 旧系统和新系统分别写入各自的数据库
4. 新系统需要幂等处理(同一请求可能重复发送)
┌─────────┐ ┌──────────────┐ ┌──────────────┐
│ 请求 │ ───► │ API 网关 │ ───► │ 旧系统 DB │
└─────────┘ └──────┬───────┘ └──────────────┘
│
│ 同时写入
▼
┌──────────────┐
│ 新系统 DB │
└──────────────┘
数据双写的挑战:
挑战一:写入性能下降
问题:同时写两个数据库,延迟增加
解决:
- 新系统优先写入,旧系统异步写入
- 使用消息队列解耦
挑战二:数据不一致
问题:两个系统写入顺序可能不同
解决:
- 使用分布式事务或 Saga 模式
- 新系统以旧系统数据为准,定期对账
挑战三:重复写入
问题:网关重试可能发送多次请求
解决:
- 新系统实现幂等写入(用唯一请求 ID)
数据双写示例代码:
// 错误示例:简单双写
@Service
public class OrderService {
@Autowired private LegacyOrderRepository legacyRepository;
@Autowired private NewOrderRepository newRepository;
public void createOrder(Order order) {
// 简单双写,任何一个失败都可能导致不一致
legacyRepository.save(order);
newRepository.save(order);
}
}
// 正确示例:带补偿的双写
@Service
public class OrderService {
@Autowired private LegacyOrderRepository legacyRepository;
@Autowired private NewOrderRepository newRepository;
@Autowired private OutboxService outboxService;
public void createOrder(Order order) {
// 1. 先写新系统(主数据源)
newRepository.save(order);
// 2. 写消息表(发件箱模式)
OutboxRecord record = new OutboxRecord();
record.setPayload(serialize(order));
record.setTarget("legacy-order");
record.setStatus(OutboxStatus.PENDING);
outboxService.save(record);
// 3. 异步任务读取消息表,发送给旧系统
// 失败会重试,保证最终一致性
}
}
第三步:全量切换
当新系统验证稳定后,逐步将流量从旧系统切换到新系统,最终废弃旧系统。
流量切换策略:
阶段一:影子流量(0%~5%)
- 新功能只在内部测试环境运行
- 不影响任何用户
阶段二:白名单用户(5%~10%)
- 邀请内部员工或忠实用户试用
- 收集反馈,快速迭代
阶段三:灰度放量(10%~50%)
- 按用户 ID 哈希分流
- 观察新系统指标(错误率、延迟)
阶段四:金丝雀发布(50%~100%)
- 保留旧系统作为热备
- 新系统接管大部分流量
- 出现异常立即回退
阶段五:完全切换
- 旧系统下线
- 监控一段时间后清理旧代码
全量切换后的验证清单:
[ ] 新系统错误率 `<` 旧系统错误率
[ ] 新系统 P99 延迟 `<` 旧系统 P99 延迟
[ ] 业务核心指标(转化率、GMV)无下降
[ ] 监控告警全部通过
[ ] 运维人员熟练掌握新系统操作
[ ] 旧系统数据全部迁移完成
[ ] 回退方案文档化并演练过
关键技术组件
绞杀者模式的实施需要几个关键技术组件支撑。
API 网关
API 网关是绞杀者模式的核心基础设施,负责流量的路由、分割和监控。
API 网关核心能力:
1. 路由规则:按路径、Header、参数等条件路由
2. 流量分割:按比例分配流量到新旧系统
3. 流量镜像:复制请求到测试环境,验证新系统
4. 熔断降级:新系统故障时自动切回旧系统
5. 监控告警:实时监控流量指标,异常时告警
推荐选型:
- Kong:插件丰富,社区活跃
- APISIX:性能高,支持 Lua/Wasm 扩展
- Spring Cloud Gateway:Java 生态集成好
流量镜像
流量镜像(Shadow Traffic)是一种安全的验证方式:将生产流量复制一份到新系统,观察新系统的表现,但不返回结果给用户。
# 流量镜像配置示例(Kong)
plugins:
- name: proxy-cache
config:
response_code: 200
request_method: GET
cache_ttl: 30
strategy: memory
# 流量镜像:复制 100% 请求到新系统
- name: kafka-log
config:
bootstrap_servers: "kafka:9092"
topic: "shadow-traffic"
timeout: 10000
服务契约测试
新旧系统并存时,需要保证接口契约一致。契约测试可以自动化验证这一点。
// Pact 契约测试示例
@Pact(consumer = "api-gateway", provider = "user-service")
public Void createUserPact(PactDslWithProvider builder) {
return builder
.uponReceiving("a request to create a user")
.path("/api/users")
.method("POST")
.body(newJsonBody(body -> {
body.stringValue("name", "John");
body.stringValue("email", "john@example.com");
}).build())
.willRespondWith()
.status(201)
.body(newJsonBody(body -> {
body.stringValue("id", "12345");
body.stringValue("name", "John");
body.stringValue("email", "john@example.com");
}).build())
.toPact();
}
真实案例:某电商平台的 18 个月迁移
以下案例来自 2019 年某中型电商平台的迁移实践(已脱敏):
背景
- 旧系统:PHP 单体,运行 8 年,代码量 30 万行
- 新目标:Java 微服务架构
- 团队规模:15 人,其中 8 人维护旧系统,7 人开发新系统
- 业务压力:每年双十一是硬性 deadline,不能停服
迁移策略
第一阶段(0~6 个月):基础设施 + 新功能
行动:
1. 搭建 Kubernetes 集群和 CI/CD 流水线
2. 开发新版本的「用户中心」和「商品中心」
3. 配置 API 网关,/api/v2/* 路由到新系统
结果:
- 基础设施就绪
- 新用户模块上线,承接新用户注册
- 旧系统用户模块保持不变
第二阶段(6~12 个月):核心业务迁移
行动:
1. 开发新版本「订单中心」和「支付中心」
2. 实施数据双写:新系统写入后同步旧系统
3. 白名单用户开始使用新订单流程
结果:
- 订单模块双写稳定运行
- 业务指标无异常
- 开始接收真实用户反馈
第三阶段(12~18 个月):全量切换
行动:
1. 按用户 ID 哈希分流,逐步扩大新系统比例
2. 双十一当天,新系统承接 80% 订单流量
3. 双十一后,旧订单模块下线
结果:
- 18 个月完成迁移
- 零停机,零数据丢失
- 双十一平稳度过(峰值 5 万 QPS)
关键成功因素
绞杀者模式的适用场景
绞杀者模式不是万能解药,它有明确的适用场景。
适合使用绞杀者模式的场景
适用场景:
✅ 技术栈陈旧,迁移风险高
→ 不破坏现有系统,渐进式验证
✅ 业务不能停,无法接受长时间回滚
→ 灰度切换,出问题立即回退
✅ 团队缺乏大型重构经验
→ 每次只迁移一小部分,风险可控
✅ 业务需求持续迭代,不能停止开发
→ 新功能在新系统开发,旧功能逐步迁移
不适合使用绞杀者模式的场景
不适用场景:
❌ 系统体量很小(`<` 1 万行代码)
→ 重写比重构快,直接大爆炸更划算
❌ 业务逻辑极度复杂,无法渐进迁移
→ 可能出现「永远无法完成迁移」的情况
❌ 团队强烈抵触维护两套系统
→ 两套系统并行需要额外的维护意愿
❌ 资金和人手极度紧张
→ 绞杀者模式的总工作量通常比大爆炸高 30%~50%
总结
绞杀者模式的核心是「渐进式替代」,它解决了大爆炸重构的主要风险。
绞杀者模式 vs 大爆炸重构:
大爆炸重构:
- 风险:一次失败全盘皆输
- 收益:完成后一次性解决
- 适合:小团队、低复杂度、充裕时间
绞杀者模式:
- 风险:分摊到每个阶段,每次只冒小风险
- 收益:持续交付价值,业务不中断
- 适合:大团队、高风险、高业务连续性要求
绞杀者模式的三步:
1. 路由层配置:流量可控切换
2. 数据双写:新旧系统数据同步
3. 全量切换:逐步扩大比例,最终废弃旧系统
成功的关键:
- 基础设施就绪
- 监控全面覆盖
- 回退方案随时可用
- 持续交付价值,保持团队信心
思考题
问题 1:在数据双写阶段,如果新系统和旧系统的数据出现不一致,应该如何处理?
参考答案
数据双写阶段的不一致是必然的,关键是快速发现和修复:
- 建立对账机制:每日或每小时对比新旧系统的关键数据,发现不一致立即告警
- 以新系统为准:新系统是主数据源,旧系统数据作为备份
- 修复策略:
- 增量数据:发现不一致后,从新系统同步到旧系统
- 全量数据:定期做全量对账,确保一致
- 灰度切换期间:保留旧系统的写入能力,作为新系统的回退目标
- 幂等处理:对写操作做幂等,避免重复写入导致的不一致
核心原则:接受短期不一致,保证最终一致性,有完善的监控和对账机制。
问题 2:绞杀者模式实施过程中,团队要维护两套系统,工作量增加近一倍。如何控制团队士气和效率?
参考答案
维护两套系统确实增加负担,但可以通过以下方式控制成本:
- 固定迁移比例:规定每周/每月迁移多少功能,不要无限制拖延
- 持续交付节奏:每 2 周交付一个可用功能,给团队成就感
- 技术债务分离:旧系统的 bug 修复只保证不阻塞迁移,不做大的功能开发
- 人员分配策略:主力开发新系统,少量人员维护旧系统(1~2 人足够)
- 自动化测试:用契约测试、集成测试减少手工验证成本
- 明确里程碑:设定清晰的完成时间,给团队希望
最关键的是:让团队看到进展。每次成功迁移一个功能,都是一次正反馈。
问题 3:如果迁移过程中发现最初的服务边界划分错误,应该怎么办?
参考答案
边界划分错误是常见的,发现后不要硬撑:
-
评估错误程度:
- 轻度(两个服务频繁调用,但还能工作):引入 API 聚合层,暂时接受
- 中度(两个服务有循环依赖风险):考虑合并或重构边界
- 重度(服务边界完全错误):制定合并计划
-
处理策略:
- API 聚合:在网关层把两个服务聚合为一个接口,掩盖后端边界问题
- 服务合并:把两个服务合并为一个,同时撤销之前的工作
- 重新拆分:承认之前的错误,重新设计边界
-
预防措施:
- 迁移前通过 DDD 建模明确边界
- 先迁移低风险模块,验证边界假设
- 保留旧系统接入能力,随时可以回退
边界错误不可怕,可怕的是发现错误后继续错误地投入资源。