演进式架构:绞杀者模式与数据迁移

重写一遍是工程师最容易做的决定,也是最容易失败的决定:新系统上线延期、旧系统没人敢停、两套逻辑在半年里持续分叉,最后得到一个更难维护的组合体。演进式架构的前提是承认一件事:架构不会一步到位,它靠一系列可独立回滚的小变更长出来。本章讲三样东西——增量变更与引导性变更的差别、绞杀者(Strangler Fig)模式的落地步骤、以及数据库迁移的六步法与回滚设计。

选择标准很简单:这次变更失败时,我们能不能在十分钟内退回上一个状态? 不能,就该拆小。

增量变更与引导性变更

演进式架构(Evolutionary Architecture)把变更分成两类,两者配合使用:

  • 增量变更(Incremental Change):在不破坏现有功能的前提下,小步加入新能力。特征是每个变更独立可发布、可回滚。
  • 引导性变更(Guided Change):用自动化的、可执行的规则约束演进方向,让「走偏」在流水线里就被拦住。它不告诉你怎么做,只告诉你不许做什么。

回滚设计属于引导性变更:把「随时能退回上一个状态」变成一条必须满足的规则,而不是出事时的临时补救。光有增量变更会出现「一百次小步走成一个大泥球」;光有引导性变更会变成写不完的规范文档。两者相加才是演进式。

适应度函数:把架构约束写成可执行的测试

适应度函数(Fitness Function)是把架构原则翻译成 CI 里能跑的检查。原则写得再漂亮,只要不能自动失败,半年后必然被绕过。

可自动化的适应度函数示例(按类别)

依赖方向类:
  - 领域层不得依赖基础设施层(用依赖检查或架构守护测试断言包依赖)
  - 模块 A 不得直接引用模块 B 的内部包,只能走对外 API

性能与容量类:
  - 核心接口的 P99 在基准压测中不得超过预算值,超过则流水线失败
  - 单次查询扫描行数上限(用执行计划断言,防止慢查询被合进主干)

耦合与规模类:
  - 单个服务对外暴露的同步依赖数不超过 N 个;跨服务直连他人数据库的调用数必须为 0

演化方向类:
  - 新旧两条实现路径的调用量占比必须单调变化(例如新路径占比每周不得下降)
  - 已废弃接口的调用次数必须为 0,否则不允许删除旧代码

最后一类最容易被忽略,却最关键:它把「迁移完成」变成可判定的状态,而不是靠某个人说「应该没人用了」。

绞杀者模式的落地步骤

绞杀者模式的核心是让新旧系统长期共存,把流量一片一片从旧的搬到新的,直到旧系统自然枯萎。

1. 选切片:从边界清晰、依赖少、价值可量化的一小块开始(不要从核心交易主流程开始)
2. 建门面:在入口(网关 / BFF / 防腐层)加一层可切换的路由,新旧实现都挂在它后面
3. 新实现:只做这一片,接口契约以门面为准,不与旧内部实现耦合
4. 双向校验:并行运行一段时间,比对两边结果,允许不一致但必须能解释
5. 灰度切流:按用户、租户或比例逐步放量,每一步观察 SLI 与业务指标
6. 观察期:新路径全量后仍保留旧路径一段时间,作为应急退回通道
7. 摘除:确认无人调用(看指标与日志,不看口头确认)后删除旧代码、旧表、旧任务

第 4 步是整个模式的灵魂:没有比对,灰度只是「换了个地方出问题」。

并行运行方式做法收益代价
影子读同一请求同时打新旧两边,只用旧结果零风险验证新实现正确性双倍读流量,新代码 bug 无业务影响
双跑比对两边都算,记录差异并统计不一致率能精确量化不一致需要差异存储与判定规则
灰度分流按用户或比例把真实流量分给新版真实的收益与性能数据用户可见差异,需要快速回退机制

数据库迁移六步

数据库是演进中最难回滚的部分,因为数据一旦写坏,回滚代码也回不来。所以采用「扩展—收缩」(Expand / Contract)节奏:先加不删,切完再删。

步骤动作系统状态回滚方式
1 加列新增目标列或新表,允许为空,不动旧列新旧结构共存,旧代码无感直接删除新列(无数据依赖)
2 双写应用同时写旧列与新列,读仍走旧列新列开始积累数据关掉双写开关,回到只写旧列
3 回填批量把历史数据补齐到新列,控制批次与速率新旧数据逐步一致停止回填任务即可
4 切读读路径切到新列,保留旧列作兜底新列为读依据开关切回旧列读
5 停双写确认新列稳定后,停止写旧列旧列成为死字段重新打开双写(需保证旧列仍有值)
6 删旧列观察期结束后删除旧列与相关索引、代码分支完成收缩依赖备份恢复,代价最高
关键约束(写进迁移方案里)

- 第 1~5 步之间,任何一步都必须能独立回滚,不允许出现「必须完成第 6 步才能回滚」的设计
- 双写期间两个写入必须同事务或同 Outbox,避免只写成功一边
- 回填任务要限速并可在任意时刻暂停;失败重跑必须幂等
- 切读开关必须是配置项,不做代码发布;回退速度以秒计
- 删除旧列单独排期,与业务发布解耦
- 新旧列含义不同(如单位、精度、时区变化)时,必须在回填脚本里显式转换并抽样验证,对账不通过不允许进入下一步

一批数据回填示例(示意,具体语法以所用数据库文档为准):

-- 第 1 步:加列(可空、无默认值,避免大表长时间锁)
ALTER TABLE orders ADD COLUMN amount_cent BIGINT NULL;

-- 第 3 步:分批回填,每批提交一次,随时可停
UPDATE orders
   SET amount_cent = ROUND(amount * 100)
 WHERE id > ? AND id <= ?
   AND amount_cent IS NULL;

-- 第 6 步:确认无人读旧列后再删(删除前跑一次新旧一致性对账)
ALTER TABLE orders DROP COLUMN amount;

切读开关的形态决定了回滚速度,别把它藏在代码分支里:

配置项(可热更新,不走发布)
  order.read.amount_source = legacy | new
  order.write.amount_mode   = legacy | dual | new

灰度期:read.amount_source = new 只对内部租户开启
异常时:把开关改回 legacy,一次配置发布完成回滚

回滚设计的三个必备条件:变更与数据解耦(数据变更单独设计回滚点)、用兼容期替代瞬间切换点(新旧格式都能读)、关闭开关前置(没有开关的新特性不允许全量)。

常见误区

误区后果正确做法
从核心主流程开始绞杀风险与收益不成比例,一失败就回退到底先选边缘切片跑通流程与工具链
门面层顺手加了业务逻辑门面成为新的耦合中心,将来摘不掉门面只做路由与协议转换
并行运行只比「有没有报错」数值不一致被漏掉,切流后才发现比对业务字段并统计不一致率与分布
靠「问了一圈都说没人用」就删表删掉仍在跑的定时任务依赖以调用量指标为唯一证据,加观察期
没有适应度函数迁移方向靠人记,两套实现的占比反弹用 CI 断言迁移进度单调收敛

边界说明:灰度分流与发布流程见部署与系统设计章节,双写期间的补偿与最终一致见跨服务一致性章节,决策留痕与评审见架构决策记录章节。

小结:演进式架构用增量变更小步替换、用引导性变更(适应度函数)约束方向,绞杀者模式靠「门面 + 并行运行 + 灰度切流 + 证据摘除」完成替换;数据库迁移固定走加列、双写、回填、切读、停双写、删旧列六步,每一步都要能独立回滚;能用开关回滚的变更才叫可回滚的变更。

笔记加载中…