pg_locks 锁监控
如果说 pg_stat_activity 回答的是「谁在等」,那么 pg_locks 回答的就是「在等什么锁、谁拿着这把锁」。它是 PostgreSQL 锁管理器(lock manager)的直接窗口:一行对应一把已经持有(granted = true)或正在排队等待(granted = false)的锁。把它和 pg_stat_activity 按进程号关联起来,就能拼出完整的阻塞链——这是数据库排障中最经典的组合查询之一。
pg_locks 是一个内存型实时视图,直接读取共享内存中的锁表,没有任何统计延迟。但它展示的是锁管理器层面的锁,理解这一点非常关键:表级锁、事务 ID 锁、顾问锁都在这里;而行级锁通常不会直接出现(它以元组头标记和事务等待的形式间接体现)。本页会专门讲清这个容易踩坑的区别。
锁模式(mode 列的八种取值)的完整定义与冲突关系在事务章节已经详细讲过,本页聚焦「怎么观察锁、怎么顺着锁找到阻塞源头」。
前置知识
建议先阅读:pg_stat_activity 实时会话
基础概念
PostgreSQL 的锁管理器维护着一个以「可锁定对象(lockable object)」为键的锁表。想锁一个东西,先要描述出「锁的是哪类对象的哪一实例」,这个描述在内核里叫 locktag,对应到视图上就是 locktype 加上一组定位字段。
| 概念 | 对应视图字段 | 说明 |
|---|---|---|
| 可锁定对象类型 | locktype | 表、页、元组、事务 ID、虚拟事务 ID、对象、顾问锁等 |
| 对象定位字段 | database / relation / page / tuple / transactionid / virtualxid / classid / objid / objsubid | 按 locktype 不同,只有其中若干字段有值 |
| 锁模式 | mode | 八种表级锁模式,决定冲突关系 |
| 持有者 / 等待者 | granted | true = 已持有;false = 排队等待中 |
| 进程 | pid | 持有或等待这把锁的进程,关联 pg_stat_activity 的主键 |
| 事务标识 | virtualtransaction | 形如 3/4821 的事务标识(backend ID / local transaction ID) |
三个必须先建立的心智模型:
granted = false的行就是「正在等待」。 一个会话在等锁,视图里会出现一行它对这个对象的granted = false记录,同时pg_stat_activity.wait_event_type = 'Lock'。这是判断「谁在等」的两个独立证据源,可以互相印证。- 一把锁、多个持有者。 同一个对象上可以同时存在多行
granted = true(多个共享模式持有者),mode各不相同;判断它们是否冲突要查冲突矩阵。 - 锁是队列制的。 等待请求按先来后到排队;一旦有冲突请求在排队,后来的请求即使与已持有锁不冲突,也要排在队列后面,避免饿死。这就是「一条排队中的
ACCESS EXCLUSIVE(如 DDL)能让后续所有查询都排队」的原理。
行级锁的特殊性
PostgreSQL 的行级锁(SELECT ... FOR UPDATE、UPDATE、DELETE 产生的行锁)不写在锁管理器表里,而是记录在元组头部的 xmax 字段中。一个会话被「行锁」阻塞时,它实际上是在等待对方的事务结束——表现为在 pg_locks 里对 transactionid 对象的 Lock 等待,而不是一行「row lock」。只有极少数情况(如并发更新同一元组需要 tuple 级仲裁时)才会出现 locktype = 'tuple' 的记录。
所以「在 pg_locks 里找 row lock」是常见误区:看到的其实是这样一串证据——
会话 A:granted = true, locktype = transactionid, transactionid = 99123 (A 是事务 99123 的持有者)
会话 B:granted = false, locktype = transactionid, transactionid = 99123 (B 在等事务 99123 结束)行级锁的语义细节见《行级锁(FOR UPDATE / FOR SHARE)》一页。
语法与用法
基础查询:看清当前锁表
-- 1. 锁表全貌:谁持有谁等待
SELECT locktype,
pid,
virtualtransaction,
mode,
granted,
waitstart,
coalesce(rel::regclass::text, '') AS rel_name
FROM pg_locks
LEFT JOIN pg_class AS rel ON rel.oid = pg_locks.relation
ORDER BY locktype, granted, pid; locktype | pid | virtualtransaction | mode | granted | waitstart | rel_name
--------------+-------+--------------------+------------------+---------+-----------+--------------------------
transactionid| 3128 | 4/1921 | ShareLock | f | 09:22:11 |
virtualxid | 3140 | 5/8 | ExclusiveLock | t | |
relation | 3128 | 4/1921 | RowExclusiveLock | t | | orders
relation | 3140 | 5/8 | AccessShareLock | t | | orders
tuple | 3140 | 5/8 | RowShareLock | f | 09:22:12 | orders-- 2. 汇总:当前有多少把锁处于等待状态
SELECT locktype, mode, count(*) AS waiting_locks
FROM pg_locks
WHERE NOT granted
GROUP BY locktype, mode
ORDER BY waiting_locks DESC;
-- 3. 某个会话的全部锁
SELECT locktype, mode, granted, relation::regclass
FROM pg_locks
WHERE pid = 3128;关联 pg_stat_activity:把锁和 SQL 拼起来
pg_locks 只有进程号,业务信息要从 pg_stat_activity 拿:
SELECT a.pid,
a.datname,
a.usename,
a.state,
a.wait_event_type,
a.wait_event,
l.locktype,
l.mode,
l.granted,
l.relation::regclass AS locked_rel,
left(a.query, 60) AS query_head
FROM pg_locks l
JOIN pg_stat_activity a ON a.pid = l.pid
WHERE l.locktype IN ('relation', 'transactionid', 'tuple', 'virtualxid')
ORDER BY a.pid, l.granted;pg_blocking_pids:一步找到阻塞者
PG9.6 起提供的内置函数,直接回答「这个会话被谁挡住了」:
-- 被 3140 阻塞的 3128
SELECT pg_blocking_pids(3128) AS blockers; blockers
----------
{3140}
(1 row)-- 全库扫描:所有处于 Lock 等待的会话及其阻塞者
SELECT a.pid AS waiting_pid,
pg_blocking_pids(a.pid) AS blocked_by,
a.wait_event,
now() - a.query_start AS waiting_time,
left(a.query, 60) AS waiting_query
FROM pg_stat_activity a
WHERE a.wait_event_type = 'Lock'
ORDER BY waiting_time DESC;pg_blocking_pids 内部会做锁表分析(含等待队列的间接阻塞),比手工自连接准确得多,日常排障应优先使用它。手工自连接的写法在「详细说明」里仍然要学——它能展示阻塞链的完整结构,且在老版本上可用。
详细说明
字段全览
| 字段 | 类型 | 说明 |
|---|---|---|
locktype | text | 可锁定对象类型,见下一小节 |
database | oid | 锁所属数据库的 OID;全局对象(如顾问锁跨库场景)可为空 |
relation | oid | 目标关系的 OID;非关系型锁为 NULL,常配合 relation::regclass 转成表名 |
page | integer | 页内锁(页/元组锁)的页号 |
tuple | smallint | 元组锁的元组号(页内偏移) |
virtualxid | text | 虚拟事务 ID 锁的目标,如 3/4821 |
transactionid | xid | 事务 ID 锁的目标事务号 |
classid / objid / objsubid | oid / oid / smallint | 对象锁与顾问锁的定位三元组 |
virtualtransaction | text | 持有或等待该锁的事务标识,形如 4/1921 |
pid | integer | 持有或等待该锁的进程号 |
mode | text | 请求的锁模式 |
granted | boolean | true 已持有;false 等待中 |
waitstart | timestamptz | 该进程开始等待这把锁的时刻(PG14+);已持有的锁为 NULL |
两点提示:
waitstart是量化「等了多久」最直接的依据(PG14+)。老版本只能用pg_stat_activity的now() - query_start估算。virtualtransaction与pid一一对应(每进程同一时刻只有一个活动事务),但它能在 pid 变化(如并行查询)场景下保持稳定的关联键,某些自连接 SQL 里更好用。
locktype 的取值:可锁定对象清单
| locktype | 锁的对象 | 典型来源 |
|---|---|---|
relation | 整张表(含索引、物化视图等关系) | 任何读写 SQL 隐式加的表级锁 |
extend | 关系的扩展权(追加新页) | 追加写入的并发仲裁 |
frozenid | 冻结相关的数据库级标识(较新版本引入) | 冻结/清理内部操作 |
page | 单个索引页 | B-tree 页分裂等内部操作 |
tuple | 单个元组 | 并发更新同一行的短暂仲裁 |
transactionid | 某个事务的完成事件 | 行级锁等待的核心实现 |
virtualxid | 虚拟事务 ID | 会话内串行化特定操作 |
object | 数据库级共享对象(如表空间、数据库本身) | DROP TABLESPACE、CREATE DATABASE 等 |
userlock | 历史遗留的用户锁接口 | 老应用,新代码应使用顾问锁 |
advisory | 顾问锁 | 应用显式申请的命名锁 |
观察建议:日常 99% 的排障只涉及 relation、transactionid、tuple、virtualxid、advisory 五种;page、extend 多见于内部操作,出现等待通常意味着高并发写入压力。
锁模式回顾
mode 列的八种取值与冲突关系,完整定义见《表级锁(ACCESS SHARE / EXCLUSIVE)》与《锁冲突矩阵》两页,这里只给速查:
| 模式 | 典型来源 | 与谁冲突 |
|---|---|---|
ACCESS SHARE | 普通 SELECT | 仅 ACCESS EXCLUSIVE |
ROW SHARE | SELECT ... FOR UPDATE/SHARE | EXCLUSIVE、ACCESS EXCLUSIVE |
ROW EXCLUSIVE | UPDATE / DELETE / INSERT | SHARE、SHARE ROW EXCLUSIVE、EXCLUSIVE、ACCESS EXCLUSIVE |
SHARE UPDATE EXCLUSIVE | VACUUM(非 FULL)、ANALYZE、CREATE INDEX CONCURRENTLY | 同级及以上全部 |
SHARE | CREATE INDEX(非并发) | ROW EXCLUSIVE 及以上 |
SHARE ROW EXCLUSIVE | 少见(如某些外键校验场景) | ROW EXCLUSIVE 及以上 |
EXCLUSIVE | 少见 | 除 ACCESS SHARE、ROW SHARE 外全部 |
ACCESS EXCLUSIVE | ALTER TABLE、DROP TABLE、VACUUM FULL、多数 DDL | 全部 |
记住一条主线:读拿 ACCESS SHARE,写拿 ROW EXCLUSIVE,DDL 拿 ACCESS EXCLUSIVE。DDL 与读冲突,是「上午还好好的、一跑 ALTER 全站卡死」的根源。
granted 与锁队列
锁的授予规则值得单独讲清楚,因为它解释了很多「反直觉」现象:
- 快路径与慢路径对使用者透明。 大多数
ACCESS SHARE锁走内核的 fast-path 优化,不一定在锁表里留下完整痕迹;冲突激烈时回落到慢路径。这不影响排障,但别指望视图行数与「理论上应持有的锁数」严格相等。 - 队列是 FIFO 的。 等待请求按到达顺序排队。新请求到达时,如果队列里已经有与自己冲突的等待者,即使当前持有者都不与它冲突,它也必须排队。
- 排队中的 DDL 是「路障」。
ACCESS EXCLUSIVE排队后,其后到达的一切请求(哪怕只是SELECT)都要排在它后面。所以 DDL 一定要放在锁等待可控的窗口执行,或先设lock_timeout。
-- 观察队列:等待中的锁按 waitstart 排队
SELECT pid, locktype, mode, waitstart,
now() - waitstart AS wait_age
FROM pg_locks
WHERE NOT granted
ORDER BY waitstart;事务 ID 锁:行级锁等待的真相
transactionid 锁是理解阻塞的关键机制,它有两种出现方式:
- 持有者:事务自身在自己的 transactionid 上持有
ExclusiveLock(granted = true)——每个分配了真实事务 ID 的事务都有一行这样的记录。 - 等待者:被行锁挡住的会话对该 transactionid 持有
ShareLock且granted = false——语义是「等这个事务提交或回滚」。
-- 一眼看穿行锁阻塞:等待者与持有者同框
SELECT w.pid AS waiting_pid,
w.transactionid AS waiting_for_xid,
h.pid AS holding_pid,
h.usename AS holder_user,
h.state AS holder_state,
h.xact_start AS holder_xact_start,
left(h.query, 60) AS holder_query
FROM pg_locks w
JOIN pg_stat_activity h ON h.pid = w.pid -- 占位,实际阻塞链见下一节
WHERE 1 = 0; -- 演示骨架,完整可执行版本见实战示例真正的阻塞链查询在「实战示例」里给出可运行版本。
虚拟事务 ID 锁与对象锁
virtualxid锁:每个会话对自己的虚拟事务 ID 持有ExclusiveLock,用于串行化会话内的特定操作。它也是某些系统行为的等待点(如等待另一个会话的虚拟事务释放)。排队等待virtualxid通常意味着会话级的串行化冲突。object锁:数据库、表空间、订阅等「数据库级共享对象」的锁。经典现象:CREATE DATABASE要对源数据库拿ShareLock,若此时有其他连接不让锁,就会排队。advisory锁:应用通过pg_advisory_lock()等函数申请的命名锁,用classid/objid(64 位键拆成两段)或objid/objsubid(32 位键)标识。忘了解锁的顾问锁是常见故障源,排查方式见实战示例。
用 pg_blocking_pids 找阻塞链
pg_blocking_pids(pid) 返回阻塞该进程的进程号数组。几个语义细节:
- 它综合了
transactionid、tuple、virtualxid、relation等各类等待,并考虑了等待队列的间接关系(A 等 B、B 又被 C 挡时,A 的阻塞者算 B 而不是 C)。 - 多个持有者同时阻塞时返回多个 pid,数组按进程号排序。
- 它是「当下」的计算结果,连续调用结果可能变化,适合点查而非长期统计。
-- 完整阻塞树:等待者 -> 第一层阻塞者 -> 阻塞者的会话信息
WITH waiting AS (
SELECT a.pid,
unnest(pg_blocking_pids(a.pid)) AS blocker
FROM pg_stat_activity a
WHERE a.wait_event_type = 'Lock'
)
SELECT w.pid AS waiting_pid,
w.blocker AS blocked_by,
b.usename AS blocker_user,
b.state AS blocker_state,
now() - b.xact_start AS blocker_xact_age,
left(b.query, 60) AS blocker_query
FROM waiting w
JOIN pg_stat_activity b ON b.pid = w.blocker;死锁与日志解读
两个(组)会话互相等待对方持有的锁时,死锁检测器会介入。检测频率由 deadlock_timeout(默认 1 秒)控制,检测到后选择一个「牺牲者」回滚,日志中会出现成对的诊断信息:
ERROR: deadlock detected
DETAIL: Process 3140 waits for ShareLock on transaction 99123; blocked by process 3128.
Process 3128 waits for ShareLock on transaction 99140; blocked by process 3140.
HINT: See server log for query details.读法要点:两行 Process ... waits for ... blocked by ... 描述了等待环,被回滚的是日志中 Process ... 开头的那一个(错误发给谁,谁就是牺牲者);DETAIL 只在 log_error_verbosity = default 及以上才输出,排查死锁务必保证日志详细度足够。死锁的形成机理、避免手段与 pg_stat_database.deadlocks 计数的监控,见《死锁检测机制》《死锁产生原因》两页。
与 pg_stat_activity.wait_event 的对照
同一个锁等待,两个视图各给出一半信息:
| 现象 | pg_stat_activity | pg_locks |
|---|---|---|
| 等行锁(事务提交) | wait_event_type = Lock,wait_event = transactionid | 对应 transactionid 的 granted = false 行 |
| 等表锁 | wait_event = relation | relation 上 granted = false 行 |
| 等元组仲裁 | wait_event = tuple | tuple 上 granted = false 行 |
| 等虚拟事务 | wait_event = virtualxid | virtualxid 上 granted = false 行 |
| 等顾问锁 | wait_event = advisory | advisory 上 granted = false 行 |
排障时两边交叉验证:wait_event 告诉你等待类别,pg_locks 给你阻塞对象的定位与持有者。
版本差异
- PG9.6+:
pg_blocking_pids()可用;此前只能手工自连接分析。 - PG14+:
pg_locks新增waitstart列,等待时长可以直接量化。 - 较新版本:
locktype清单中出现了frozenid(冻结相关内部操作),属于内核内部行为,日常排障很少涉及。 - 更老版本(PG9.5 及以前)没有
waitstart,等待时长只能靠pg_stat_activity.query_start估算。
顾问锁的键值编址与「幽灵锁」排查
顾问锁(advisory lock)是应用显式申请的命名锁,通过 pg_advisory_lock() 系列函数使用。它的键有两种编址方式:
| 函数形态 | 键的构成 |
|---|---|
pg_advisory_lock(key bigint) | 单个 64 位键 |
pg_advisory_lock(key1 int, key2 int) | 两个 32 位键 |
无论哪种编址,落到 pg_locks 里都是以 classid、objid(必要时配合 objsubid)三元组标识的对象;locktype = 'advisory' 的行按三元组分组,就是当前全部顾问锁的台账。
顾问锁还分会话级与事务级两族:pg_advisory_lock / pg_advisory_unlock 是会话级(必须显式释放),pg_advisory_xact_lock 是事务级(事务结束自动释放)。绝大多数「幽灵锁」事故来自前者:拿到锁的会话异常退出前没解锁、或者代码分支漏掉释放。会话级锁可以脱离事务长期存在——持有者的 state 早已是 idle,锁却纹丝不动。
-- 幽灵锁体检:持有顾问锁、但会话早已空闲
SELECT l.classid, l.objid, l.mode, l.pid,
a.state, a.usename, a.application_name,
now() - a.state_change AS idle_since
FROM pg_locks l
JOIN pg_stat_activity a ON a.pid = l.pid
WHERE l.locktype = 'advisory'
AND a.state = 'idle';
-- 兜底释放:清空当前会话持有的全部会话级顾问锁
SELECT pg_advisory_unlock_all();顾问锁的函数家族与使用模式见《顾问锁入门》一页;这里聚焦的是「出了问题怎么从视图侧定位」。
DDL 路障的时间线还原
「一条 ALTER TABLE 卡死全站」是最经典的锁事故,时间线值得完整还原一遍:
T0 业务查询 A 持有 orders 上的 ACCESS SHARE(granted = true)
T1 ALTER TABLE orders 请求 ACCESS EXCLUSIVE → 与 A 冲突,进入等待队列
T2 业务查询 B、C、D 陆续到达,请求 ACCESS SHARE
→ 与队列头部的 ACCESS EXCLUSIVE 冲突,全部排在 ALTER 后面
T3 A 执行结束释放 ACCESS SHARE,但队列里还有更早的持有者;
ALTER 仍排在队首,B/C/D 排在它后面
→ 表面上「连 SELECT 都卡住」,真正的病根只是队列头部那一条 DDL视图层面的证据链:pg_locks 里 orders 的关系锁出现一串 granted = false 的 AccessShareLock 行,它们全部晚于那条 granted = false 的 AccessExclusiveLock 行进入队列(PG14+ 用 waitstart 排序即可还原顺序)。解堵动作永远是处理队列头部的 DDL 请求者,而不是满场杀 SELECT。
预防手段也由此推导:DDL 前设 lock_timeout,拿不到锁就快速失败重试;ALTER TABLE 类操作在低峰执行;变更窗口跑一遍本节末尾的锁体检清单。
高危操作前的锁体检清单
执行任何需要 ACCESS EXCLUSIVE 或长时间 SHARE UPDATE EXCLUSIVE 的操作(大表 ALTER、VACUUM FULL、REINDEX)之前,按这张清单过一遍:
| 体检项 | 查询要点 | 不通过的处理 |
|---|---|---|
| 有无长事务 | pg_stat_activity 中 xact_start 超过阈值的会话 | 先与业务确认、终止或等待 |
| 有无 idle in transaction | state = 'idle in transaction' | 取证后 terminate |
| 目标表上现有的锁 | pg_locks 按 relation 过滤 | 评估持有者还要持有多久 |
| 目标表上的活跃查询 | pg_stat_activity 的 query 涉及目标表 | 通知业务方错峰 |
| 备库回放压力 | 长查询会放大备库恢复冲突 | 评估 max_standby_streaming_delay |
| 兜底参数 | SET lock_timeout = '5s' | 拿不到锁就失败重试,绝不裸奔 |
把这张清单固化进变更模板,与《锁等待与超时》的参数配置配套使用。
死锁日志与视图字段的对照
死锁发生时,日志与视图是同一套语言的两种表达,字段对照如下:
| 死锁日志片段 | 对应视图字段 |
|---|---|
Process 3140 waits for ShareLock on transaction 99123 | pg_locks:transactionid = 99123、mode = ShareLock、granted = false、pid = 3140 |
blocked by process 3128 | pg_locks:pid = 3128、transactionid = 99123、granted = true |
Process 3128: SELECT ...(verbose 级别下的对方语句) | pg_stat_activity.query(若进程仍存活) |
deadlock detected 的 SQLSTATE 40P01 | csvlog/jsonlog 的 sql_state_code / state_code 字段(见本章日志部分) |
解读顺序:先从日志拿到等待环(谁在等谁的哪个事务),再到 pg_stat_database.deadlocks 确认计数增长,最后结合应用代码的加锁顺序(日志里两条语句的先后)定整改方案。死锁的完整机理见《死锁检测机制》与《死锁产生原因》。
等待队列的公平性:先到先得
PostgreSQL 的锁等待队列是先进先出的:一旦某把锁被授予,之后所有与之冲突的请求(哪怕是很轻的 ACCESS SHARE)都必须排在队列尾部,没有「插队」。这条规则解释了两件事:
- 路障效应为什么成立:
ACCESS EXCLUSIVE进入队列后,后面到达的普通 SELECT 也要排队——不是 DDL 「挡住」了读,而是队列规则让后来的读无法越过它。 - 不会饿死:重负载表的 DDL 请求虽然慢,但队列保证了它终会被服务;反过来,持续到来的轻请求也不会抢走它前面的位置。
观察队列顺序(PG14+ 直接按 waitstart 排序):
-- 目标表的锁队列快照:granted=false 的行按等待开始时间排队
SELECT l.pid, a.usename, l.mode, l.granted, l.waitstart
FROM pg_locks l
JOIN pg_stat_activity a ON a.pid = l.pid
WHERE l.relation = 'orders'::regclass
ORDER BY l.granted DESC, l.waitstart;队列快照(granted 列):
pid | mode | granted
------+-------------------+---------
612 | AccessShareLock | true ← 持有者(仍在执行)
901 | AccessExclusive | false ← 队列头(DDL,等 612 释放)
915 | AccessShareLock | false ← 被 901 路障挡住
916 | AccessShareLock | false ← 同上排在前面的 granted = false 行就是队列头部——处理它就等于处理整条队伍。锁等待的参数化治理(lock_timeout、deadlock_timeout)见《锁等待与超时》。
实战示例
示例一:经典阻塞链查询(可运行完整版)
模拟行锁阻塞:会话 A 开事务更新一行不提交,会话 B 更新同一行。
-- 会话 A
BEGIN;
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
-- 不提交,保持会话-- 会话 B(此时会卡住)
UPDATE accounts SET balance = balance + 100 WHERE id = 1;在第三个会话里查阻塞链:
SELECT waiting.pid AS waiting_pid,
waiting.usename AS waiting_user,
waiting.wait_event_type,
waiting.wait_event,
now() - waiting.query_start AS waiting_time,
left(waiting.query, 50) AS waiting_query,
blocking.pid AS blocking_pid,
blocking.usename AS blocking_user,
blocking.state AS blocking_state,
now() - blocking.xact_start AS blocking_xact_age,
left(blocking.query, 50) AS blocking_query
FROM pg_stat_activity waiting
JOIN pg_stat_activity blocking
ON blocking.pid = ANY (pg_blocking_pids(waiting.pid))
WHERE waiting.wait_event_type = 'Lock'; waiting_pid | waiting_user | wait_event_type | wait_event | waiting_time | waiting_query | blocking_pid | blocking_user | blocking_state | blocking_xact_age | blocking_query
-------------+--------------+-----------------+---------------+--------------+----------------------------+--------------+---------------+----------------+-------------------+----------------------
3140 | app | Lock | transactionid | 00:02:11 | UPDATE accounts SET balance| 3128 | app | idle in transaction | 00:05:40 | UPDATE accounts SET balance
(1 row)结论一目了然:3140 在等 3128,而 3128 的状态是 idle in transaction——典型的「开事务不提交」。
示例二:手工自连接分析(老版本可用)
不依赖 pg_blocking_pids 的写法,理解锁表结构的好教材:
SELECT w.pid AS waiting_pid,
h.pid AS blocking_pid,
w.locktype,
coalesce(w.relation::regclass::text, w.transactionid::text) AS lock_target,
w.mode AS waiting_mode,
h.mode AS held_mode
FROM pg_locks w
JOIN pg_locks h
ON w.locktype = h.locktype
AND w.database IS NOT DISTINCT FROM h.database
AND w.relation IS NOT DISTINCT FROM h.relation
AND w.page IS NOT DISTINCT FROM h.page
AND w.tuple IS NOT DISTINCT FROM h.tuple
AND w.transactionid IS NOT DISTINCT FROM h.transactionid
AND w.virtualxid IS NOT DISTINCT FROM h.virtualxid
AND w.classid IS NOT DISTINCT FROM h.classid
AND w.objid IS NOT DISTINCT FROM h.objid
AND w.objsubid IS NOT DISTINCT FROM h.objsubid
AND w.pid <> h.pid
AND h.granted
WHERE NOT w.granted
ORDER BY w.pid;自连接的思路是:同一对象上,granted = false 的等待者与 granted = true 的持有者配对。它只能给出「直接阻塞者」,且对等待队列的间接关系不敏感,生产上仍推荐 pg_blocking_pids。
示例三:排队中的 DDL 路障排查
-- ACCESS EXCLUSIVE 排队时,把持有者与受害者一起揪出来
SELECT l.pid,
a.usename,
a.state,
l.mode,
l.granted,
l.relation::regclass AS target,
a.wait_event_type,
now() - a.query_start AS duration,
left(a.query, 60) AS query_head
FROM pg_locks l
JOIN pg_stat_activity a ON a.pid = l.pid
WHERE l.relation = 'orders'::regclass
AND (l.mode = 'AccessExclusiveLock' OR NOT l.granted)
ORDER BY l.granted, a.query_start;典型读法:一行 granted = true, mode = AccessExclusiveLock 的持有者后面跟着一串 granted = false 的受害者(连 SELECT 都在等),处理持有者即可解堵。
示例四:顾问锁体检
-- 当前所有顾问锁:谁拿的、什么键、拿了多久
SELECT l.pid,
a.usename,
a.application_name,
l.classid,
l.objid,
l.objsubid,
l.mode,
now() - a.backend_start AS session_age
FROM pg_locks l
JOIN pg_stat_activity a ON a.pid = l.pid
WHERE l.locktype = 'advisory'
ORDER BY l.classid, l.objid;如果发现某顾问锁的持有会话 state = idle 且会话很老,多半是「拿锁后忘了释放」,用 pg_terminate_backend 处理或通知应用修复。
示例五:变更窗口的锁体检流程
目标:在业务低峰对 orders 表执行一个 ALTER TABLE。标准流程:
-- 步骤 1:会话级设置兜底参数(拿不到锁 5 秒内失败)
SET lock_timeout = '5s';
-- 步骤 2:体检——目标表上现在有哪些锁、谁持有的
SELECT l.pid, a.usename, a.state, l.mode, l.granted,
now() - a.xact_start AS xact_age
FROM pg_locks l
JOIN pg_stat_activity a ON a.pid = l.pid
WHERE l.relation = 'orders'::regclass
ORDER BY l.granted, a.xact_start;
-- 步骤 3:体检——有无空闲事务(它们会挡住一切 DDL)
SELECT pid, usename, now() - state_change AS idle_age
FROM pg_stat_activity
WHERE state = 'idle in transaction';
-- 步骤 4:体检通过后执行 DDL
ALTER TABLE orders ADD COLUMN remark text;# 步骤 5:lock_timeout 失败时的重试包装(bash 侧)
for i in 1 2 3; do
psql -c "SET lock_timeout='5s'; ALTER TABLE orders ADD COLUMN remark text;" && break
echo "attempt $i failed, retry in 10s"; sleep 10
done体检不通过的任何一项都要先处置再动手;把这套流程写进变更模板,比「出事了再排查」便宜得多。
注意事项
pg_locks 里看不到「行锁」三个字。 行级锁由元组头 xmax 与 transactionid 锁实现,等待行锁的表现是 transactionid 上的排队。找行锁冲突,别在视图里按 locktype = 'row' 搜索(这个值不存在)。
fast-path 锁可能不完整。 大量 ACCESS SHARE 锁走 fast-path 优化,视图中的行数不等于「系统理论上持有的锁总数」。这不影响阻塞分析(冲突锁一定走慢路径),但做「锁总量」类统计时要知道口径。
waitstart 为 NULL 不代表没有等待。 waitstart 只对 granted = false 的行有值;持有中的锁该列为 NULL 属正常。PG14 以前的版本没有这一列,等待时长要从 pg_stat_activity 侧估算。
杀持有者之前先看它的 state。 持有者若是 active(正在跑长查询),先 pg_cancel_backend 取消语句;若是 idle in transaction,只能 pg_terminate_backend。无条件杀进程可能让应用重连风暴雪上加霜。
排队会放大单点阻塞。 一个排在队首的 ACCESS EXCLUSIVE 会让之后所有请求排队,即使它们与当前持有者根本不冲突。看到「连 SELECT 都在等」时,先找队列头部的 DDL。
deadlock_timeout 调小要权衡。 缩短它能让死锁更早被发现,但每次锁等待超过该值都会触发一次死锁检查扫描,高并发下有额外开销。默认 1 秒对多数系统合适。
视图是实时读的,别在高负载时刻高频轮询。 pg_locks 查询要扫描锁表并可能解析对象,本身有成本。监控采样频率控制在秒级足够,毫秒级轮询反而制造新负载。
自连接分析不能覆盖间接阻塞。 手工配对只能找到「直接冲突的持有者」;等待队列造成的间接阻塞(A 等 B、B 被 C 挡)只有 pg_blocking_pids 这类内核逻辑能算准。
最佳实践
- 把「阻塞链巡检」做成固定监控项。 基于
pg_stat_activity.wait_event_type = 'Lock'+pg_blocking_pids()的查询每分钟跑一次,等待超过阈值的触发告警,把锁问题消灭在「排队」阶段而不是「雪崩」阶段。 - 所有 DDL 都带
lock_timeout执行。 例如SET lock_timeout = '5s'; ALTER TABLE ...,拿不到锁就快速失败重试,绝不无限排队制造路障。这与《锁等待与超时》一页的结论一致。 - 长事务是锁问题的第一嫌疑人。 阻塞链的顶端十有八九是
idle in transaction或一个跑了几十分钟的长查询;治理锁问题先治理长事务(见pg_stat_activity页的最佳实践)。 - 用
wait_event_type做第一层分流。Lock类等待才需要进入pg_locks分析;IO、Client类等待去查别处,避免把非锁问题误判成锁问题。 - 等锁时长用
waitstart量化(PG14+)。 等待「存在」不算事,等待「时长」才是告警依据;老版本用now() - query_start近似。 - 死锁告警盯
pg_stat_database.deadlocks计数。 该计数增长说明发生过死锁回滚,结合日志中的deadlock detected明细定位业务代码的加锁顺序问题。 - 顾问锁要「配对使用、集中登记」。 申请与释放写在同一段代码路径里(事务级顾问锁可交给事务边界释放),并把键值约定记录在案,避免「幽灵锁」。
- 高危操作前先查锁。
VACUUM FULL、大表ALTER TABLE、REINDEX这类需要ACCESS EXCLUSIVE的操作,执行前用本页巡检 SQL 确认没有长事务在场,并选择业务低峰。 - 把锁监控与 autovacuum 联动看。 autovacuum 拿不到锁会反复重启;而它需要
SHARE UPDATE EXCLUSIVE,与排队的 DDL 互为因果。两侧现象要放在一起解读。 - 升级到 PG14+ 尽快利用
waitstart。 它让等待时长的度量从「估算」变成「实测」,监控脚本的准确度直接上一个台阶。 - 保留一版手工自连接 SQL 作为教学与兜底。 老版本集群、以及需要展示「锁表结构」的培训场景里,它仍然是最好的工具。
下一节