AI 写了一个迁移,给 users 加一个 NOT NULL 列。在你电脑上几乎是空表,80ms 跑完;staging 12 秒;生产环境吊了 9 分钟,最后报 null value in column "country" violates not-null constraint。这个迁移假设的是一个不存在的世界:所有已有行都填好了这一列、没有长事务持有锁、表里有 50 行而不是 5000 万行。AI 生成的迁移特别危险——读起来干净自信,而 dev 环境会骗你。生产 schema 里有遗留数据、半成品 backfill、历史约束,以及那个能把 0.1 秒操作变成 30 分钟事故的表规模。
常见原因
按真实事故频次排序。
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 锁。只要表上还有写操作,你的迁移会等它们;它们等你;读也跟着堆积。
如何识别:迁移”吊”几分钟无进展,然后全站延迟飙升。pg_locks 里能看到 blocked / blocking pid。
3. 建索引没用 CONCURRENTLY
AI 写 CREATE INDEX idx_users_email ON users(email);。在 1000 万行的表上,这会全程锁表。在线建索引必须 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。如果 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 < migrations/2026_05_add_country.sql
在这里失败,就是在出 prod 事故前抓到了。AI 迁移的大部分 bug 在真实数据量 + 约束下立刻暴露。
第 2 步:把 NOT NULL 列添加改成两步安全模式
把:
ALTER TABLE users ADD COLUMN country text NOT NULL;
换成:
-- Phase 1: nullable add with default for new rows
ALTER TABLE users ADD COLUMN country text;
-- Phase 2: backfill in chunks
UPDATE users SET country = 'US' WHERE country IS NULL AND id BETWEEN 1 AND 100000;
-- (repeat in batches; use \watch or a job)
-- Phase 3: enforce NOT NULL only after backfill complete
ALTER TABLE users ALTER COLUMN country SET NOT NULL;
超过约 1 万行的表都必须用这套模式。
第 3 步:大表建索引一律 CONCURRENTLY
CREATE INDEX CONCURRENTLY idx_users_email ON users(email);
CONCURRENTLY 不阻塞写,代价是更慢且不能放进事务——必要时把迁移拆开。
第 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;
-- 修历史数据
ALTER TABLE orders VALIDATE CONSTRAINT chk_amount_positive;
NOT VALID 跳过初始校验;VALIDATE 之后再校验且不阻塞写。
第 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);
总要确认引用列有可用索引。AI 几乎从不会主动加这一步。
第 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。
第 7 步:给迁移加 statement_timeout
SET statement_timeout = '5min';
SET lock_timeout = '30s';
30 秒内拿不到锁就 abort 重试,总比把应用堵 9 分钟好。Rails、Prisma、Flyway 等迁移框架大多内置了这个能力。
验证
- 迁移在生产克隆 + 真实数据量下端到端跑过。
- 新增的约束或索引没让任何 top-N 查询计划退化——
EXPLAIN一下重点查询。 - 强制非空的新列里
NULL行数为 0:SELECT count(*) FROM <table> WHERE <col> IS NULL。 - 迁移期间应用健康指标(延迟、错误率、锁等待)都在正常范围。
- down-migration / rollback 路径已测试且可用。
长期预防
- 在
CLAUDE.md里维护一份”迁移安全 checklist”,要求 AI 输出迁移前逐项确认。 - 用
squawk、pgroll(Postgres)或gh-ost(MySQL)等工具对迁移做静态检查。 - CI 强制对接近生产副本的克隆跑迁移。把生产专属的失败拦在本地是 ROI 最高的关卡。
- 在数据库角色层强制
statement_timeout与lock_timeout。 - 把 expand-and-contract 写成全项目通行规则。AI 一旦在你迁移历史里见到这种模式,后续就会自动模仿。
- 迁移保持小而幂等:一个文件只做一件事。AI 倾向把多个变更塞一起,review 时拒掉。
常见误区
- 让 AI 写的迁移在空 dev 库上跑过就宣布”测过了”。
- 默认 AI 知道
CONCURRENTLY、NOT VALID、expand-and-contract——除非显式要求,它通常并不会用。 - 觉得给 NOT NULL 列加
DEFAULT就安全——Postgres 11 之前,ALTER TABLE ADD ... DEFAULT会重写整张表。 - 跳过”加外键前先建索引”,理由是”FK 不就自带索引吗”——Postgres 只在被引用 PK 一侧自带索引,引用列那一侧没有。
- 在大表上把 schema 变更和数据 backfill 合在同一个迁移里。拆开。
- 忽视
lock_timeout,眼看着迁移堵住一千个查询直到有人手动 kill。
相关问题见 AI 幻觉出了一个文件、AI 删掉了能跑的逻辑、AI 代码弄崩了构建。
FAQ
Q:AI 写的迁移”本地通过了”,为什么还不够?
本地 dev 库行数小、没并发、没历史数据。迁移大部分翻车都是规模、并发或历史 NULL 引起的,必须对生产克隆测试。
Q:能不能直接让 AI”把迁移改成生产安全的”?
有时候管用。但你把具体规则一条条列出来效果更好:索引 CONCURRENTLY、约束 NOT VALID + VALIDATE、NOT NULL 两步加、rename 走 expand-and-contract——AI 看到具名的”配方”会照着做。
Q:迁移卡在 “waiting for AccessExclusiveLock”,怎么办?
要么 kill 掉(SELECT pg_cancel_backend(pid))挑空闲时段再来,要么靠 lock_timeout 干净退出。不要让它阻塞读写好几分钟——故障会指数级放大。
Q:我的迁移是 ORM 生成的,不是 AI 写的,这套还适用吗?
适用。大多数 ORM 默认是”方便”而不是”在规模下安全”。expand-and-contract、NOT VALID、CONCURRENTLY 这些规则对 ORM 生成的迁移同样适用。
标签: #排查 #AI 编程 #migrations #数据库 #Schema