改客户表结构,别直接动正在用的列
把破坏性变更拆成多次小部署,收缩阶段之前每步都能回滚,最后的清理也不能省。
要点速览
- 不要修改正在使用的列。先在旁边加上新结构,两边同时写入,然后才切换读取路径。
- 每一步都是一次单独的部署,与上一步向后兼容,因此在收缩阶段之前随时可以回滚。
- 跳过收缩阶段,系统可能比改动前还糟。
设想你正在客户现场。客户要把客户数据推送到一个新系统,而新系统要求姓和名分成两个字段。customers 表只有一个 name 列,读它的有主应用、一个每晚运行的报表任务,还有另一个团队负责的提取-转换-加载(ETL)数据管道。
最先想到的做法是写一个迁移:拆列、改名、改代码,一次部署完事。但滚动部署期间,旧版本的应用实例仍在运行,仍在找 name 列。今晚的报表任务也完全不知道你改了什么。
这种局面对前线部署工程师(FDE)来说尤其棘手。你在别人的数据库上干活,不清楚谁在读什么,也没法让整个公司停下来等你。应对这种局面的技术叫扩展-收缩。
核心思路:永远不改正在运行的东西
Martin Fowler 称之为并行变更(parallel change):一种安全引入向后不兼容接口变更的方法。数据库表结构也是一种接口,每一条对它执行的 SQL 查询都是它的客户端。
这个模式分三个阶段。扩展阶段,拓宽接口,让它同时支持新旧两个版本。迁移阶段,把仍在使用旧版本的客户端逐个迁到新版本。收缩阶段,移除旧版本。
Pete Hodgson 把好处说得很直白:原本是一次高风险部署,现在变成一串相互独立的部署,而且这串变更中的每一次都保持兼容。
由于每个阶段都与上一阶段向后兼容,系统的不同部分可以处在不同阶段。新旧版本的应用并行运行,互不破坏。
拆分 name 列,一次部署一步
Prisma 关于扩展-收缩的指南用的正是这个例子:需要把 name 拆成 first_name 和 last_name 时,应新增这两列,而不是修改 name。Stripe 在介绍如何大规模执行在线迁移时,把工作拆成四步。
先对新旧结构双写,再把所有读取路径迁到新结构,然后只写新结构,最后删除仍依赖旧模型的数据。
套用到客户的表上,第一步只动表结构:
-- 第 1 步:扩展。只新增,不修改也不删除。
ALTER TABLE customers ADD COLUMN first_name text NULL;
ALTER TABLE customers ADD COLUMN last_name text NULL;
新列允许 NULL,旧应用可以照常插入。万一出问题,只要删掉新加的两列,谁都不受影响。
第二步修改写入代码。按 Prisma 的描述,这一阶段客户端同时写入原结构和新结构,读取路径仍走原结构:
def save_customer(conn, cid, full_name, first, last):
conn.execute(
"""UPDATE customers
SET name = %s, first_name = %s, last_name = %s
WHERE id = %s""",
(full_name, first, last, cid),
)
双写只能让新记录保持同步。数百万条旧数据的 first_name 仍是空的。Stripe 的办法是回填(backfill):把新存储中缺失的部分补齐,让两边的信息完全一致。
-- 第 3 步(单独一次部署或单独一个任务):分批回填,重复执行直到没有剩余行
UPDATE customers
SET first_name = split_part(name, ' ', 1),
last_name = substr(name, length(split_part(name, ' ', 1)) + 2)
WHERE id IN (
SELECT id FROM customers
WHERE first_name IS NULL
ORDER BY id LIMIT 5000
);
上面的查询假设第一个词是名,其余部分是姓。对于“Nguyen Van An”这样姓在前的越南语数据,这条规则得出的结果完全错误。这不是技术 bug,而是业务决策,做决定的必须是客户,而不是你。
切换读取路径之前先校验
读取路径最容易出错,因为这里的错误会直接暴露在用户面前。Stripe 在切换读取路径之前,用 GitHub 的 Scientist 库做校验。不一定要用这个库,用它的思路就行:两边都读,做比较,不一致时记日志,但仍返回旧结构的结果。
def display_name(row):
old = row["name"]
if row["first_name"] is None:
return old # 该行尚未回填:没有可比较的内容,避免误报不一致日志
new = f'{row["first_name"]} {row["last_name"] or ""}'.strip()
if old != new:
log.warning("name_mismatch", extra={"id": row["id"]})
return old # 在日志干净之前,继续信任旧结构
要清楚这个函数的局限。把 first_name 和 last_name 拼起来,几乎能原样还原原字符串,所以它发现不了“Nguyen”被误放进名字段这类问题。它只能捕捉技术错误,比如字符丢失、空白不对,或者双写失败的行。
要捕捉业务错误,需要和客户认可的东西做比较:比如由客户员工手工拆分姓名的几百行样本,或者一条经过签字确认的姓氏拆分规则。与这份样本出现偏差,才是回填理解错了含义的真正信号。
在客户的真实流量上跑几天。等日志里不再出现不一致,或者每一处不一致都有了解释,再部署从 first_name 和 last_name 读取的版本。之后再做一次部署,切换为只写新列。
下表应放进交给客户的交接文档。它列出每一步在必要时如何回滚:
| 步骤 | 写入 | 读取 | 如何回滚 |
|---|---|---|---|
| 1. 扩展 | name |
name |
删除两个新列 |
| 2. 双写 | 两边 | name |
恢复为只写 name 的代码 |
| 3. 分批回填 | 两边 | name |
停止任务;旧数据未被改动 |
| 4. 比较读取 | 两边 | name,不一致时记日志 |
关闭比较 |
| 5. 切换读取,再改为只写新列 | 两边,之后只写新列 | 新列 | 在保持双写的同时把读取指回 name |
| 6. 收缩 | 新列 | 新列 | 没有低成本的退路 |
越往后走,回滚代价越高,到了收缩这一步几乎无路可退。因此收缩必须放在最后,而且需要单独审批。
收缩阶段:最常被遗忘的一步
一切运行顺畅之后,最大的诱惑是就此打住。Fowler 警告说,如果跳过收缩阶段,最终状态可能比开始时更糟:两列并存,代码写两遍,接手的人不知道该信哪一列。
-- 最后一步:收缩。只在没有任何客户端还在读 name 时执行。
ALTER TABLE customers DROP COLUMN name;
“没有客户端还在读”必须证明,不能靠猜。在客户环境里,要查看数据库查询日志,询问负责 ETL 的团队,检查各个看板。每晚的报表任务会是第二天早上第一个出问题的。
在客户环境里,一切都更慢
Hodgson 估计,一个随时能部署到生产环境的团队,熟练之后一两个小时就能走完一轮扩展-收缩。客户环境很少是这样:可能有固定的发布周期、变更评审委员会,还有另一个团队掌管着一半的客户端。整个过程会因此拉长很多。
所以在第一次工作会议上,就要问清客户多久能部署一次、由谁审批。答案决定你如何合并步骤。如果每月只有一个发布窗口,也许不得不把扩展和双写放进同一次发布,但收缩绝不能和其他任何步骤合并。
常见错误
最常见的错误是直接对正在使用的列执行 ALTER COLUMN 或 RENAME,完全跳过扩展阶段。其次是用一条 UPDATE 回填整张表,长时间持有锁,拖慢的恰恰是你本想不去惊动的系统。第三是回填还没完成就切换读取路径,让用户看到空名字。
还有两个错误往往后来才暴露。一是忘了那些不在你代码仓库里的客户端。二是放弃收缩阶段就离开项目,留给客户一套比你来之前更难理解的表结构。
对职业发展来说,这项技能值得在简历上写准确。不要写“执行过数据库迁移”,而要写:通过一系列向后兼容的部署修改了客户生产表的结构,每一步都有比较读取和回滚方案。
FDE 面试问到事故经历时,不要只讲救火的故事。也准备一个这样的例子:你如何规划一次变更,让它在不停机的情况下完成,以及每一步如何回滚。
本周练习:在手头项目里挑一个真实的列,按上表的六个步骤写一份方案,交给同事审读。如果对方指不出哪一步会让系统崩溃,这份方案就可以拿给客户了。
这篇文章对你有帮助吗?
感谢你的反馈!