部署到一半,迁移卡在 ALTER TABLE orders ADD COLUMN ... 不动了。CPU 平的、没有报错。Postgres 的大多数 DDL 要 ACCESS EXCLUSIVE 锁,只要还有别的事务摸过这张表——哪怕是分析师一条懒得提交的 SELECT,或者一个 pg_dump——就得排队。更糟的是,迁移在等锁的时候,后续所有查询都排在它后面卡住。修法是先定位是谁挡着、kill 掉,然后加 lock_timeout 重跑,让以后的迁移失败得干脆。
常见原因
按踩坑频率排序。
1. 长事务持有竞争锁
一个报表查询、忘关的 psql 会话、ORM 没收尾的事务,正持有目标表的 ROW EXCLUSIVE 或 ACCESS SHARE。
怎么判断:pg_stat_activity 里能看到一条对同一张表跑了好久的语句。
2. pg_dump 正在跑
pg_dump 整个 dump 期间对每张表都拿 ACCESS SHARE。ALTER TABLE 只能等。
怎么判断:pg_stat_activity 里有 application_name = 'pg_dump' 的会话。
3. 同一张表的 autovacuum
普通 autovacuum 持的锁不阻塞查询,但阻塞 DDL。更糟的是 VACUUM FULL 或 CLUSTER 自己就要 ACCESS EXCLUSIVE。
怎么判断:pg_stat_activity.query 显示 autovacuum: VACUUM table。
4. 应用层 idle in transaction
谁 BEGIN 了再没关——常见于调试器、或连接断开但没正常 close。
怎么判断:state = 'idle in transaction' 已经好几分钟了。
5. DDL 不是被挡,是真在干活
ADD COLUMN ... DEFAULT non_constant(老版本 Postgres)或者 ADD CONSTRAINT ... NOT VALID 然后 VALIDATE CONSTRAINT,都要全表扫描。它没卡,它在跑。
怎么判断:会话状态是 active 不是 waiting;wait_event_type IS NULL。
最短修复路径
Step 1: 定位阻塞者
SELECT
blocked.pid AS blocked_pid,
blocked.query AS blocked_query,
blocking.pid AS blocking_pid,
blocking.usename AS blocking_user,
blocking.application_name,
blocking.state,
age(now(), blocking.xact_start) AS xact_age,
blocking.query AS blocking_query
FROM pg_stat_activity blocked
JOIN pg_stat_activity blocking
ON blocking.pid = ANY(pg_blocking_pids(blocked.pid))
WHERE blocked.query ILIKE 'ALTER TABLE%';
直接告诉你是谁在挡迁移。pg_blocking_pids() 比老式连 pg_locks 的写法准、也轻。
Step 2: 终止阻塞者(小心点)
大多数应用会话可以放心取消:
-- 先 cancel(温柔点)
SELECT pg_cancel_backend(12345);
-- 不放就 terminate
SELECT pg_terminate_backend(12345);
pg_dump 是备份链路的就别瞎 kill——要么暂停迁移让 dump 跑完,要么把迁移排到 dump 之后。
Step 3: 加 lock_timeout 重跑
让以后的迁移失败得干脆、可以重试。
SET lock_timeout = '5s';
SET statement_timeout = '0'; -- DDL 本身可能就要跑很久
ALTER TABLE orders ADD COLUMN shipping_method text;
迁移工具配置(Flyway、Alembic、Prisma):
# alembic.ini 片段——每个迁移前生效
sqlalchemy.connect_args = { "options": "-c lock_timeout=5s" }
-- Flyway:在迁移脚本顶上设
SET lock_timeout TO '5s';
拿不到锁就报 canceling statement due to lock timeout,重试。小步重试比一次长卡好得多。
Step 4: 选更安全的 DDL 写法
大表加列:
-- Postgres 11+:加可空列是纯元数据操作
ALTER TABLE orders ADD COLUMN shipping_method text;
-- 加 NOT NULL 拆两步
ALTER TABLE orders ADD COLUMN shipping_method text;
UPDATE orders SET shipping_method = 'standard' WHERE shipping_method IS NULL; -- 分批
ALTER TABLE orders ALTER COLUMN shipping_method SET NOT NULL;
新索引用 CREATE INDEX CONCURRENTLY(不要 ACCESS EXCLUSIVE):
CREATE INDEX CONCURRENTLY orders_shipping_method_idx
ON orders (shipping_method);
注意:不能在事务里跑;有些迁移工具需要 opt-out(Alembic 用 op.create_index(..., postgresql_concurrently=True) 配合 op.execute("COMMIT"))。
Step 5: 确认迁移落地了
\d+ orders -- 列在
SELECT relname, n_live_tup FROM pg_stat_user_tables WHERE relname = 'orders';
SELECT * FROM pg_locks WHERE relation = 'orders'::regclass; -- 没有遗留锁
预防
- 每个迁移都设
lock_timeout(2-30 秒)且可重试。 - 把迁移排在备份和重报表窗口之外。
- 用
CREATE INDEX CONCURRENTLY;大表别用ADD COLUMN ... DEFAULT non_constant。 - 对
idle in transaction超过 60 秒告警——这是常见嫌疑人。 - 高风险迁移先在生产数据量级的影子库上跑一次(
pg_dump+pg_restore到 staging)。