跳到正文

绞杀者无花果模式:用图逐步迁移单体应用

用绞杀者无花果模式替换单体应用:路由门面、四个阶段、先拆什么、不做双写的数据迁移,以及如何证明一致。

· 创始人兼开发者

发布于

客户调用作为门面的 API 网关。/catalog/*(100%)流向有独立数据库的目录服务,GET /orders/* 的 25% 流向订单服务,其余流向遗留单体,单体的数据库通过虚线 CDC 路径向订单数据库供数。

你的单体应用已有十年历史,每次发布都要经历漫长的回归周期,这时有人提议重写:并行建一套新系统,达到功能对等,然后在某个周末切换。与此同时,旧系统仍在变化,切换日期不断后延。Martin Fowler 写道,他和同事见过这种计划「大多数时候以惨败收场」。

绞杀者无花果模式则一块一块地替换系统。单体前面的门面把每个请求路由到旧代码或新服务,能力逐个迁移,直到没有任何请求再路由到单体。路由是容易的部分。数据归属、证明一致和收尾才是难的部分。

本文把这些都讲到,并附上一张进行中迁移的图。

名字的由来

2001 年,Fowler 在昆士兰的雨林里见到了绞杀榕:这种藤蔓在宿主树上发芽,在土壤里扎根,能比宿主树活得更久,最后只留下保持树形的无花果。2004 年 6 月,他用它比喻逐步替换遗留系统。2019 年 4 月,他把文章名从「Strangler Application」改为「Strangler Fig Application」,以指回这种植物,并在 2024 年 8 月重写了全文。当前版本强调了团队抗拒的东西:过渡架构,也就是你只为让新旧共存而写的代码。

由门面负责路由

门面是单体前面的反向代理或 API 网关:NGINX、Envoy、云厂商的 API 网关,或带路径规则的负载均衡器。一开始它把所有流量送往单体。此后,每个迁移步骤都是一次路由变更。可以按以下维度路由:

  • 路径: /catalog/* 流向目录服务。
  • 方法: 先迁 GET /orders/*,再迁 POST /orders,让读取先走。
  • 比例: 对会话 ID 或用户 ID 做哈希,让每个用户始终留在同一侧。
  • 请求头或租户: 内部用户或试点客户先走。
# upstream blocks catalog, orders and monolith defined elsewhere
split_clients "${cookie_session}" $orders_read {
    25%      orders;       # canary share for reads
    *        monolith;
}
map $request_method $orders_target {
    GET      $orders_read;
    default  monolith;     # order writes stay in the monolith
}
server {
    location /catalog/ { proxy_pass http://catalog; }
    location /orders/  { proxy_pass http://$orders_target; }
    location /         { proxy_pass http://monolith; }
}

回滚只需改一处:把 25% 那一行指向 monolith(nginx 不接受 0% 的条目)。对每个请求都带有的键做哈希:没有 Cookie 的请求会全部落进同一个桶。

让门面保持精简:路由、TLS、认证和日志,不放业务规则。它位于每个请求的路径上,所以要运行多个实例,并测量它增加的延迟。网关里该放什么,API 网关架构图里有说明。

迁移的四个阶段

阶段 门面的路由 数据 完成标志
0. 先上门面 全部流向单体 不变 门面已上线,已测得新增延迟
1. 第一项能力 一条路由,如 /catalog/*,流向新服务 服务先读单体的表或 CDC 副本,之后用自己的数据 路由达到 100%,并经历一个完整业务周期
2. 大部分流量在新服务上 大多数路由;单体保留一份已知清单 每个服务拥有自己的表 剩下的内容一页就能写完
3. 单体退役 没有流量流向单体 遗留数据库先归档,再删除 单体已关闭

Microsoft 的 Strangler Fig 模式页面还加了最后一步:移除门面,或把它留作遗留客户端的适配器。

选择先拆什么

好的首批候选有三个特点:

  • 清晰的接缝: 用 Michael Feathers 的话说,就是不用在那个位置修改代码就能改变行为的地方,比如 URL 前缀、队列或模块边界。
  • 变更频率高: 这样迁移很快就有回报。版本控制能看出来。
  • 耦合低: 与其他表的 join 少,回调单体的次数也少。
git log --since="12 months ago" --format= --name-only -- src/ \
  | sort | uniq -c | sort -rn | head -20

一家网店的排序示例:

候选 接缝 变更频率 耦合 结论
目录浏览 /catalog/* 高 低 第一
订单历史 GET /orders/* 中 中 第二,先迁读取
结账 调用价格、库存和支付 高 高 在其依赖之后
夜间报表 跨整个 schema 的 SQL 低 很高 最后,或改为数据仓库的数据流

按业务能力拆分,而不是按技术层拆分。共享的「数据访问服务」什么也移不动,还会把一切耦合在一起。

迁移数据

路由移动的是请求,不是数据,而迁移往往卡在数据上。要在每一步决定每张表归谁。

策略 工作方式 适用场景 风险
共享数据库 新服务使用单体的表 最初几周 单体的 schema 变更会让它出问题
Change data capture 连接器从单体的日志中流式输出行变更 先迁移读取 延迟、遗留 schema、单向流动
每个服务一个数据库 服务拥有自己的表,其他服务调用它的 API 最终状态 跨服务的 join 变成 API 调用
代码里的双写 应用同时写两个数据库 不要用 两次写入之间崩溃,会让两边失去同步

Microsoft 的指南给出了常见顺序:新服务先使用单体的表,然后拥有自己的数据库,一次性加载后由 CDC 保持最新,验证之后成为系统记录。在遗留表被删除之前,回滚只是一次路由变更;之后就得靠恢复备份。

避免双写:两次独立写入之间崩溃,会让两个数据库不一致,并发更新也可能以不同顺序落地。通过 CDC 或事务性发件箱,让一个事务成为每次变更的来源。change data capture 图讲解了 Debezium 和复制槽。

防腐层

单体的模型会外泄:单字母状态码、同一张表里的账单和发货字段、写在存储过程里的规则。消费这些行的服务会继承你正要离开的模型。防腐层是 Eric Evans 在《领域驱动设计》(2003)中提出的模式,它在边界处做转换:

  • 遗留到新系统: 携带遗留行的 CDC 事件变成新模型里的订单。
  • 新系统到遗留: 订单服务通过适配器从单体读取客户数据。
  • 单体到新系统: 目录迁走后,单体代码改为调用目录 API,而不是它原来的表。

不要把业务规则放进这一层。在六边形服务中,它位于适配器里,所以核心永远看不到遗留类型。

在迁移路由之前证明一致

每条路由都要经过同样的步骤:

  1. 影子。 门面把请求复制给新服务并丢弃它的响应,NGINX 的镜像模块和 Envoy 的请求镜像就是这样做的。只镜像读取:被镜像的 POST /orders 会创建第二个订单。
  2. 比较。 把时间戳、ID 和排序归一化之后,对比两边的响应。Twitter 在 2015 年开源的 Diffy 会运行两份旧代码,并把它们之间的差异当作噪声忽略。在单体内部,GitHub 的 Scientist 库(Ruby 编写,有其他语言的移植版)会并行运行新旧路径,并返回旧路径的结果。
  3. 金丝雀。 依次迁移 1%、5%、25%、50%,最后 100% 的真实流量,把同一路由的错误率和 p99 延迟与单体对比,并让每一步都经历一次每日高峰。部署策略图讲解了具体机制。
  4. 保持。 在 100% 时,让旧路径保持可部署,并让它的数据在月末结账之类的事件期间保持最新。
  5. 删除旧代码、它的表和那条路由。

只有当单体的数据仍是最新时,把路由切回去才有效。一旦新服务拥有了写入,就要通过反向 CDC 或事件把写入回传,否则回滚会丢掉它们。

绞杀者无花果迁移如何失败

  • 永远做不完。 容易的 80% 迁走了,报表、批处理作业和管理页面留了下来,你要同时运维两套系统。每个迭代都统计剩余的部分,并定下关停日期。
  • 分布式单体。 服务共享表、一起部署,或通过长长的同步调用链互相调用。检验标准是每张表只有一个写入方。
  • 门面成为瓶颈: 网关里有业务逻辑,或者一个实例承担了全部流量。
  • 隐藏的消费者。 报表和其他团队直接读取单体的数据库,门面看不到。迁移某张表之前,先检查连接日志。
  • 把功能对等当作目标。 Fowler 指出,许多遗留行为「其实并不被需要」。Patterns of Legacy Displacement 讨论了这个陷阱和其他陷阱。

绞杀者无花果迁移图

客户调用作为门面的 API 网关。/catalog/*(100%)流向有独立数据库的目录服务,GET /orders/* 的 25% 流向订单服务,其余流向遗留单体,单体的数据库通过虚线 CDC 路径向订单数据库供数。

17 个组件 · 10 个连接

在 Archboard 中打开
图表内容
  • Customers
  • Facade (API gateway)
  • Catalog service
  • Orders service
  • Legacy monolith
  • Catalog DB
  • Orders DB
  • Monolith DB
  • Debezium + Kafka
  • Anti-corruption layer
  • HTTPS
  • /catalog/* · 100%
  • GET /orders/* · 25%
  • everything else + 75% of GET /orders
  • reads, writes
  • reads
  • WAL changes
  • order row events
  • upserts in the new model
一次进行中的单体迁移。门面把全部目录流量送到拥有自己数据的新服务,四分之一的订单读取送到由 change data capture 供数的新订单服务,其余送到单体。每一步都可撤销。

这张图的读法如下:

  • 从左到右: 客户调用门面,门面按路由拆分流量;数据库在右侧。
  • 绿色,已迁移: 全部 /catalog/* 流向目录服务,它拥有自己的数据。
  • 琥珀色,金丝雀: GET /orders/* 的 25% 流向订单服务。写入和其余读取仍流向单体。
  • 灰色,遗留: 单体处理其余请求,并仍然拥有订单数据。
  • 紫色,虚线: Debezium 从单体的 WAL 经由 Kafka 流式输出订单变更,防腐层把它们映射到新模型。
  • 路由表列出每条路由的目标、比例和阶段,所以回滚只需改一处。

把绞杀者无花果迁移画清楚

  • 每个阶段一张图, 或者在标题里写上日期;保留旧图,以显示方向。
  • 路由标签写明路径和比例, 例如「GET /orders/* · 25%」。
  • 标出数据归属: 系统记录,以及每条同步的方向。
  • 按状态着色(已迁移、金丝雀、遗留),而不是按团队。
  • 省略内部细节。 这是迁移地图,不是组件图。
  • 画出隐藏的消费者, 例如读取单体数据库的夜间报表。

如何让最终状态保持可读,微服务架构图有示范。

在 Archboard 中构建

  1. 用 Google 或 GitHub 登录,并创建一张图。
  2. 从 C4 Model 包添加一个人员代表客户,从云包添加一个 API 网关作为门面。
  3. 从 C4 Model 包添加三个容器节点:Catalog service、Orders service 和 Legacy monolith。把它们分别涂成绿色、琥珀色和灰色。
  4. 在每个容器右侧,从数据包添加一个 Postgres 节点。
  5. 在单体的数据库和订单数据库之间,叠放一个来自数据包的 Kafka 节点(Debezium + Kafka)和一个来自 C4 Model 包的组件(Anti-corruption layer),并用虚线边向上连接。
  6. 从门面画出各条路由,标签写明路径和比例,并在每个容器上方用基础包的文本添加状态标签。
  7. 从基础包添加一个 Markdown 块,放入路由表。

如果你的路由图在 Mermaid 流程图里,Mermaid 转换器可以把它转成可编辑的初稿。

这个示例共用 11 个图表节点,在 Free 套餐的限制之内。

常见问题

什么是绞杀者无花果模式? 一种逐步替换遗留系统的方法:门面把每个请求路由到旧代码或新服务,能力逐个迁移,当没有请求再路由到旧系统时就把它关掉。

为什么叫绞杀者无花果模式? Martin Fowler 在 2004 年以他在昆士兰见到的绞杀榕为它命名,这种植物围绕宿主树生长,并可能取代它。2019 年他在名字里加上了 Fig,以突出这种植物。

绞杀者无花果迁移中如何处理数据库? 分步进行。新服务先读取单体的表,然后拥有由 change data capture 供数的自有数据库,并在验证之后接管写入。避免在应用代码里双写。

绞杀者无花果与 branch by abstraction 有什么区别? 绞杀者无花果在系统边缘路由请求。Branch by abstraction 在代码内部起作用:通过接口让新旧实现共存,直到所有调用方都完成切换。许多迁移两者都会用到。

什么时候不该使用绞杀者无花果模式? 当对旧系统的请求无法拦截、系统小到可以一次性替换,或者必须尽快关停的时候。

免费开始 — 1 个席位,无需信用卡。

相关文章

← 返回博客

一分钟打开画布

使用 Google 或 GitHub 登录。单席位免费,团队加入后再升级。