PostgreSQL 锁与阻塞分析

PostgreSQL 锁与阻塞全解析:8 种表级锁与 4 种行级锁的冲突矩阵、咨询锁、阻塞链与死锁的形成机制、pg_locks / pg_stat_activity / pg_blocking_pids 诊断手法、DDL 排队与热点行更新等典型场景的解法、lock_timeout 与死锁预防策略。

生产环境最典型的「突然全站变慢」往往不是慢查询本身,而是一次锁等待引发的雪崩:一条长事务持有某张表的锁,后续查询排队等待,连接池被占满,健康检查超时,服务开始熔断。整个过程可能只用了十几秒,但根因只是一条没提交的 UPDATE 或一个排队的 ALTER TABLE。

PostgreSQL 的锁机制是多粒度、多模式的:表级锁有 8 种模式,行级锁有 4 种,它们之间的冲突关系决定了哪些操作会互相阻塞。理解这套体系,配合 pg_locks 与 pg_blocking_pids() 这类诊断工具,才能把「服务挂了」快速定位到「谁在等谁」。本文从锁模式讲起,覆盖阻塞链与死锁的形成、诊断 SQL、典型场景的解法,以及从源头预防的手段。

一、锁的体系

1.1 表级锁的 8 种模式

PostgreSQL 的表级锁按「强度」从弱到强排列:

模式典型触发冲突强度
ACCESS SHARESELECT最弱,只与 ACCESS EXCLUSIVE 冲突
ROW SHARESELECT FOR UPDATE与 EXCLUSIVE/ACCESS EXCLUSIVE 冲突
ROW EXCLUSIVEINSERT / UPDATE / DELETE与 SHARE 及以上冲突
SHARE UPDATE EXCLUSIVEVACUUM / ANALYZE / CREATE INDEX CONCURRENTLY与自身及以上冲突
SHARECREATE INDEX(非并发)阻塞写
SHARE ROW EXCLUSIVEALTER TABLE 部分操作阻塞写与部分读
EXCLUSIVE阻塞所有非 ACCESS SHARE强
ACCESS EXCLUSIVEALTER TABLE / DROP TABLE / TRUNCATE最强,阻塞一切

关键认知:普通的 SELECT 只拿 ACCESS SHARE,它只与 ACCESS EXCLUSIVE 冲突。所以「查询之间不会互相阻塞」,但一个 ALTER TABLE 会阻塞所有查询——这就是为什么 DDL 必须谨慎。

1.2 行级锁的 4 种模式

行级锁只在写操作时出现,且不阻塞「读」:

模式触发阻塞
FOR UPDATESELECT ... FOR UPDATE / UPDATE阻塞其它 FOR UPDATE
FOR NO KEY UPDATE不更新键列的 UPDATE弱于 FOR UPDATE
FOR SHARESELECT ... FOR SHARE阻塞 FOR UPDATE
FOR KEY SHARE外键引用检查最弱,只阻塞改键的 UPDATE

FOR NO KEY UPDATE 与 FOR KEY SHARE 的引入是为了减少外键带来的锁冲突:外键的引用检查只需 FOR KEY SHARE,而更新非键列的 UPDATE 只需 FOR NO KEY UPDATE,两者互不冲突。这让「父表更新非键列」不会阻塞「子表插入」。

1.3 咨询锁(Advisory Lock)

咨询锁是应用自定义的锁,与表/行无关,纯粹是「一个可以全局协调的锁标识」:

-- 会话级:直到显式释放或会话结束
SELECT pg_advisory_lock(12345);
SELECT pg_advisory_unlock(12345);

-- 事务级:事务结束时自动释放(推荐)
SELECT pg_advisory_xact_lock(12345);

-- 非阻塞版本,拿不到立即返回 false
SELECT pg_try_advisory_lock(12345);

它常用于「同一时刻只有一个 worker 执行某任务」的场景,如防止多个实例同时跑定时任务。优先用事务级 pg_advisory_xact_lock,避免会话级锁忘记释放导致永久死锁。

1.4 锁的排队与快速路径

PostgreSQL 的表锁采用「快速路径(fast-path)」优化:当一个会话只持有弱锁(ACCESS SHARE 等)且冲突的强锁很少时,锁信息记录在进程私有的快速路径槽位里,无需修改全局锁表,开销极低。只有当快速路径槽位耗尽(默认每个会话若干槽位)或出现强锁时,才升级为全局锁表条目。

这解释了两个现象:

  • 纯读并发几乎无锁开销,因为都走快速路径。
  • 一旦出现 ACCESS EXCLUSIVE,所有后续请求都要进全局队列,即使它们彼此不冲突——队列是 FIFO 的,后来的必须先排队。

因此「阻塞雪崩」的触发点往往是第一个强锁请求,而不是数据量或并发量本身。把 DDL、TRUNCATE、VACUUM FULL 这类强锁操作隔离到低峰期,比调任何参数都有效。

二、阻塞链与死锁

2.1 阻塞链

阻塞不是一对一的,而是可以形成链条:A 等 B、B 等 C、C 等 D。链条越长,恢复越慢。一条典型的链是:

ALTER TABLE orders ...            -- 持有 ACCESS EXCLUSIVE 的等待者(排队中)
  ← SELECT * FROM orders LIMIT 1  -- 持有 ACCESS SHARE,阻塞了 ALTER
      ← 另一个 SELECT ...         -- 排在 ALTER 后面(因为 PostgreSQL 锁队列是 FIFO)

这里最反直觉的是第三行:后到的 SELECT 本来与 ALTER 不冲突,但因为锁队列是先进先出,它必须排在 ALTER 后面等——而 ALTER 在等前面的 SELECT 释放。于是「一个短查询后面堵了一长串查询」。这就是「DDL 排队雪崩」的完整机制。

2.2 死锁

死锁是两个事务互相等待对方持有的锁:

事务 A: UPDATE t WHERE id = 1;   -- 持有行 1
事务 B: UPDATE t WHERE id = 2;   -- 持有行 2
事务 A: UPDATE t WHERE id = 2;   -- 等 B
事务 B: UPDATE t WHERE id = 1;   -- 等 A → 死锁

PostgreSQL 有死锁检测器(deadlock detector),默认每 deadlock_timeout(1 秒)检查一次,发现环后回滚其中一个事务并报错:

ERROR:  deadlock detected
DETAIL:  Process 12345 waits for ShareLock on transaction 67890;
         blocked by process 54321.

死锁本身不会让数据库挂掉,但频繁死锁说明应用存在「不一致的加锁顺序」。修复方法是统一加锁顺序:所有事务按相同的顺序访问资源(如按 ID 升序更新)。批量更新时尤其要注意:

-- 错误:按业务顺序逐行更新,容易死锁
UPDATE accounts SET balance = balance - 100 WHERE id = 5;
UPDATE accounts SET balance = balance + 100 WHERE id = 3;

-- 正确:按 id 排序后一次性更新
UPDATE accounts SET balance = balance + CASE WHEN id = 3 THEN 100
                                             WHEN id = 5 THEN -100 END
WHERE id IN (3, 5);

三、诊断手法

3.1 找出阻塞关系

pg_blocking_pids() 是诊断阻塞最直接的工具,它返回「阻塞了指定 PID 的那些 PID」:

SELECT
    pid,
    pg_blocking_pids(pid) AS blocked_by,
    state,
    wait_event_type,
    wait_event,
    now() - query_start AS duration,
    left(query, 60) AS query
FROM pg_stat_activity
WHERE cardinality(pg_blocking_pids(pid)) > 0
ORDER BY duration DESC;

blocked_by 非空的行就是「正在被阻塞的会话」,blocked_by 里的 PID 就是「持锁不放的元凶」。顺着这个字段可以一路找到链条的头部。

3.2 查看锁详情

pg_locks 是锁的原始视图,需要与 pg_stat_activity、pg_class 关联才能读懂:

SELECT
    l.pid,
    a.state,
    l.locktype,
    l.mode,
    l.granted,
    c.relname,
    l.relation::regclass AS relation_name,
    a.query
FROM pg_locks l
JOIN pg_stat_activity a ON a.pid = l.pid
LEFT JOIN pg_class c ON c.oid = l.relation
WHERE NOT l.granted
ORDER BY l.pid;

granted = false 的行是正在等待的锁。locktype 常见取值:relation(表锁)、tuple(行锁)、transactionid(等待事务结束)、advisory(咨询锁)。

等待 transactionid 是行锁冲突的典型表现——行级锁不是显式记录的,而是通过「等待对方事务 ID 结束」来实现。

3.3 定位长事务

长事务是阻塞的头号来源。找出运行超过 5 分钟的事务:

SELECT pid, state, xact_start,
       now() - xact_start AS xact_age,
       now() - state_change AS idle_age,
       left(query, 60) AS query
FROM pg_stat_activity
WHERE xact_start IS NOT NULL
  AND now() - xact_start > interval '5 minutes'
ORDER BY xact_start;

特别注意 state = 'idle in transaction' 的会话:它们开着事务却不干活,持有锁不放,是「隐形杀手」。这类会话通常源于应用忘记提交/回滚,或连接池归还连接时没结束事务。

3.4 综合诊断视图

把上面几步合并成一个「阻塞总览」查询,是 DBA 的日常工具:

SELECT
    blocked.pid                AS blocked_pid,
    blocked.query              AS blocked_query,
    blocking.pid               AS blocking_pid,
    blocking.query             AS blocking_query,
    now() - blocked.query_start AS blocked_duration
FROM pg_stat_activity blocked
JOIN LATERAL unnest(pg_blocking_pids(blocked.pid)) AS b(pid) ON true
JOIN pg_stat_activity blocking ON blocking.pid = b.pid
WHERE cardinality(pg_blocking_pids(blocked.pid)) > 0;

这个视图能一眼看出「谁被谁挡住了、挡了多久、双方在跑什么 SQL」。把它做成监控告警的定时查询,能在雪崩前提前介入。配合 PostgreSQL 监控与诊断 里的指标采集,可以构建完整的可观测性闭环。

3.5 锁等待的分布统计

排查阶段性问题时,先看「等待集中在哪种锁上」,能快速缩小范围:

SELECT mode, locktype, count(*) AS waiters
FROM pg_locks
WHERE NOT granted
GROUP BY mode, locktype
ORDER BY waiters DESC;
等待的锁含义常见根因
transactionid等待某个事务结束行锁冲突、长事务
relation + AccessExclusiveLock等表级排他锁DDL 排队
relation + ShareLock等共享锁CREATE INDEX 非并发
advisory等咨询锁应用自定义锁未释放

若 waiters 集中在 transactionid,说明是行级写冲突(热点行或长事务);若集中在 AccessExclusiveLock,说明有 DDL 在排队。两类问题的解法完全不同:前者要缩短事务或打散热点,后者要用 lock_timeout 让 DDL 快速失败。

四、典型阻塞场景与解法

4.1 DDL 排队雪崩

场景:业务低峰期执行 ALTER TABLE orders ADD COLUMN note text;,结果该表上有长查询,ALTER 拿不到 ACCESS EXCLUSIVE 而排队,后续所有 SELECT orders 又排在 ALTER 后面,整张表「冻结」。

解法:先设锁超时,让 DDL 失败而不是无限等待。

SET lock_timeout = '3s';
ALTER TABLE orders ADD COLUMN note text;
-- 超时报错:canceling statement due to lock timeout

拿不到锁就放弃、重试,避免 DDL 长时间挂在队列头部。更彻底的做法是用 lock_timeout + 重试循环:

while ! psql -c "SET lock_timeout='3s'; ALTER TABLE orders ADD COLUMN note text;"; do
  echo "locked, retrying in 10s"; sleep 10
done

对于新增列这类操作,PostgreSQL 11+ 的 ADD COLUMN 若带 DEFAULT 且默认值是常量,是元数据操作、不重写表,本身很快——慢的只是拿锁。所以关键是缩短「持有 ACCESS EXCLUSIVE 的等待窗口」。

4.2 外键锁冲突

场景:父表某行被 SELECT FOR UPDATE 锁住,导致子表插入该父行的外键时等待。

解法:理解外键检查只需 FOR KEY SHARE,它与 FOR NO KEY UPDATE 不冲突。因此更新父表非键列不会阻塞子表插入。若发现阻塞,多半是应用用了 SELECT ... FOR UPDATE 而非 FOR NO KEY UPDATE。改用更弱的锁模式:

-- 若不需要阻止并发改键,用更弱的模式
SELECT * FROM accounts WHERE id = 42 FOR NO KEY UPDATE;

4.3 热点行更新

场景:秒杀、计数器、库存扣减——大量事务更新同一行,全部串行化,形成「排队」。

行级锁意味着同一行同一时刻只能有一个写事务,这是无法绕开的。缓解思路是「把一行拆成多行」:

-- 把计数器拆成 16 个分片行
CREATE TABLE counters (key text, shard int, value bigint, PRIMARY KEY (key, shard));

-- 写入时随机选一个分片
UPDATE counters SET value = value + 1
WHERE key = 'page_views' AND shard = floor(random() * 16);

-- 读取时求和
SELECT sum(value) FROM counters WHERE key = 'page_views';

这把并发冲突从「16 个事务抢 1 行」变成「16 个事务各写 1 行」,吞吐提升接近 16 倍。代价是读要聚合。这是经典的「写分散、读聚合」权衡。

4.4 长事务阻塞 VACUUM

场景:一个 idle in transaction 的会话,其快照阻止了 VACUUM 回收死元组,导致表持续膨胀,进而让所有查询变慢。

这不算「锁阻塞」,但危害类似。解法是设置空闲事务超时:

idle_in_transaction_session_timeout = '5min'

超过 5 分钟的 idle in transaction 会话会被自动断开。这个参数应该成为生产环境的标配。

4.5 连接池与锁的叠加

连接池会放大锁问题:一个持锁的慢事务占着一个连接,连接池的其余连接也在排队等待,很快就耗尽。事务级池化(pgBouncer pool_mode = transaction)要求应用在事务结束后归还连接,但若应用在事务里做慢 IO,锁与连接会同时被长期占用。锁问题与连接池配置往往交织在一起,排查时要把两者放在一起看。

4.6 大事务的锁放大

一个更新 100 万行的事务会持有 100 万行的行锁,直到提交才全部释放。这在并发写场景下几乎必然造成大面积等待。把大事务拆成小批次是标准解法:

-- 分批更新,每批 5000 行,批间提交
DO $$
DECLARE
    rows_updated int;
BEGIN
    LOOP
        UPDATE orders SET status = 'archived'
        WHERE id IN (
            SELECT id FROM orders WHERE status = 'old' LIMIT 5000
            FOR UPDATE SKIP LOCKED
        );
        GET DIAGNOSTICS rows_updated = ROW_COUNT;
        EXIT WHEN rows_updated = 0;
        COMMIT;
    END LOOP;
END $$;

FOR UPDATE SKIP LOCKED 是关键:它跳过已被其它事务锁住的行,避免多个 worker 互相等待。这让「多进程并行消费同一张表」成为可能——每个 worker 各取各的批次,互不阻塞。这是队列式处理的经典模式,代价是跳过 0 行的批次也要退出,需要处理好边界。

注意 DO 块里 COMMIT 只在 PL/pgSQL 的 CALL/过程(CREATE PROCEDURE)中允许,匿名块需改为过程或由应用侧循环控制提交。用应用侧循环 + 每批一个独立事务,是更可控的做法。

五、从源头预防

5.1 参数配置

# 死锁检测间隔,过长会让死锁发现变慢,过短会增加检查开销
deadlock_timeout = 1s

# 空闲事务超时,断开忘记提交的会话
idle_in_transaction_session_timeout = 5min

# 语句超时,防止单条 SQL 无限跑
statement_timeout = 30s

lock_timeout 建议按会话/语句设置而非全局——DDL 需要短超时,普通业务查询则不需要。可以用角色的默认参数:

ALTER ROLE app_user SET statement_timeout = '30s';
ALTER ROLE migration_user SET lock_timeout = '3s';

5.2 应用侧约定

  • 事务要短:事务里不做网络调用、不等待用户输入、不做大批量计算。
  • 加锁顺序一致:所有事务按相同顺序访问多行/多表,消除死锁。
  • 优先用 FOR NO KEY UPDATE / FOR KEY SHARE:能减少与外键检查的冲突。
  • DDL 走独立的低权限角色 + lock_timeout,绝不与应用连接共用。
  • 重试死锁:捕获 40P01(deadlock detected)后随机退避重试,是分布式事务的标准做法。

ORM 的事务封装容易让开发者无意中把慢操作(HTTP 调用、批量计算)放进事务,从而把锁持有时间拉长。用 Prisma 时显式控制事务边界、避免在 $transaction 回调里做外部 IO,是常见的优化点,具体写法见 Prisma 与 PostgreSQL 集成 。

5.3 监控告警

把「阻塞数」与「最长阻塞时长」作为核心告警指标:

SELECT count(*) AS blocked_sessions,
       max(now() - query_start) AS max_blocked_duration
FROM pg_stat_activity
WHERE cardinality(pg_blocking_pids(pid)) > 0;

当 blocked_sessions 超过阈值(如 10)或 max_blocked_duration 超过 30 秒时告警。这个查询很轻,可以每 10 秒采集一次。把它接到监控系统后,就能在雪崩发生前几分钟收到信号。

5.4 复盘与工具

排查锁问题时,pg_stat_activity 的 query_start 与 state_change 是两条关键时间线:前者说明查询跑了多久,后者说明状态多久没变。idle in transaction 且 state_change 很久没变,基本就是「忘了提交」。

排查用的 SQL 应整理成 runbook 固化下来,而不是每次临时手写。把本文的诊断查询、PostgreSQL 查询优化实战 里的慢查询分析、以及 PostgreSQL 性能调优 里的参数调优方法组合起来,就构成了一套完整的「性能故障响应手册」。事务隔离级别如何影响锁行为,也是排查时需要考虑的一环——不同隔离级别下加锁的范围与时机不同。

六、常见错误速查

报错 / 症状根因处置
deadlock detected加锁顺序不一致统一顺序,应用侧捕获重试
canceling statement due to lock timeoutlock_timeout 生效正常,重试即可
canceling statement due to statement timeout语句超时优化查询或调大超时
全表查询集体变慢DDL 排队阻塞找到 DDL 会话,pg_cancel_backend
idle in transaction 堆积应用忘记提交设 idle_in_transaction_session_timeout
库存扣减排队热点行冲突分片计数,写分散读聚合
死锁只在压测时出现并发顺序随机用确定性排序消除

紧急情况下的处置手段:pg_cancel_backend(pid) 取消某个会话当前的查询,pg_terminate_backend(pid) 直接断开连接。前者更温和(可回滚),后者用于「会话卡死、连取消都不响应」的情况。执行前务必确认 PID 对应的会话确实是元凶,误杀业务连接会引发二次故障。

-- 先确认,再操作
SELECT pid, usename, application_name, state, left(query, 50)
FROM pg_stat_activity WHERE pid = 12345;

SELECT pg_cancel_backend(12345);

小结

PostgreSQL 锁问题的本质是「多粒度锁 + FIFO 队列」的相互作用:普通查询只拿 ACCESS SHARE,但一个 ACCESS EXCLUSIVE 的 DDL 会把它后面的所有查询一起堵住,形成雪崩。诊断的抓手是 pg_blocking_pids()——它直接给出「谁被谁阻塞」,顺着链条就能找到元凶。预防上,lock_timeout 让 DDL 快速失败、idle_in_transaction_session_timeout 清理忘记提交的会话、统一加锁顺序消除死锁、热点行用分片打散。最后,把阻塞数作为监控告警指标,把诊断 SQL 固化成 runbook——锁问题几乎不可能靠「事后反应」解决,必须在它演变成雪崩前就介入。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「database」更多文章

  1. COPY 与批量数据加载优化
  2. pgvector 向量检索与混合查询
  3. Citus 分布式分片与水平扩展