《AI Agent 工程实战》17.3 版本与迁移:不中断 schema 迁移演练

灰度让新旧两版代码同时在跑,于是 schema 变更不能一删了事。本节讲 expand–contract 五阶段迁移法,并在本机 Postgres 17.11 上用 psycopg 真跑一遍:加新列、双写、回填、切读、删旧列,每阶段查询验证新旧两列一致,另演示 DDL 锁表与 CREATE INDEX CONCURRENTLY。

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 容量与架构评审清单 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「agent」更多文章

  1. 《AI Agent 高级编程》目录
  2. 《AI Agent 高级编程》11.3 全景回顾:从零到框架的完整路径与展望
  3. 《AI Agent 高级编程》11.2 真实 API 冒烟:假模型 vs 真模型量化差距