AI 生成的迁移本地能跑、上生产就炸 —— 排查与修复

AI 写的迁移在空 dev 库里干净通过,到生产却卡死或触发约束错误。关键是 dev-prod 一致性与迁移安全审查。

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 输出迁移前逐项确认。
  • squawkpgroll(Postgres)或 gh-ost(MySQL)等工具对迁移做静态检查。
  • CI 强制对接近生产副本的克隆跑迁移。把生产专属的失败拦在本地是 ROI 最高的关卡。
  • 在数据库角色层强制 statement_timeoutlock_timeout
  • 把 expand-and-contract 写成全项目通行规则。AI 一旦在你迁移历史里见到这种模式,后续就会自动模仿。
  • 迁移保持小而幂等:一个文件只做一件事。AI 倾向把多个变更塞一起,review 时拒掉。

常见误区

  • 让 AI 写的迁移在空 dev 库上跑过就宣布”测过了”。
  • 默认 AI 知道 CONCURRENTLYNOT 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