绞杀者模式

2015 年,澳大利亚一家零售公司决定重构他们运行了 10 年的电商平台。这是一个典型的巨石应用:ASP.NET 单体,连接 SQL Server 数据库,代码量超过 100 万行。

技术团队估算,完全重写需要 3 年时间。但业务等不了这么久——每年电商平台都有大促,改版计划排得很紧。

他们没有选择大爆炸重构,而是在原有系统旁边构建新服务,逐步把流量从旧系统切换到新系统。整个过程用了 18 个月,最终完成了从巨石到微服务的迁移,而业务几乎没有中断。

这套方法叫做「绞杀者模式」(Strangler Pattern)。

绞杀者模式的核心理念

绞杀者模式的名字来源于一种澳大利亚植物——绞杀榕。它在宿主树的枝干上扎根,随着时间推移,根系越来越发达,最终宿主树死亡,只剩下绞杀榕独立生长。

绞杀者模式示意图:

     旧系统(宿主)              新系统(绞杀者)
    ┌──────────────┐          ┌──────────────┐
    │              │          │              │
    │  ┌────────┐  │          │  新功能 A    │
    │  │老功能 A │  │          └──────────────┘
    │  └────────┘  │          ┌──────────────┐
    │  ┌────────┐  │          │  新功能 B    │
    │  │老功能 B │  │          └──────────────┘
    │  └────────┘  │          ┌──────────────┐
    │  ┌────────┐  │          │  新功能 C    │
    │  │老功能 C │  │          └──────────────┘
    │  └────────┘  │               │
    └──────┬───────┘                │
           │                        │
           ▼                        ▼
    ┌──────────────────────────────────────┐
    │           API 网关(流量入口)         │
    │   路由规则决定流量走旧系统还是新系统     │
    └──────────────────────────────────────┘

核心思想

绞杀者模式的核心是「渐进式替代」:

  1. 不破坏现有系统:旧系统在迁移过程中继续运行
  2. 逐功能切换:每次只迁移一小部分功能
  3. 可回退:任何时候出问题,都可以切回旧系统
  4. 持续验证:新功能验证通过后,才废弃旧功能

这与大爆炸重构形成鲜明对比:大爆炸是「先拆再建」,绞杀者是「边建边拆」。

为什么大爆炸重构往往失败

大爆炸重构(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)

关键成功因素

因素具体做法
持续价值交付每 2 周发布一个可用的功能模块
数据一致性保障消息队列 + 定时对账,保证数据最终一致
监控全面覆盖新旧系统指标对比,出现异常立即告警
回退方案随时可用任何时候可以切回旧系统,且演练过多次

绞杀者模式的适用场景

绞杀者模式不是万能解药,它有明确的适用场景。

适合使用绞杀者模式的场景

适用场景:

✅ 技术栈陈旧,迁移风险高
   → 不破坏现有系统,渐进式验证

✅ 业务不能停,无法接受长时间回滚
   → 灰度切换,出问题立即回退

✅ 团队缺乏大型重构经验
   → 每次只迁移一小部分,风险可控

✅ 业务需求持续迭代,不能停止开发
   → 新功能在新系统开发,旧功能逐步迁移

不适合使用绞杀者模式的场景

不适用场景:

❌ 系统体量很小(`<` 1 万行代码)
   → 重写比重构快,直接大爆炸更划算

❌ 业务逻辑极度复杂,无法渐进迁移
   → 可能出现「永远无法完成迁移」的情况

❌ 团队强烈抵触维护两套系统
   → 两套系统并行需要额外的维护意愿

❌ 资金和人手极度紧张
   → 绞杀者模式的总工作量通常比大爆炸高 30%~50%

总结

绞杀者模式的核心是「渐进式替代」,它解决了大爆炸重构的主要风险。

绞杀者模式 vs 大爆炸重构:

大爆炸重构:
- 风险:一次失败全盘皆输
- 收益:完成后一次性解决
- 适合:小团队、低复杂度、充裕时间

绞杀者模式:
- 风险:分摊到每个阶段,每次只冒小风险
- 收益:持续交付价值,业务不中断
- 适合:大团队、高风险、高业务连续性要求

绞杀者模式的三步:
1. 路由层配置:流量可控切换
2. 数据双写:新旧系统数据同步
3. 全量切换:逐步扩大比例,最终废弃旧系统

成功的关键:
- 基础设施就绪
- 监控全面覆盖
- 回退方案随时可用
- 持续交付价值,保持团队信心

思考题

问题 1:在数据双写阶段,如果新系统和旧系统的数据出现不一致,应该如何处理?

参考答案

数据双写阶段的不一致是必然的,关键是快速发现和修复:

  1. 建立对账机制:每日或每小时对比新旧系统的关键数据,发现不一致立即告警
  2. 以新系统为准:新系统是主数据源,旧系统数据作为备份
  3. 修复策略
    • 增量数据:发现不一致后,从新系统同步到旧系统
    • 全量数据:定期做全量对账,确保一致
  4. 灰度切换期间:保留旧系统的写入能力,作为新系统的回退目标
  5. 幂等处理:对写操作做幂等,避免重复写入导致的不一致

核心原则:接受短期不一致,保证最终一致性,有完善的监控和对账机制。

问题 2:绞杀者模式实施过程中,团队要维护两套系统,工作量增加近一倍。如何控制团队士气和效率?

参考答案

维护两套系统确实增加负担,但可以通过以下方式控制成本:

  1. 固定迁移比例:规定每周/每月迁移多少功能,不要无限制拖延
  2. 持续交付节奏:每 2 周交付一个可用功能,给团队成就感
  3. 技术债务分离:旧系统的 bug 修复只保证不阻塞迁移,不做大的功能开发
  4. 人员分配策略:主力开发新系统,少量人员维护旧系统(1~2 人足够)
  5. 自动化测试:用契约测试、集成测试减少手工验证成本
  6. 明确里程碑:设定清晰的完成时间,给团队希望

最关键的是:让团队看到进展。每次成功迁移一个功能,都是一次正反馈。

问题 3:如果迁移过程中发现最初的服务边界划分错误,应该怎么办?

参考答案

边界划分错误是常见的,发现后不要硬撑:

  1. 评估错误程度

    • 轻度(两个服务频繁调用,但还能工作):引入 API 聚合层,暂时接受
    • 中度(两个服务有循环依赖风险):考虑合并或重构边界
    • 重度(服务边界完全错误):制定合并计划
  2. 处理策略

    • API 聚合:在网关层把两个服务聚合为一个接口,掩盖后端边界问题
    • 服务合并:把两个服务合并为一个,同时撤销之前的工作
    • 重新拆分:承认之前的错误,重新设计边界
  3. 预防措施

    • 迁移前通过 DDD 建模明确边界
    • 先迁移低风险模块,验证边界假设
    • 保留旧系统接入能力,随时可以回退

边界错误不可怕,可怕的是发现错误后继续错误地投入资源。