# 改客户表结构，别直接动正在用的列

> 把破坏性变更拆成多次小部署，收缩阶段之前每步都能回滚，最后的清理也不能省。

原文: https://fdetimes.net/zh/guides/expand-contract-schema-migration-without-downtime/

设想你正在客户现场。客户要把客户数据推送到一个新系统，而新系统要求姓和名分成两个字段。`customers` 表只有一个 `name` 列，读它的有主应用、一个每晚运行的报表任务，还有另一个团队负责的提取-转换-加载（ETL）数据管道。

最先想到的做法是写一个迁移：拆列、改名、改代码，一次部署完事。但滚动部署期间，旧版本的应用实例仍在运行，仍在找 `name` 列。今晚的报表任务也完全不知道你改了什么。

这种局面对前线部署工程师（FDE）来说尤其棘手。你在别人的数据库上干活，不清楚谁在读什么，也没法让整个公司停下来等你。应对这种局面的技术叫扩展-收缩。

## 核心思路：永远不改正在运行的东西

Martin Fowler 称之为并行变更（parallel change）：一种安全引入向后不兼容接口变更的方法。数据库表结构也是一种接口，每一条对它执行的 SQL 查询都是它的客户端。

这个模式分三个阶段。扩展阶段，拓宽接口，让它同时支持新旧两个版本。迁移阶段，把仍在使用旧版本的客户端逐个迁到新版本。收缩阶段，移除旧版本。

Pete Hodgson 把好处说得很直白：原本是一次高风险部署，现在变成一串相互独立的部署，而且这串变更中的每一次都保持兼容。

由于每个阶段都与上一阶段向后兼容，系统的不同部分可以处在不同阶段。新旧版本的应用并行运行，互不破坏。

**要点:** 每次部署只做一件上一版本仍能承受的事。

## 拆分 name 列，一次部署一步

Prisma 关于扩展-收缩的指南用的正是这个例子：需要把 `name` 拆成 `first_name` 和 `last_name` 时，应新增这两列，而不是修改 `name`。Stripe 在介绍如何大规模执行在线迁移时，把工作拆成四步。

先对新旧结构双写，再把所有读取路径迁到新结构，然后只写新结构，最后删除仍依赖旧模型的数据。

套用到客户的表上，第一步只动表结构：

```sql
-- 第 1 步：扩展。只新增，不修改也不删除。
ALTER TABLE customers ADD COLUMN first_name text NULL;
ALTER TABLE customers ADD COLUMN last_name  text NULL;
```

新列允许 NULL，旧应用可以照常插入。万一出问题，只要删掉新加的两列，谁都不受影响。

第二步修改写入代码。按 Prisma 的描述，这一阶段客户端同时写入原结构和新结构，读取路径仍走原结构：

```python
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）：把新存储中缺失的部分补齐，让两边的信息完全一致。

```sql
-- 第 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 库做校验。不一定要用这个库，用它的思路就行：两边都读，做比较，不一致时记日志，但仍返回旧结构的结果。

```python
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 警告说，如果跳过收缩阶段，最终状态可能比开始时更糟：两列并存，代码写两遍，接手的人不知道该信哪一列。

```sql
-- 最后一步：收缩。只在没有任何客户端还在读 name 时执行。
ALTER TABLE customers DROP COLUMN name;
```

“没有客户端还在读”必须证明，不能靠猜。在客户环境里，要查看数据库查询日志，询问负责 ETL 的团队，检查各个看板。每晚的报表任务会是第二天早上第一个出问题的。

## 在客户环境里，一切都更慢

Hodgson 估计，一个随时能部署到生产环境的团队，熟练之后一两个小时就能走完一轮扩展-收缩。客户环境很少是这样：可能有固定的发布周期、变更评审委员会，还有另一个团队掌管着一半的客户端。整个过程会因此拉长很多。

所以在第一次工作会议上，就要问清客户多久能部署一次、由谁审批。答案决定你如何合并步骤。如果每月只有一个发布窗口，也许不得不把扩展和双写放进同一次发布，但收缩绝不能和其他任何步骤合并。

## 常见错误

最常见的错误是直接对正在使用的列执行 `ALTER COLUMN` 或 `RENAME`，完全跳过扩展阶段。其次是用一条 `UPDATE` 回填整张表，长时间持有锁，拖慢的恰恰是你本想不去惊动的系统。第三是回填还没完成就切换读取路径，让用户看到空名字。

还有两个错误往往后来才暴露。一是忘了那些不在你代码仓库里的客户端。二是放弃收缩阶段就离开项目，留给客户一套比你来之前更难理解的表结构。

对职业发展来说，这项技能值得在简历上写准确。不要写“执行过数据库迁移”，而要写：通过一系列向后兼容的部署修改了客户生产表的结构，每一步都有比较读取和回滚方案。

FDE 面试问到事故经历时，不要只讲救火的故事。也准备一个这样的例子：你如何规划一次变更，让它在不停机的情况下完成，以及每一步如何回滚。

本周练习：在手头项目里挑一个真实的列，按上表的六个步骤写一份方案，交给同事审读。如果对方指不出哪一步会让系统崩溃，这份方案就可以拿给客户了。

**本周动手试试:**

- 在你的项目里挑一个想改名或拆分的列，按本文的表格为它写一份六步方案，写明每一步如何回滚。
- 在写第一行 SQL 之前，列出所有读取该列的系统（应用、报表任务、ETL、看板）。
- 写一个比较读取函数：同时读取新旧结构，跳过尚未回填的行，两边不一致时记日志，但仍返回旧结构的结果。

## 信源

- [Parallel Change (Martin Fowler)](https://www.martinfowler.com/bliki/ParallelChange.html)

- [Expand/Contract: making a breaking change without a big bang](https://blog.thepete.net/blog/2023/12/05/expand/contract-making-a-breaking-change-without-a-big-bang/)

- [Online migrations at scale](https://stripe.com/blog/online-migrations)

- [Using the expand and contract pattern for schema changes (Prisma Data Guide)](https://www.prisma.io/dataguide/types/relational/expand-and-contract-pattern)
