线上 Postgres 迁移卡在 ALTER TABLE 一动不动

ALTER TABLE 迁移在生产卡死。用 pg_blocking_pids 找出阻塞者、终止它,再加 lock_timeout 重跑——让迁移失败得干脆而不是一直卡。

部署到一半,迁移卡在 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 很老、同一张表长事务 / 忘关的 psqlcancel 掉(Step 2)
application_name = 'pg_dump'并发备份在持 ACCESS SHARE等它跑完或改期——别瞎 kill
queryautovacuum: VACUUM 开头表上的 autovacuum / VACUUM FULL让它跑完,或设维护窗口
state = 'idle in transaction' 好几分钟应用 BEGIN 了但没提交terminate 掉(Step 2)
你的 ALTERactivewait_event_type 为 null没被挡——DDL 在真干活等着;下次按 Step 4 避免重写

常见原因

按踩坑频率排序。

1. 长事务持有竞争锁

一个报表查询、忘关的 psql 会话、或 ORM 没收尾的事务,正持有目标表的 ROW EXCLUSIVEACCESS SHAREACCESS EXCLUSIVE 和所有其他锁模式都冲突,所以你的 ALTER TABLE 只能等。

怎么判断pg_stat_activity 里有一条对同一张表跑了很久、xact_start 很老的语句。

2. pg_dump 正在跑

pg_dump 在整个 dump 期间,对它读到的每张表都持 ACCESS SHAREALTER TABLE 只能等它结束。

怎么判断pg_stat_activity 里有 application_name = 'pg_dump' 的会话。

3. 同一张表的 autovacuum

普通 autovacuum 持的是 SHARE UPDATE EXCLUSIVE 锁——弱到不挡读写,但和 DDL 冲突。更糟的是 VACUUM FULLCLUSTER 自己就要 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_pidapplication_namestate——它们对应上面表格里你属于哪一行。

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_backendpg_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 TABLEactive、不是在等锁,为什么还慢? 它没被挡——它在重写或扫描整表(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。

标签: #后端 #排查 #迁移