AI 写了一个迁移,给 users 加一个 NOT NULL 列。在你电脑上几乎是空表,80ms 跑完;staging 12 秒;生产环境吊了 9 分钟,最后报 null value in column "country" violates not-null constraint。这个迁移假设的是一个根本不存在的世界:所有已有行都填好了这一列、没有长事务持有锁、表里只有 50 行而不是 5000 万行。
最快的修复: 在生产上动手之前,先把一份最近的生产备份还原到一个一次性数据库里,对它跑一遍一模一样的迁移。几乎所有 AI 生成的迁移 bug,一碰到真实行数和真实历史数据就会立刻暴露。如果在克隆库上通过了,再用下面的安全模式重写有风险的语句(常量 default 的加列、CREATE INDEX CONCURRENTLY、约束用 NOT VALID + VALIDATE、rename 走 expand-and-contract),并设一个 lock_timeout,让卡住的迁移干净地 abort,而不是把整个应用堵死。
AI 生成的迁移特别危险:它读起来干净又自信,而 dev 环境会骗你。生产 schema 里有遗留数据、半成品 backfill、历史约束,以及那个能把 0.1 秒操作变成 30 分钟事故的表规模。
先对号入座
把你看到的症状对上,找到原因,直接跳到对应修复。
| 你看到的症状 | 可能的原因 | 跳转 |
|---|---|---|
null value in column "X" violates not-null constraint | 加 NOT NULL 列却没 backfill | 原因 1 / 第 2 步 |
| 迁移”吊”几分钟、全站延迟飙升 | 长时间持有 ACCESS EXCLUSIVE 锁 | 原因 2 / 第 7 步 |
| 索引构建期间应用写超时 30 分钟以上 | CREATE INDEX 没用 CONCURRENTLY | 原因 3 / 第 3 步 |
check constraint "X" is violated by some row | 在违反约束的数据上加约束 | 原因 4 / 第 4 步 |
| 迁移上线后父表查询明显变慢 | 加外键却没给子表列建索引 | 原因 5 / 第 5 步 |
迁移干净通过后应用立刻报 column "X" does not exist | 旧代码还在跑就单步 rename | 原因 6 / 第 6 步 |
| text 列的唯一索引报”按理不该发生”的 duplicate-key | 迁移依赖 dev 的 collation / locale | 原因 7 |
常见原因
按真实事故频次排序。
1. 加 NOT NULL 列时没有 default、也没有 backfill
AI 写出:
ALTER TABLE users ADD COLUMN country text NOT NULL;
Dev:空表,没行可违反约束;Prod:800 万行,瞬间全都 NULL,迁移直接 abort。
如何识别: 迁移期间报 null value in column "X" violates not-null constraint。
2. 在热点表上长时间持有独占锁
Postgres 的 ALTER TABLE 在大多数操作上拿的是 ACCESS EXCLUSIVE 锁。只要表上还有写操作,你的迁移会等它们;新的读和写又排在你的迁移后面。一条 ALTER 就能把整张表堵死。
如何识别: 迁移”吊”几分钟无进展,然后全站延迟飙升。pg_locks 里能看到 blocked / blocking pid。
3. 建索引没用 CONCURRENTLY
AI 写 CREATE INDEX idx_users_email ON users(email);。在 1000 万行的表上,这会全程持锁、阻塞写。在线建索引必须用 CREATE INDEX CONCURRENTLY。
如何识别: 索引构建期间应用写超时;迁移耗时 30 分钟以上。
4. 加约束时已有数据违反它
ALTER TABLE orders ADD CONSTRAINT chk_amount_positive CHECK (amount > 0); 在 dev 通过,因为没有 amount <= 0 的行。Prod 里有 47 条历史 amount = 0 的订单。迁移失败。
如何识别: 迁移步骤报 check constraint "X" is violated by some row。
5. 加外键却没给引用列建索引
AI 在 orders.user_id 上加了引用 users.id 的 FK。Postgres 不会自动给引用列建索引——只有被引用的主键那一侧才自带索引。如果 orders.user_id 没索引,每次在 users 上删行或改行都要在 orders 上做全表扫描。迁移成功;接下来生产里 DELETE FROM users WHERE id = ? 要花 4 分钟。
如何识别: 迁移上线后,涉及父表的查询明显变慢;EXPLAIN 在子表上显示 sequential scan。
6. 应用还在跑的时候直接单步 RENAME 列
ALTER TABLE users RENAME COLUMN name TO full_name; 在 dev 行得通,因为没人在请求。生产里旧代码仍在跑、仍在引用 users.name,迁移一 commit 就崩。AI 没生成多阶段的安全 rename。
如何识别: 迁移成功,应用立刻开始抛 column "name" does not exist。
7. 迁移依赖默认 collation / 时区 / 编码
Dev 是 en_US.UTF-8,Prod 是 C 或某个老 locale。排序、不区分大小写比较、text vs varchar 行为都不同,AI 生成的查询恰好依赖了 dev 的 locale 行为。
如何识别: 迁移跑过了,但 text 列上的唯一索引报 duplicate-key——“按理不该发生”。
开始之前
- 确认实际生产 schema:
pg_dump --schema-only或对应数据库的等价命令。不要相信 ORM model。 - 获取迁移涉及表的行数:
SELECT count(*) FROM <table>。 - 检查目标表上的活动会话与锁。
- 准备并测试过的回滚方案——每个 forward migration 都要有对应的 down migration 或恢复脚本。
- 在生产克隆而不是空白 dev 库上跑迁移。如有必要可以脱敏,但行数和约束历史必须保留。
需要收集的信息
- AI 生成的完整迁移 SQL。
- 受影响表的生产 schema:
pg_dump --schema-only -t <table>。 - 行数及 NULL 分布:
SELECT count(*), count(<column>) FROM <table>。 - 业务时段未授予的锁:
SELECT * FROM pg_locks WHERE not granted。 - 目标表上现有的约束、索引、触发器。
- 应用是零停机(滚动)部署,还是迁移时全停。
分步修复
按”先防止当前事故,再做长期加固”排序。
第 1 步:在生产克隆上跑一遍迁移
pg_dump prod > prod.dump
createdb prod_clone
pg_restore -d prod_clone prod.dump
psql prod_clone -f migrations/2026_05_add_country.sql
在这里失败,就是在出 prod 事故前抓到了。AI 迁移的大部分 bug 在真实数据量 + 真实约束下立刻暴露。条件允许就跑两遍:一遍确认成功,一遍确认你的 down-migration 能把库恢复到原状。
第 2 步:用安全方式加 NOT NULL 列
“安全”具体怎么做,取决于你的 Postgres 版本和 default 的类型。
常量 default(像 'US'、0、false 这种字面值): 从 Postgres 11 起(到 2026 年 6 月,在用的 14 到 18 都属此列),非 volatile 的 default 只存进表的元数据、读取时才套用,不会重写表,即便在超大表上 ALTER 也几乎是瞬时的。一条语句就安全:
ALTER TABLE users ADD COLUMN country text NOT NULL DEFAULT 'US';
Volatile default(now()、gen_random_uuid()、按行计算的值),或你必须从已有数据 backfill: 没有常量可存,单条语句会在独占锁下重写每一行。拆成三步:
-- Phase 1: 先加可空列(瞬时,不重写)
ALTER TABLE users ADD COLUMN country text;
-- Phase 2: 分批 backfill,别让单个事务太大
UPDATE users SET country = 'US'
WHERE country IS NULL AND id BETWEEN 1 AND 100000;
-- 用循环、后台 job 或 psql \watch 反复跑
-- Phase 3: backfill 完成后才强制 NOT NULL
ALTER TABLE users ALTER COLUMN country SET NOT NULL;
Phase 3 默认仍会全表扫描。要在大表上连这次扫描都省掉,先加一个已 validate 的 CHECK——Postgres 会据此证明非空、无需再扫:
ALTER TABLE users ADD CONSTRAINT users_country_not_null
CHECK (country IS NOT NULL) NOT VALID;
ALTER TABLE users VALIDATE CONSTRAINT users_country_not_null; -- SHARE UPDATE EXCLUSIVE,不阻塞写
ALTER TABLE users ALTER COLUMN country SET NOT NULL; -- 已有效的 CHECK 已证明,跳过扫描
ALTER TABLE users DROP CONSTRAINT users_country_not_null; -- 可选清理
第 3 步:大表建索引一律 CONCURRENTLY
CREATE INDEX CONCURRENTLY idx_users_email ON users(email);
CONCURRENTLY 不阻塞写。代价是更慢,而且不能放进事务——如果你的迁移框架(Rails、大多数 ORM)会把每个迁移包进事务,就把这条单独放进一个非事务迁移。CONCURRENTLY 中途失败会留下一个 INVALID 索引:用 SELECT * FROM pg_index WHERE NOT indisvalid; 找出来,DROP 掉再重试。
第 4 步:加约束前先验证现有数据
加之前:
ALTER TABLE orders ADD CONSTRAINT chk_amount_positive CHECK (amount > 0);
先跑:
SELECT count(*) FROM orders WHERE NOT (amount > 0);
非零就要决定:修数据、用 NOT VALID 豁免历史、或只对新行检查。用两步模式,让初次添加不必在扫描全表时一直持有独占锁:
ALTER TABLE orders ADD CONSTRAINT chk_amount_positive CHECK (amount > 0) NOT VALID;
-- backfill 或修复违规的历史行
ALTER TABLE orders VALIDATE CONSTRAINT chk_amount_positive;
加 CHECK 时带上 NOT VALID 会跳过初始扫描,因此只需短暂持有 ACCESS EXCLUSIVE 锁,而不是在整个扫描期间一直持有;VALIDATE 之后再扫,持的是 SHARE UPDATE EXCLUSIVE 锁,不阻塞读写。
第 5 步:加外键前先给引用列建索引
CREATE INDEX CONCURRENTLY idx_orders_user_id ON orders(user_id);
ALTER TABLE orders ADD CONSTRAINT fk_orders_user
FOREIGN KEY (user_id) REFERENCES users(id) NOT VALID;
ALTER TABLE orders VALIDATE CONSTRAINT fk_orders_user;
AI 几乎从不主动做两件事:给引用列建索引(Postgres 不会替你建),以及用 NOT VALID / VALIDATE 拆开,让加外键时不必在扫描两张表期间一直持锁。
第 6 步:rename 改成 expand-and-contract
千万别在旧代码运行时直接 RENAME COLUMN。改成三个 deploy:
-- Deploy 1: 加新列,应用层双写
ALTER TABLE users ADD COLUMN full_name text;
-- 应用代码同时写 `name` 与 `full_name`
-- Deploy 2 (later): 读 full_name,停止写 `name`
-- Deploy 3 (later): 丢弃旧列
ALTER TABLE users DROP COLUMN name;
显式让 AI 生成 expand-and-contract 版本,否则它默认给你一个破坏性的单步 rename。同一套形态(加列、双写、backfill、切读、删列)也适用于改类型和拆列。
第 7 步:给迁移设 lock_timeout
SET lock_timeout = '5s';
SET statement_timeout = '5min';
如果迁移在 lock_timeout 内拿不到锁,它会干净地 abort,而不是排在某条长查询后面、把后续每个请求都堵住。挑空闲时段重试即可。一个短的 lock_timeout 是防止 ALTER TABLE 把整张表拖垮最有效的单一手段。大多数迁移框架都暴露了这个能力(Rails 用 disable_ddl_transaction! 加单迁移超时,Prisma、Flyway 走会话设置,strong_migrations 则默认强制)。
如何确认已修复
- 迁移在生产克隆 + 真实数据量下端到端跑过。
- 新增的约束或索引没让任何查询计划退化:对重点查询跑
EXPLAIN (ANALYZE, BUFFERS),确认没有意外的 sequential scan。 - 强制非空的新列里
NULL行数为 0:SELECT count(*) FROM <table> WHERE <col> IS NULL返回 0。 - 迁移期间应用健康指标(延迟、错误率、锁等待)都在正常范围。
- down-migration / rollback 路径已测试,且确实能恢复到原来的 schema。
长期预防
- 在
CLAUDE.md(或你 AI 工具的规则文件)里维护一份”迁移安全 checklist”,要求 AI 输出迁移前逐项确认。 - 在 CI 里跑迁移 linter。squawk 能静态揪出上面这些模式(缺
CONCURRENTLY、缺NOT VALID、不安全的 NOT NULL 加列);pgroll 能在 Postgres 上替你跑 expand-and-contract 迁移;gh-ost负责 MySQL 的在线变更。 - 始终对接近生产的克隆在 CI 里跑迁移。把生产专属的失败拦在部署前,是 ROI 最高的关卡。
- 在数据库角色层(而不只是会话层)强制
statement_timeout与lock_timeout。 - 把 expand-and-contract 写成全项目通行规则。AI 一旦在你迁移历史里见到这种模式,后续就会自动模仿。
- 迁移保持小而幂等:一个文件只做一件事。AI 倾向把 schema 变更和数据 backfill 塞进同一个大表迁移,review 时拒掉。
常见误区
- 让 AI 写的迁移在空 dev 库上跑过就宣布”测过了”。
- 默认 AI 知道
CONCURRENTLY、NOT VALID、expand-and-contract——除非显式要求,它通常并不会用。 - 以为给
ADD COLUMN加任何DEFAULT都会重写表。到 2026 年 6 月为止,常量 default 在 Postgres 11+ 上只改元数据、瞬时完成;只有 volatile default(或 11 之前的版本)才会强制全表重写。分清这点能让你省下一次本不必要的三阶段迁移。 - 跳过”加外键前先建索引”,理由是”FK 不就自带索引吗”——Postgres 只在被引用的主键一侧自带索引,引用列那一侧从来没有。
- 在大表上把 schema 变更和数据 backfill 合在同一个迁移里。拆开。
- 忽视
lock_timeout,眼看着迁移堵住成百上千个查询直到有人手动 kill。 CREATE INDEX CONCURRENTLY失败后留下一个INVALID索引,还以为那列已经建好索引了。
相关问题见 AI 幻觉出了一个文件、AI 删掉了能跑的逻辑、AI 代码弄崩了构建。
FAQ
Q:AI 写的迁移”本地通过了”,为什么还不够?
本地 dev 库行数小、没并发、没历史数据——这些在你电脑上统统不存在。迁移大部分翻车都是规模、并发或历史 NULL 引起的,必须对带真实行数和真实约束历史的生产克隆测试。
Q:现在给列加 DEFAULT 到底安不安全,会不会重写表?
到 2026 年 6 月,常量 default(字面值)在所有在用的 Postgres 版本上都安全:值存进 catalog,ALTER 几乎瞬时、不重写。now()、gen_random_uuid() 这类 volatile default 仍会在独占锁下重写每一行——对它们,先加可空列、分批 backfill,再为将来的新行设 default。
Q:能不能直接让 AI”把迁移改成生产安全的”?
有时候管用。但你把具体规则一条条列出来效果更好:索引 CONCURRENTLY、约束 NOT VALID + VALIDATE、NOT NULL 三步加(或用常量 default)、rename 走 expand-and-contract、加 lock_timeout——AI 看到具名的”配方”就会照做,而不是凭感觉猜。
Q:迁移卡在 “waiting for AccessExclusiveLock”,怎么办?
要么用 SELECT pg_cancel_backend(pid) 取消(取消不掉就用 pg_terminate_backend(pid)),挑空闲时段再来;要么靠 lock_timeout 下次干净退出。不要让它阻塞读写好几分钟——连接越堆越多,故障会指数级放大。用 SELECT * FROM pg_locks WHERE not granted 找出谁在挡路。
Q:我的迁移是 ORM 生成的,不是 AI 写的,这套还适用吗?
适用。大多数 ORM 默认是”方便”而不是”在规模下安全”。expand-and-contract、NOT VALID、CONCURRENTLY 这些规则对 ORM 生成的迁移同样适用。用 squawk 或 strong_migrations gem 这类 linter,能在不安全的迁移上生产前就拦下来。
标签: #排查 #AI 编程 #migrations #数据库 #Schema