部署到一半,迁移卡在 ALTER TABLE orders ADD COLUMN ... 不动了。CPU 是平的、日志里没有报错。原因是这样:Postgres 的大多数 DDL 要拿 ACCESS EXCLUSIVE 锁——这是最强的一种锁,它必须排在任何已经摸过这张表的事务后面,哪怕只是分析师一条懒得提交的 SELECT,或者一个正在跑的 pg_dump。最狠的是锁队列:你的 ALTER TABLE 在等锁的时候,所有新进来的查询也排在它后面,连普通读都得排。于是一个卡住的迁移就能把整张表拖下线。
最快的修法: 用 pg_blocking_pids() 找出挡着你的会话(Step 1),cancel 或 terminate 它(Step 2),然后加 SET lock_timeout = '5s' 重跑迁移(Step 3),让下一次尝试失败得干脆、可以重试,而不是再次冻住整张表。下面所有示例都按 Postgres 18 验证过(截至 2026 年 6 月为当前版本);这套锁行为从 Postgres 11 起就没变过。
你属于哪一类?
先跑 Step 1 的诊断查询,再对照症状:
pg_stat_activity 里的症状 | 大概率原因 | 处理 |
|---|---|---|
另一个会话 xact_start 很老、同一张表 | 长事务 / 忘关的 psql | cancel 掉(Step 2) |
application_name = 'pg_dump' | 并发备份在持 ACCESS SHARE | 等它跑完或改期——别瞎 kill |
query 以 autovacuum: VACUUM 开头 | 表上的 autovacuum / VACUUM FULL | 让它跑完,或设维护窗口 |
state = 'idle in transaction' 好几分钟 | 应用 BEGIN 了但没提交 | terminate 掉(Step 2) |
你的 ALTER 是 active、wait_event_type 为 null | 没被挡——DDL 在真干活 | 等着;下次按 Step 4 避免重写 |
常见原因
按踩坑频率排序。
1. 长事务持有竞争锁
一个报表查询、忘关的 psql 会话、或 ORM 没收尾的事务,正持有目标表的 ROW EXCLUSIVE 或 ACCESS SHARE。ACCESS EXCLUSIVE 和所有其他锁模式都冲突,所以你的 ALTER TABLE 只能等。
怎么判断:pg_stat_activity 里有一条对同一张表跑了很久、xact_start 很老的语句。
2. pg_dump 正在跑
pg_dump 在整个 dump 期间,对它读到的每张表都持 ACCESS SHARE。ALTER TABLE 只能等它结束。
怎么判断:pg_stat_activity 里有 application_name = 'pg_dump' 的会话。
3. 同一张表的 autovacuum
普通 autovacuum 持的是 SHARE UPDATE EXCLUSIVE 锁——弱到不挡读写,但和 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 <volatile>(比如 clock_timestamp())、一个 stored generated 列、identity 列,或者 ADD CONSTRAINT ... NOT VALID 之后再 VALIDATE CONSTRAINT,都会重写或扫描整张表。它没卡,它在跑。
怎么判断:会话状态是 active 不是在等锁,而且 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()(Postgres 9.6+)比老式自连 pg_locks 的写法准、也轻得多。记下 blocking_pid、application_name 和 state——它们对应上面表格里你属于哪一行。
Step 2: 终止阻塞者(小心点)
对普通应用会话或分析师会话,先 cancel,不放再 terminate:
-- 先 cancel(只取消当前查询,连接还在)
SELECT pg_cancel_backend(12345);
-- 还不放锁(比如 idle in transaction),就 terminate 整个 backend
SELECT pg_terminate_backend(12345);
pg_cancel_backend 只能掐掉正在跑的语句,清不掉 idle in transaction 的会话,那种得用 pg_terminate_backend。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;
在迁移工具里按会话设:
# alembic.ini——每个迁移前生效
sqlalchemy.connect_args = { "options": "-c lock_timeout=5s" }
-- Flyway / 裸 SQL:在迁移脚本最上面设
SET lock_timeout TO '5s';
如果限定时间内拿不到锁,语句会以下面这条中止:
ERROR: canceling statement due to lock timeout
Postgres 不会自己重试——这得靠你或你的迁移工具。把迁移包进一个带指数退避的重试循环里(大多数工具会替你做,比如 pgroll、gh-ost 那类)。一串短重试比一次长卡好得多,因为两次尝试之间锁队列会排空,你的读也能一直走。lock_timeout 要短;繁忙系统常用 2 秒以内。
Step 4: 选更安全的 DDL 写法
ADD COLUMN 是快是慢,取决于默认值,不取决于是否可空:
-- Postgres 11+:常量 / 非 volatile 的 DEFAULT 是纯元数据操作,瞬间完成,
-- 哪怕表很大也一样。now() 在这里算非 volatile。
ALTER TABLE orders ADD COLUMN created_at timestamptz DEFAULT now();
-- 危险:volatile 的 DEFAULT 会在 ACCESS EXCLUSIVE 下重写整张表 + 所有索引。
--(clock_timestamp()、random()、逐行 gen_random_uuid()、stored generated 列、
-- identity 列、带约束的 domain 类型,都会触发重写。)
ALTER TABLE orders ADD COLUMN token uuid DEFAULT clock_timestamp(); -- 大表别这么干
大表加 NOT NULL 拆成三步小操作,让任何一条语句都不重写整表:
ALTER TABLE orders ADD COLUMN shipping_method text; -- 1. 先加可空列
UPDATE orders SET shipping_method = 'standard' WHERE shipping_method IS NULL; -- 2. 分批回填
ALTER TABLE orders ALTER COLUMN shipping_method SET NOT NULL; -- 3. 再加约束
新索引用 CREATE INDEX CONCURRENTLY,这样建索引全程都不拿 ACCESS EXCLUSIVE:
CREATE INDEX CONCURRENTLY orders_shipping_method_idx
ON orders (shipping_method);
有两个坑要注意:
CREATE INDEX CONCURRENTLY不能在事务块里跑。有些迁移工具要 opt-out(Alembic 用op.create_index(..., postgresql_concurrently=True),并让该迁移走 autocommit)。- 如果它中途失败(包括
lock_timeout),会留下一个 invalid(无效) 索引。下次重试就会报relation "..._idx" already exists。重试前先把残留的删掉:
-- 找出无效索引
SELECT indexrelid::regclass AS idx FROM pg_index WHERE indisvalid = false;
DROP INDEX CONCURRENTLY orders_shipping_method_idx;
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; -- 没有遗留的 ACCESS EXCLUSIVE
SELECT pid, state, age(now(), xact_start)
FROM pg_stat_activity
WHERE state = 'idle in transaction'
ORDER BY xact_start; -- 没有新的滞留事务
如果 \d+ 里能看到那列、orders::regclass 上没有遗留锁、应用对这张表的查询也恢复正常返回,那迁移就完成了,队列也排空了。
预防
- 每个迁移都设
lock_timeout(繁忙系统常用 2 秒以内,错峰 DDL 可放到 30 秒左右)且可重试。 - 在数据库或角色级别设
idle_in_transaction_session_timeout(比如'60s'),让被遗弃的事务自动被 kill——它们是最常见的阻塞源。 - 把迁移排在备份和重报表窗口之外。
- 用
CREATE INDEX CONCURRENTLY,并用常量默认值;大表绝不要ADD COLUMN ... DEFAULT <volatile>。 - 高风险迁移先在生产数据量级的影子库上跑一遍(
pg_dump+pg_restore到 staging),顺便测一下锁窗口有多长。
常见问题
kill 掉阻塞会话会不会把数据搞坏?
不会。pg_cancel_backend 中止正在跑的查询,pg_terminate_backend 干净地回滚整个事务——Postgres 是事务性的,不会留下半提交的状态。风险在应用层:你取消了别人的活,他们可能看到报错然后重试。
我能不能不用 lock_timeout,直接调小 statement_timeout?
不行。statement_timeout 限的是语句总运行时间,那会把一条正当的长 DDL(大重写)也掐掉。lock_timeout 只限等待拿锁的时间,这正是你要约束的那段卡顿。DDL 本身的 statement_timeout 留 0(或给宽松些)。
我的 ALTER TABLE 是 active、不是在等锁,为什么还慢?
它没被挡——它在重写或扫描整表(volatile 默认值、stored generated 列、VALIDATE CONSTRAINT 等等)。见 Step 4。这时取消它只是白费已做的工,应该改写迁移以避开重写。
重试迁移为什么报 relation "..._idx" already exists?
之前一次 CREATE INDEX CONCURRENTLY 失败了,留下一个无效索引。把它删掉(DROP INDEX CONCURRENTLY ...)再重跑。用 pg_index WHERE indisvalid = false 能查出这些。
加可空列是不是一定瞬间完成?
当默认值是常量 / 非 volatile(包括 now())时,加列就是瞬间的——这从 Postgres 11 起就是纯元数据操作。决定快慢的不是可空与否,而是默认值是否 volatile。