企业应用架构22:Strangler 与 Branch by Abstraction
旧租赁系统用 cents 保存合同金额,新实现准备采用 amount_minor 与 currency。替换一个字段看似简单,实际存在多个同时运行的事实:旧版本还在写,历史行尚未回填,新版本可能只接管部分租户,而回退按钮只能切换代码,无法撤销已经提交的数据。
本篇用 SQLite 文件数据库演练一个受限迁移:保留旧字段,增加兼容字段,通过稳定写入入口按租户路由;回填中途退出,再从检查点恢复;最后切回旧实现并继续写入。实验中的副作用是数据库 effects 记录,没有访问外部支付或通知服务,线上网关、跨机器复制和真实流量迁移均为 NOT_RUN。
先确定回退以后谁读什么
迁移的第一份契约应描述旧版本仍然需要的列、类型和含义。旧读取 cents,固定解释为 CNY;新读取优先使用 amount_minor 和 currency,尚未回填时退回旧字段及 CNY。两条读取路径对同一合同必须返回相同金额与币种,比较应在实际数据上执行,不能只比较两份接口定义。
因此,这次变化只允许 CNY。若新版本开始写 USD,旧版本把同一个数字解释为人民币,即使 SQL 能执行,业务也已经不兼容。新增字段并不天然可回退;兼容窗口的有效范围由语义决定。样例主动排除多币种合同,避免借助新增 currency 字段假装完成跨币种迁移。
新旧写入都必须同步维护 cents 与 amount_minor,且在一个数据库事务中完成。把两次 UPDATE 放到不同事务,会留下新值已提交而旧值未变的窗口;路由回退后就会读到过期金额。实验把兼容写入集中在一个入口,旧分支与新分支使用不同列顺序,但都维护相同的持久化约束。
这种入口改造不意味着旧程序的原始二进制无需变化。现实中若旧版本完全不知道新字段,则需要先发布兼容适配层、数据库机制或变更传播方案,再允许新版本接管。本实验的旧实现已经经过兼容改造;它证明兼容窗口内的路由回退,不能证明任意历史版本都能直接运行。
下图回答哪些边界可以逐步切换。它是调用结构图,省略实际 HTTP 网关和部署单元;两个实现仍在同一教学进程内。
flowchart LR
C[租赁变更用例] --> P[稳定写入入口]
P --> R[按租户读取路由]
R --> O[旧实现]
R --> N[新实现]
O --> T[同事务维护旧列与新列]
N --> T
T --> D[(SQLite合同与副作用记录)]
Strangler 与抽象分支解决不同切换位置
Strangler Fig Application 的迁移思路是让新能力逐渐替代旧系统的职责。切换位置可能是接口、页面或业务用例,重点是明确哪些流量已经属于新实现,哪些仍然保留旧路径。它没有自动规定数据库怎样同步,也没有保证所有旧功能都能轻松退出。
Branch by Abstraction 则在代码内部建立稳定抽象,使调用方能在实现替换期间继续使用同一个入口。这个实验的 write 函数承担该入口,route 表决定具体分支。二者可以组合:外层逐用例迁移,内部以抽象隔离实现变化;也可以只使用其中一种,不能因为画了两条分支就宣称完成整套系统绞杀。
路由必须有可查询的状态。样例先把 A 切到 new,B 仍为 old,写入时日志输出 dispatch=A:new 与 dispatch=B:old。回退后 A 再次写入会记录 dispatch=A:old。这样能区分“配置表改了”与“实际执行路径改了”,后者才支持迁移演练结论。
路由范围采用租户只是本实验的选择。有的系统按用例、有的按合同创建时间或版本切换;同一合同的相邻操作若被切到不同实现,可能违反状态假设。选择粒度时应优先保持一个业务对象在兼容窗口内的语义一致,再考虑发布便利。
回填进度必须与数据一起提交
历史数据有两条合同,初始新列均为空。backfill 根据 checkpoint.last_id 读取后续行,每处理一条,在同一事务内写新列并更新检查点,然后提交。故障点位于第一条提交之后,进程用退出码 73 结束。退出时第一条数据与检查点都已经持久化,第二条仍待处理。
父进程检查子进程退出码,随后启动全新的 Python 进程继续回填;再运行一次确认没有新增工作。最终检查点为 2,两条新旧读取相等。这里重新执行的是实际 SQL,并没有用循环变量模拟重启,也没有在内存中记住进度。
如果先提交检查点再提交数据,重启会跳过未完成行;如果先提交数据而检查点落后,重复执行可能再次产生副作用。数据回填本身应设计成可重入,并且尽可能把进度与写入放在相同事务中。样例只覆盖单库、单回填工作者,多个工作者竞争检查点与运行期间新增历史行的处理尚未验证。
下图描述真实进程退出窗口,重点是检查点与更新的事务关系。省略文件系统断电与数据库介质损坏,因此它不支持灾难恢复结论。
sequenceDiagram
participant P1 as 回填进程一
participant D as SQLite
participant P2 as 回填进程二
P1->>D: 更新合同1的新列
P1->>D: 检查点改为1
P1->>D: COMMIT
P1--xP1: exit 73
P2->>D: 读取检查点1
P2->>D: 更新合同2和检查点2
P2->>D: COMMIT
P2->>D: 再运行无待处理行
影子比较必须是只读的
迁移前让新旧读取同时处理合同 1、2,比较返回的金额与币种。正常候选结果完全一致;故意让新结果增加一分,会产生两项实际差异报告,包含合同编号、旧值与候选值。差异报告是数据,不是“理论上应该一致”的表格。
影子路径只调用 read,不调用 write。实验在比较前后读取 effects 的行数,确认计数不变。这个断言针对样例中的持久副作用,不证明任意插件、网络客户端或日志处理器没有副作用;真实系统需要检查影子链路依赖,尤其是看似查询却触发延迟初始化、发送通知或修改访问时间的代码。
对账也需要考虑时间一致性。旧路径在变更前读取,新路径在变更后读取,可能出现合法差异;若忽略这一点,迁移团队会把并发变化误诊为算法错误。本实验比较时没有并发写入,明确避开了这一变量。引入并发后,应使用可比较的快照或业务版本,并把无法对齐的样本单独记录。
真正发现不一致时,路由切换应停止在受影响的用例或租户,而不是靠忽略字段来让报表变绿。金额、币种和合同身份都是契约字段;显示格式等非业务差异可以单独归一化,但归一化规则也要可审阅。实验故意增加一分,就是为了确认比较器不会把核心金额差异吞掉。
切回旧实现之后继续写
A 使用新分支把合同改为 350,B 使用旧分支改为 450,两个动作都使用相同业务键 change-1,但租户不同,因此各形成一条副作用记录。同一租户再次执行相同键、目标合同和金额直接返回,不会重复追加 effects;相同键对应不同合同或金额都会被拒绝。effects 保存目标合同与金额,租户则由联合主键限定,幂等指纹覆盖这次变更的完整业务输入。路由版本没有进入指纹,因为旧实现和新实现应表达同一个变更意图。
冲突回归在回填完成后额外建立甲公司的合同三,初始金额为一百七十五。它重用 change-1 请求编号,以相同金额改投合同三;另一个反例保留合同一,却把金额改为三百五十一。两个输入均被拒绝,原合同保持三百五十,新目标保持一百七十五,副作用记录仍为两条。这样可以发现只比较金额而忽略目标对象的错误:即使金额相同,换一个合同也不是同一次请求的安全重试。
路由全部切回 old 后,再对 A 发起独立请求 after-rollback,把金额改为 375。新连接读取结果为 A=375、B=450,旧读与新读仍一致,effects 总数为 3。这个额外操作很关键:回退后仅仅能查询,不足以说明旧路径还能继续办理业务。
实验没有删除 amount_minor 和 currency。破坏性清理需要等到所有旧版本、后台作业、报表与回滚窗口都退出之后。清理完成后,再回退旧程序通常已经不可行,应以新的恢复策略替代“开关切回”承诺。扩展、迁移和收缩三个阶段不能压成一次不可逆部署。
数据库事务保证的是本库中的合同与 effects 一致。若 effects 换成外部通知,单库事务不能撤回已经发送的请求,应改用前文的持久待发记录与接收方幂等。这里保留本地 effects 作为二值可观察量,避免用假的发送日志代替真实外部服务证据。
可复现入口与决策
1 | |
入口 lab.py 使用 Python 标准库 sqlite3,原始 SQL、退出码 73、checkpoint=1/2、difference-report、路由选择和最终金额保存于 examples/enterprise-application-architecture/evidence/22/normal.stdout.txt。完整实验退出码为零;读者可查看 backfill、read 和 write 三个函数,分别对应进度恢复、兼容读取与路由写入。
本章选择先增加兼容层再逐租户切换,替代方案是停机一次迁移或长期维护两套独立数据库。停机方案运维简单,但需要业务接受停止服务;双库方案可独立演进,却需要额外同步、对账和冲突处理。当前有限需求没有证明双库值得引入,因此保留单库兼容窗口。
退出条件也应写进决策:确认所有调用方只使用新语义、历史回填完成、对账无未解释差异、旧路径在约定窗口内无请求,然后另行安排旧列清理。当前实验完成回填和本地回退,不声称满足生产退出条件。
参考资料
实验下载:独立实验与原始证据包
- Martin Fowler:Strangler Fig Application。
- Martin Fowler:Branch by Abstraction。
- SQLite Transactions,事务开始、提交与回滚语义;实际 SQLite 版本见环境记录。
