17.3 版本与迁移:不中断 schema 迁移演练
17.2 的灰度让新旧两版代码同时在跑。这正是 schema 迁移最危险的前提:当你准备把某张表的列改个名字或换个类型,线上还跑着不认识新列的旧版代码。如果你直接 ALTER TABLE ... DROP COLUMN,旧版代码下一次查询就会报 UndefinedColumn——一次「例行迁移」瞬间变成全站事故。所以不中断的迁移,本质上是一套让新旧代码在迁移期间都能正常工作的编排。
本节把 ResearchCopilot 推进到「schema 可安全演进」:讲清 expand–contract 五阶段迁移法,并在本机 Postgres 17.11 上真连真跑,用 psycopg 3.3.6 完整走完「加列 → 双写 → 回填 → 切读 → 删列」五个阶段,每阶段查询验证新旧两列一致;另演示 DDL 锁表与
CREATE INDEX CONCURRENTLY。
17.3.1 为什么不能直接 DROP COLUMN
先看反例。假设把 runs 表的 status 列改名成 state,最直觉的迁移是「删旧的、建新的」:
ALTER TABLE runs DROP COLUMN status;
ALTER TABLE runs ADD COLUMN state text;
这在单版本部署下没问题——应用代码和 schema 一起改、一起上。但在灰度期间,线上同时有旧版(读 status)和新版(读 state)。这条 SQL 一执行,旧版代码的每一次查询都会炸。哪怕你「先发新版代码、再删旧列」,中间也必然有一个窗口:新版已上线,但还有旧版的 Pod 在滚动更新中没退完。这个窗口就是事故窗口。
结论很硬:迁移期间,任何一版代码都不允许被 schema 变更弄坏。这就逼出了 expand–contract。
17.3.2 expand–contract 五阶段
把「一次危险的改名」拆成五个每一步都向后兼容的阶段:
| 阶段 | 动作 | 旧代码 | 新代码 | 可回滚 |
|---|---|---|---|---|
| ① expand | 加新列(可空) | 正常 | 正常 | 删新列即可 |
| ② dual-write | 应用同时写新旧两列 | 正常 | 正常 | 停双写 |
| ③ backfill | 分批把旧列值搬进新列 | 正常 | 正常 | 重跑即可 |
| ④ switch-read | 新代码改读新列 | 正常 | 正常 | 改回读旧列 |
| ⑤ contract | 删旧列 | 已下线 | 正常 | 需从备份恢复 |
关键在于每一步都保持「两版代码都能跑」:
- ① expand 只加一个可空的新列。旧代码完全不知道它,新代码可以开始写它。可空是必须的——如果新列设
NOT NULL且无默认值,ADD COLUMN会对历史行报错。 - ② dual-write 让应用在写旧列的同时也写新列。此时新列开始有新写入的数据,但历史数据仍是空的。
- ③ backfill 把历史行里旧列的值批量搬到新列。这是唯一处理「迁移之前就已存在的数据」的阶段,必须分批做(见 17.3.3)。
- ④ switch-read 把新代码的读取从旧列切到新列。这一步之后,旧列就没人读了,但还在被写(双写),所以仍是安全的。
- ⑤ contract 删旧列。这一步的前提是旧版代码已全部下线——只有当 17.2 的灰度走到 100% 且旧 Pod 退干净,才敢删。
五阶段的核心思想是:把「切换点」从一个(schema 变了)拆成多个(双写、切读、删列),每个切换点单独、可回滚地做。任一步出问题,退回上一步都是安全的。
17.3.3 长事务与锁:为什么大表不能随便 ALTER
阶段 ①②④ 都是纯元数据或应用层改动,很轻。真正的坑在 ③ backfill 和 ⑤ 相关的索引操作上,它们涉及锁与长事务。
ALTER TABLE 在 Postgres 里默认取 ACCESS EXCLUSIVE 锁——它阻塞这张表上的所有读写。对一张几百万行的大表,一个「加列」操作哪怕只改元数据,也可能因为要等一个正在跑的长查询释放锁而排起长队,把后面所有请求堵死。本机实测这个锁的真实形态(见 17.3.5):ALTER TABLE ... ADD COLUMN 期间,并发 INSERT 会被 AccessExclusiveLock 挡在门外。
三条对应的纪律:
- 加列要可空、给常量默认值。Postgres 11 起,
ADD COLUMN ... DEFAULT <常量>是元数据操作,不重写表;但DEFAULT <易变表达式>或加NOT NULL会触发全表重写,代价陡增。 - 建索引用
CREATE INDEX CONCURRENTLY。普通CREATE INDEX取SHARE锁,阻塞写;CONCURRENTLY只取更弱的锁,不阻塞读写,代价是慢、且不能在事务块里跑(17.3.5 有实测报错)。 - 回填要分批。一条
UPDATE ... SET new = old更新全表会持有一个长事务,撑大 WAL、拖长锁等待。改成分批(每次几百到几千行、每批提交),把长事务拆成一串短事务。
17.3.4 真跑:五阶段完整演练
连本机容器真跑。先起 Postgres(读者自行执行;本机端口 15435,不是默认 5432):
docker run -d --name ab-pg -e POSTGRES_PASSWORD=postgres \
-e POSTGRES_DB=research -p 15435:5432 postgres:17-alpine
连接串 postgresql://postgres:postgres@localhost:15435/research,本机实测 PostgreSQL 17.11、psycopg 3.3.6。用一张临时表演练(避免动到真实表),脚本用 autocommit=True 逐阶段执行并查询验证:
import psycopg
DSN = "postgresql://postgres:postgres@localhost:15435/research"
T = "migr_demo_17_3"
with psycopg.connect(DSN, autocommit=True) as conn, conn.cursor() as cur:
cur.execute(f"CREATE TABLE {T} (id int PRIMARY KEY, legacy_status text NOT NULL)")
cur.execute(f"INSERT INTO {T} VALUES (1,'open'),(2,'closed'),(3,'open'),(4,'pending'),(5,'closed')")
# ① expand:加可空新列
cur.execute(f"ALTER TABLE {T} ADD COLUMN status text")
# ② dual-write:INSERT/UPDATE 同时写两列
cur.execute(f"INSERT INTO {T}(id,legacy_status,status) VALUES (6,'open','open')")
cur.execute(f"UPDATE {T} SET legacy_status='closed', status='closed' WHERE id=3")
# ③ backfill:分批回填
while True:
cur.execute(f"UPDATE {T} SET status=legacy_status WHERE status IS NULL "
f"AND id IN (SELECT id FROM {T} WHERE status IS NULL LIMIT 2)")
if cur.rowcount == 0: break
# ④ switch-read:对比两列
old = cur.execute(f"SELECT id,legacy_status FROM {T} ORDER BY id").fetchall()
new = cur.execute(f"SELECT id,status FROM {T} ORDER BY id").fetchall()
assert old == new
# ⑤ contract:删旧列
cur.execute(f"ALTER TABLE {T} DROP COLUMN legacy_status")
cur.execute(f"DROP TABLE {T}")
真跑输出(本机实测):
阶段 0:旧 schema 就位
初始数据: [(1, 'open'), (2, 'closed'), (3, 'open'), (4, 'pending'), (5, 'closed')]
阶段 1 expand:ALTER TABLE ADD COLUMN status text(可空)
旧代码 SELECT legacy_status 仍工作: [(1, 'open')]
新列当前为空: [(1, None), (2, None)]
阶段 2 dual-write:应用 INSERT/UPDATE 同时写两列
双写后: [(1,'open',None),(2,'closed',None),(3,'closed','closed'),(4,'pending',None),(5,'closed',None),(6,'open','open')]
阶段 3 backfill:分批把旧列的值搬进新列
回填一批,本批 2 行
回填一批,本批 2 行
共回填 4 行;空值剩余: 0
阶段 4 switch-read:新代码读 status,旧代码读 legacy_status
两列一致: True
阶段 5 contract:DROP COLUMN legacy_status(此时旧代码已下线)
剩余列: ['id', 'status']
清理:已 DROP TABLE migr_demo_17_3
逐阶段读这份输出,五阶段的安全边界一目了然:
- 阶段 1 后旧代码仍能查
legacy_status([(1, 'open')]),证明 expand 没破坏旧版——新列只是「多出来一列空的」。 - 阶段 2 双写后,
id=6的新行两列都有值((6,'open','open')),id=3被改成两列都closed;而id=1/2/4/5这些历史行新列仍是None——这正是为什么必须有阶段 3。 - 阶段 3 回填分两批(每批 2 行)共 4 行,空值归零。分批的意义在真实大表上才完全体现:把一条全表
UPDATE拆成两个短事务,锁持有时间从「整表时长」降到「单批时长」。 - 阶段 4 断言
old == new为 True,证明两列数据完全一致——这是「切读新列安全」的直接证据。没有这条验证就贸然切读,等于在赌数据没搬错。 - 阶段 5 删旧列后只剩
['id', 'status'],迁移完成。演示表用完即删,不留残留。
17.3.5 真跑:DDL 锁与 CREATE INDEX CONCURRENTLY
阶段 ⑤ 和建索引是最容易引发线上事故的两处,用两个真跑看清楚。演示 1:在一个未提交的事务里执行 ALTER TABLE ... ADD COLUMN,同时用另一个连接尝试 INSERT:
演示 1:ALTER TABLE ADD COLUMN 期间,并发 INSERT 被阻塞
A 持有的表锁: [('AccessExclusiveLock', True)]
B 的 INSERT 被阻塞超时: LockNotAvailable -> canceling statement due to lock timeout
A 提交后,B 的 INSERT 成功: 1 行
pg_locks 直接读出 A 持有的是 AccessExclusiveLock——最高级别的表锁,读写全挡。B 的 INSERT 在 lock_timeout(500ms)后失败。这就是「大表 ALTER 会锁表」的机制:只要有一个长查询没结束,ALTER TABLE 就得排队等它,而排队期间它又把后面所有请求挡在门外。
演示 2:CREATE INDEX CONCURRENTLY 不能在事务块里跑:
演示 2:CREATE INDEX CONCURRENTLY 不能在事务块里执行
真实报错: CREATE INDEX CONCURRENTLY cannot run inside a transaction block
autocommit 下建索引成功: ['lock_demo_17_3_pkey', 'idx_lock_demo']
真实报错原文是 CREATE INDEX CONCURRENTLY cannot run inside a transaction block——这是 Postgres 的硬约束,因为它内部要在多个事务间来回切换。所以迁移工具(如 Alembic)里写 CONCURRENTLY 时,必须让这条语句跑在 autocommit 模式下,否则整个迁移会失败。很多团队第一次用 CONCURRENTLY 就栽在这里。
两段演示合起来给出一条实操纪律:凡是要在大表上跑的 DDL,先问两个问题——它取什么锁?它要跑多久? 取 AccessExclusiveLock 且要跑很久的,一律拆:加列拆成可空加列、建索引拆成 CONCURRENTLY、回填拆成分批。
17.3.6 迁移清单表
把常见迁移操作按「可逆性 / 停机 / 回滚方式」列成一张表,发布前逐条过:
| 操作 | 是否需停机 | 可逆性 | 回滚方式 | 注意 |
|---|---|---|---|---|
| 加可空列 | 否 | 高 | DROP COLUMN | 常量默认值不重写表 |
加 NOT NULL 列 | 否(分步) | 中 | 先去掉约束 | 先加可空、回填、再加约束 |
| 双写 | 否 | 高 | 停双写 | 应用层,与 schema 解耦 |
| 分批回填 | 否 | 高 | 重跑 | 控制每批行数 |
| 建索引 | 否 | 高 | DROP INDEX | 用 CONCURRENTLY(须 autocommit) |
| 改列类型 | 视情况 | 中 | 反向转换 | 可能重写表,常拆成「加新列 + 回填 + 切读」 |
| 删列(contract) | 否 | 低 | 从备份恢复 | 必须等旧代码全下线 |
| 删表 | 否 | 低 | 从备份恢复 | 同上,且要确认无外键引用 |
表里最该盯的是最后两行的「可逆性:低」——删列和删表是不可逆的。前面所有阶段都留了后路,唯独 contract 一旦执行,旧数据就没了。所以 contract 是整条迁移里唯一需要「确认旧版代码已 100% 下线」才敢做的一步。工程上的做法是:contract 与前四个阶段隔一个发布周期,让「旧代码彻底消失」这件事有足够时间被确认。
17.3.7 迁移的版本化与发布顺序
手工跑五阶段演示能讲清机制,但真实项目里迁移必须版本化——每次 schema 变更都是一个有编号的迁移脚本,与代码一起提交、按序执行。常见工具是 Alembic(配 SQLAlchemy)或手写迁移脚本。三条纪律:
- 迁移脚本与代码同仓库、同提交。schema 和应用代码的变更必须原子提交,否则会出现「代码期待新列、迁移还没跑」的错位。
- 上线前用
--sql干跑。alembic upgrade head --sql会把要执行的 SQL 打印出来而不真跑,让你先审一遍——尤其是CONCURRENTLY这类不能在事务里跑的语句,干跑能提前暴露问题。 - 迁移要可重入。迁移脚本可能因中断而重跑,所以每一步都应是幂等的(
ADD COLUMN IF NOT EXISTS、回填用WHERE status IS NULL限定)。
更重要的是发布顺序。expand–contract 五阶段不是一次部署完成的,它跨三个发布周期:
| 发布周期 | schema 动作 | 应用动作 |
|---|---|---|
| 第 1 次 | expand(加列) | 无 |
| 第 2 次 | 无 | 上线双写 + 切读的新代码 |
| 第 3 次 | contract(删列) | 旧代码已下线 |
顺序的原则是:schema 变更永远先行或后随于代码,绝不与代码「同时生效」。先加列(旧代码无感),等新代码稳定了再删列(此时旧代码已消失)。把 schema 与代码解耦成两次独立部署,是「不中断」在时间维度上的含义。
17.3.8 多实例并发下的迁移
真实服务是多实例的(多个 Pod、多台机器),这会引入演示里看不到的问题:多个实例同时启动、同时尝试跑迁移。
两个实例同时执行 ALTER TABLE ... ADD COLUMN status,第二个会报 column "status" already exists 而崩溃;两个实例同时回填,可能重复搬运同一批行。防护手段有两条:
| 并发问题 | 症状 | 防护 |
|---|---|---|
| 多实例同时建列 / 建索引 | 第二个报 already exists | 迁移加 IF NOT EXISTS;或用分布式锁 |
| 多实例同时回填 | 重复搬运、行数对不上 | 回填用 WHERE ... IS NULL 天然幂等 |
| 迁移与流量并发 | DDL 锁把在线请求堵死 | 迁移取锁用短 lock_timeout,失败即重试 |
最稳妥的是只让一个实例跑迁移。Postgres 提供了 advisory lock 来保证这件事——用一个全局锁 key 把「谁有权跑迁移」串行化:
with conn.cursor() as cur:
cur.execute("SELECT pg_try_advisory_lock(42)") # 42 是迁移专用 key
got = cur.fetchone()[0]
if not got:
print("另一个实例正在迁移,本实例跳过")
else:
run_migrations(cur) # 持锁执行
pg_try_advisory_lock 立即返回真假,不阻塞——拿不到锁的实例直接跳过,让拿到的那个独占执行。这条锁是「多实例下迁移只跑一次」的标准解法,比「约定好谁跑」可靠得多,因为 Pod 是被调度器随机起停的,你没法约定谁先谁后。
17.3.9 验收与真实环境
本节把 schema 变更从「一次危险动作」拆成「五步安全编排」:
- 不能直接 DROP:灰度期间新旧代码并存,任何一步都不能弄坏另一版。
- expand–contract 五阶段:加可空列 → 双写 → 分批回填 → 切读 → 删列,每步都可回滚。
- 真跑五阶段:在 Postgres 17.11 上完整走完,断言
old == new为 True,演示表用完即删。 - 锁与并发索引:实测
ALTER TABLE持AccessExclusiveLock阻塞并发写;CREATE INDEX CONCURRENTLY不能在事务块里跑。
验收清单:
- 迁移拆成了 expand–contract 五阶段,每阶段单独可回滚
- 回填是分批的,不是一条全表
UPDATE - 大表索引用
CONCURRENTLY,且跑在 autocommit 下 - 删列 / 删表前确认旧版代码已全部下线,且与前四阶段隔一个发布周期
真实环境 / 真实模型下会怎样:本节的演练是在一张 6 行的临时表上做的,真跑验证的是机制正确性,不是性能。真实大表上,五阶段的每一处都会放大:回填几百万行要跑几小时(必须分批 + 断点续跑)、CREATE INDEX CONCURRENTLY 在大表上可能跑十几分钟且中途失败会留下 INVALID 索引需要清理、ADD COLUMN 等锁可能等到超时。此外真实迁移要落进版本化迁移工具(Alembic / 手写迁移脚本),每次上线前用 --sql 干跑一遍确认生成的 SQL,并在影子库上先演练一次。无论工具多好,「每阶段都保持新旧代码可跑」这条原则不变——它才是「不中断」三个字的全部含义。想回看灰度是怎么让新旧并存的,翻 17.2 灰度与回滚
;想回看 Postgres 在 Agent 服务里的用法,翻 5.1 schema 与迁移
。
阅读导航:上一节:17.2 灰度与回滚:新提示/新模型小流量 · 下一节:18.1 容量与架构评审清单 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。