你的单体应用已有十年历史,每次发布都要经历漫长的回归周期,这时有人提议重写:并行建一套新系统,达到功能对等,然后在某个周末切换。与此同时,旧系统仍在变化,切换日期不断后延。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,而不是它原来的表。
不要把业务规则放进这一层。在六边形服务中,它位于适配器里,所以核心永远看不到遗留类型。
在迁移路由之前证明一致
每条路由都要经过同样的步骤:
- 影子。 门面把请求复制给新服务并丢弃它的响应,NGINX 的镜像模块和 Envoy 的请求镜像就是这样做的。只镜像读取:被镜像的
POST /orders会创建第二个订单。 - 比较。 把时间戳、ID 和排序归一化之后,对比两边的响应。Twitter 在 2015 年开源的 Diffy 会运行两份旧代码,并把它们之间的差异当作噪声忽略。在单体内部,GitHub 的 Scientist 库(Ruby 编写,有其他语言的移植版)会并行运行新旧路径,并返回旧路径的结果。
- 金丝雀。 依次迁移 1%、5%、25%、50%,最后 100% 的真实流量,把同一路由的错误率和 p99 延迟与单体对比,并让每一步都经历一次每日高峰。部署策略图讲解了具体机制。
- 保持。 在 100% 时,让旧路径保持可部署,并让它的数据在月末结账之类的事件期间保持最新。
- 删除旧代码、它的表和那条路由。
只有当单体的数据仍是最新时,把路由切回去才有效。一旦新服务拥有了写入,就要通过反向 CDC 或事件把写入回传,否则回滚会丢掉它们。
绞杀者无花果迁移如何失败
- 永远做不完。 容易的 80% 迁走了,报表、批处理作业和管理页面留了下来,你要同时运维两套系统。每个迭代都统计剩余的部分,并定下关停日期。
- 分布式单体。 服务共享表、一起部署,或通过长长的同步调用链互相调用。检验标准是每张表只有一个写入方。
- 门面成为瓶颈: 网关里有业务逻辑,或者一个实例承担了全部流量。
- 隐藏的消费者。 报表和其他团队直接读取单体的数据库,门面看不到。迁移某张表之前,先检查连接日志。
- 把功能对等当作目标。 Fowler 指出,许多遗留行为「其实并不被需要」。Patterns of Legacy Displacement 讨论了这个陷阱和其他陷阱。
绞杀者无花果迁移图
图表内容
- 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
这张图的读法如下:
- 从左到右: 客户调用门面,门面按路由拆分流量;数据库在右侧。
- 绿色,已迁移: 全部
/catalog/*流向目录服务,它拥有自己的数据。 - 琥珀色,金丝雀:
GET /orders/*的 25% 流向订单服务。写入和其余读取仍流向单体。 - 灰色,遗留: 单体处理其余请求,并仍然拥有订单数据。
- 紫色,虚线: Debezium 从单体的 WAL 经由 Kafka 流式输出订单变更,防腐层把它们映射到新模型。
- 路由表列出每条路由的目标、比例和阶段,所以回滚只需改一处。
把绞杀者无花果迁移画清楚
- 每个阶段一张图, 或者在标题里写上日期;保留旧图,以显示方向。
- 路由标签写明路径和比例, 例如「GET /orders/* · 25%」。
- 标出数据归属: 系统记录,以及每条同步的方向。
- 按状态着色(已迁移、金丝雀、遗留),而不是按团队。
- 省略内部细节。 这是迁移地图,不是组件图。
- 画出隐藏的消费者, 例如读取单体数据库的夜间报表。
如何让最终状态保持可读,微服务架构图有示范。
在 Archboard 中构建
- 用 Google 或 GitHub 登录,并创建一张图。
- 从 C4 Model 包添加一个人员代表客户,从云包添加一个 API 网关作为门面。
- 从 C4 Model 包添加三个容器节点:Catalog service、Orders service 和 Legacy monolith。把它们分别涂成绿色、琥珀色和灰色。
- 在每个容器右侧,从数据包添加一个 Postgres 节点。
- 在单体的数据库和订单数据库之间,叠放一个来自数据包的 Kafka 节点(Debezium + Kafka)和一个来自 C4 Model 包的组件(Anti-corruption layer),并用虚线边向上连接。
- 从门面画出各条路由,标签写明路径和比例,并在每个容器上方用基础包的文本添加状态标签。
- 从基础包添加一个 Markdown 块,放入路由表。
如果你的路由图在 Mermaid 流程图里,Mermaid 转换器可以把它转成可编辑的初稿。
这个示例共用 11 个图表节点,在 Free 套餐的限制之内。
常见问题
什么是绞杀者无花果模式? 一种逐步替换遗留系统的方法:门面把每个请求路由到旧代码或新服务,能力逐个迁移,当没有请求再路由到旧系统时就把它关掉。
为什么叫绞杀者无花果模式? Martin Fowler 在 2004 年以他在昆士兰见到的绞杀榕为它命名,这种植物围绕宿主树生长,并可能取代它。2019 年他在名字里加上了 Fig,以突出这种植物。
绞杀者无花果迁移中如何处理数据库? 分步进行。新服务先读取单体的表,然后拥有由 change data capture 供数的自有数据库,并在验证之后接管写入。避免在应用代码里双写。
绞杀者无花果与 branch by abstraction 有什么区别? 绞杀者无花果在系统边缘路由请求。Branch by abstraction 在代码内部起作用:通过接口让新旧实现共存,直到所有调用方都完成切换。许多迁移两者都会用到。
什么时候不该使用绞杀者无花果模式? 当对旧系统的请求无法拦截、系统小到可以一次性替换,或者必须尽快关停的时候。