演进式架构:绞杀者模式与数据迁移
重写一遍是工程师最容易做的决定,也是最容易失败的决定:新系统上线延期、旧系统没人敢停、两套逻辑在半年里持续分叉,最后得到一个更难维护的组合体。演进式架构的前提是承认一件事:架构不会一步到位,它靠一系列可独立回滚的小变更长出来。本章讲三样东西——增量变更与引导性变更的差别、绞杀者(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 断言迁移进度单调收敛 |
边界说明:灰度分流与发布流程见部署与系统设计章节,双写期间的补偿与最终一致见跨服务一致性章节,决策留痕与评审见架构决策记录章节。
小结:演进式架构用增量变更小步替换、用引导性变更(适应度函数)约束方向,绞杀者模式靠「门面 + 并行运行 + 灰度切流 + 证据摘除」完成替换;数据库迁移固定走加列、双写、回填、切读、停双写、删旧列六步,每一步都要能独立回滚;能用开关回滚的变更才叫可回滚的变更。