生产环境里突然一堆应用连不上库,后端日志全是 "canceling statement due to conflict with recovery" 或者干脆卡死在获取连接池连接上,十有八九就是锁等待在作祟。这时候你打开pg_stat_activity,会看到一大片wait_event不是ClientRead,而是Lock,其中赫然躺着几个pid。这些pid就是你要找的线索——顺着它,你才能从一团乱麻里揪出真正锁住表的那个会话。
今天这篇就聊聊我在 Postgres 锁等待排查里最常用、也最有效的一套方法:如何借助 PID 一路顺藤摸瓜,找到锁的源头。无论你是 DBA、后端开发还是运维,这套思路都能帮你少熬夜。我会把原理、实操 SQL 和踩过的坑全部写出来。
1. 锁等待到底是怎么出现的,PID 为什么是破局关键
1.1 一个典型的“卡死”现场长什么样
先还原一下真实场景。某天早上业务方说订单表写入超时,我连上数据库,发现pg_stat_activity里有几十个会话都卡在同一条INSERT语句上,state全是active,但wait_event_type却是Lock。此时应用侧的表现就是接口越来越慢,直到连接池被占满,新请求直接排队。
其实每个会话都是一个操作系统进程,都有一个独立的 PID。在 PostgreSQL 里,PID 不只是操作系统层面的进程号,它还是锁管理机制的核心标识。pg_locks表里清楚地记录了每个锁被哪个 PID 持有或者等待,而pg_stat_activity表里又记录了每个 PID 当前执行的 SQL、会话状态、等待事件。两张表通过 PID 关联,就能形成一条完整的锁等待链路。
1.2 为什么不能只靠“看表面”
很多初学者遇到这种问题,第一反应是翻日志,或者直接kill -9某个看起来很可疑的会话。但锁等待的根源通常是隐藏的:一般你看到的被阻塞会话是“受害者”,真正持有锁的“凶手”可能在很前面的查询里已经执行完,却因为事务没提交而一直占着茅坑。
更麻烦的是,Postgres 的锁等待是可以级联的。A 在等 B,B 在等 C,如果你只杀掉了 A,业务依然卡着。所以定位锁源必须沿着 PID 递归往上追,找到链条最顶端的那个 PID,才有意义。这也是为什么我会说 PID 是一把钥匙,它能把pg_locks和pg_stat_activity这两张表串起来,画出阻塞树。
1.3 锁等待问题的几个高频来源
根据我的经验,线上最常见的锁等待来源就这几类:
- 长事务持锁不释放。比如某个会话里开了事务,执行了
UPDATE,但忘了COMMIT,然后人就走开了。这种最坑。 - 大量并发执行
UPDATE或DELETE同一批行,互相等待行锁。 - DDL 操作,比如
ALTER TABLE加了ACCESS EXCLUSIVE锁,和普通的 DML 语句互斥。 - 外键约束触发的一些额外锁,特别是删除父表行时,子表上的行锁容易被忽略。
- autovacuum 与业务语句抢锁,常见于大表频繁更新后的 vacuum 进程持有锁。
这些问题都有一个共同点:症状都在“被阻塞的 PID”身上,而根源却在“另一个 PID”身上。所以接下来,我们需要一套系统性的排查思路,核心就是围绕 PID 做关联分析。
2. 核心排查思路:用 pg_locks 和 pg_stat_activity 绘制锁等待地图
2.1 先搞清楚这两张核心系统表的结构
pg_locks记录的是数据库内的所有锁对象,每一行代表一个已授予或等待中的锁。它有几个关键字段对定位很重要:
pid:持有或等待这个锁的进程 ID,也就是我们说的核心标识。locktype:锁类型,比如relation、transactionid、tuple、virtualxid等。database:锁关联的数据库 OID,因为锁是数据库级的对象。relation:锁关联的表的 OID,如果是关系对象的话。granted:布尔值,true表示该 PID 已经获得了锁,false表示该 PID 正在等待这把锁。mode:锁模式,比如AccessShareLock、RowExclusiveLock、AccessExclusiveLock等。
再来看pg_stat_activity。它会为每个后端进程保留一行状态记录,字段包括:
pid:进程 ID,和pg_locks.pid一一对应。usename:执行查询的数据库用户。datname:连接的数据库名。application_name:连接来源的应用名,通常由连接池配置。client_addr:客户端 IP,方便找到是哪个机器发起的会话。state:active、idle in transaction、idle等状态。wait_event_type和wait_event:当前等待事件类型和名称。
所以排查锁等待,本质就是 JOIN 这两张表,把“在等锁的 PID”和“拿着锁的 PID”对应起来。这不是什么高深魔法,但光用一个简单 JOIN 往往不够,因为同一张表可能同时存在多把锁,而且一个 PID 可能同时等待多把锁。
2.2 一个简单可用的第一版查询
我先给一个入门版本的查询,帮你快速看到当前有哪些 PID 在等待锁,以及它们在等什么:
SELECT blocked.pid AS waiting_pid, blocked.query AS waiting_query, blocker.pid AS blocking_pid, blocker.query AS blocking_query, blocked.wait_event_type, blocked.wait_event FROM pg_stat_activity blocked JOIN pg_locks l1 ON l1.pid = blocked.pid AND NOT l1.granted JOIN pg_locks l2 ON l2.pid <> blocked.pid AND l2.locktype = l1.locktype AND l2.database = l1.database AND l2.relation = l1.relation AND l2.granted JOIN pg_stat_activity blocker ON blocker.pid = l2.pid WHERE blocked.wait_event_type = 'Lock';这个查询的思路是:先找到所有granted = false的锁,锁定等待方 PID;再找到同一对象上granted = true的锁,锁定持有方 PID。用blocked.wait_event_type = 'Lock'过滤可以剔除一些干扰项。
但你必须知道,它不完美。一个明显的问题是:如果阻塞持有方也在等待另一把锁,这条查询就只给了你第一层关联,你还要继续往上找。层级一深,肉眼看得头晕。
2.3 更优雅的官方函数 pg_blocking_pids
从 PostgreSQL 9.6 开始,内置了函数pg_blocking_pids(integer),它可以直接返回阻塞指定 PID 的进程 PID 数组。这个函数做的就是递归查找阻塞源,比你自己写 JOIN 更省心。比如我想看某个 PID 到底被谁阻塞:
SELECT pid, usename, state, query, pg_blocking_pids(pid) AS blocker_pids FROM pg_stat_activity WHERE pid = 12345;它会返回一个数组,比如{9876},表示 PID 12345 正被 9876 阻塞。如果blocker_pids是空数组,说明这个 PID 没有在等锁,一切正常。这个函数最大的好处是,Postgres 内部已经处理了虚拟事务 ID 和事务 ID 的关联,不用你手动处理virtualxid和transactionid的匹配问题。
不过要注意,pg_blocking_pids返回的是“直接阻塞者”,如果阻塞链是 A 阻塞 B,B 阻塞 C,那么查询 C 的时候只会返回 B,不会直接返回 A。你还需要顺着 B 的 PID 再查一次,才能找到 A。所以我的经验是,配合递归查询或者手工追查,才能把完整链条画出来。
3. 完整实操:一步步从 PID 挖出锁源并安全处理
3.1 第一步:快速定位所有等待锁的会话
我通常第一步就是跑一个能给出全局视图的查询,把当前所有等待锁的会话和它们的状态捞出来:
SELECT pid, datname, usename, application_name, client_addr, state, wait_event_type, wait_event, pg_blocking_pids(pid) AS blocker_pids, left(query, 100) AS query_preview FROM pg_stat_activity WHERE wait_event_type = 'Lock' ORDER BY pid;这个查询输出里,blocker_pids列就告诉你了每个等待会话的“敌人”是谁。如果blocker_pids是{1234, 5678},通常说明多个 PID 联合持有同一把锁,或者存在多级锁等待。
注意一个细节:state如果是idle in transaction,说明这个会话已经执行完事务内最后一条 SQL,但事务始终没提交,锁还没释放。这往往是卡住一切的元凶。
3.2 第二步:追查阻塞者的真实状态和 SQL
拿到阻塞者 PID 之后,别急着杀。先看一下阻塞者本身的状态,它的query列能告诉我们它到底在干什么:
SELECT pid, datname, usename, application_name, client_addr, state, wait_event_type, wait_event, pg_blocking_pids(pid) AS blocker_pids, query FROM pg_stat_activity WHERE pid = ANY(ARRAY[1234, 5678]);这里有几种常见结果,处理方式完全不同:
- 阻塞者
state是active,正在执行一个很长的查询。这种情况可能是真有大查询占用资源,也可能是它自己也卡住了,还得继续往上追blocker_pids。 - 阻塞者
state是idle in transaction,SQL 已经执行完但事务没提交。这是最有嫌疑的情况,通常直接commit或者终止这个后端就能解决。 - 阻塞者也是一个
wait_event_type = 'Lock'的会话,说明它是级联链条的中间层,继续用pg_blocking_pids追它的阻塞者。 blocker_pids为空数组,但它还在阻塞别人,说明它没有等待锁,单纯是持锁后长时间不提交。
3.3 第三步:形成完整的阻塞链,找到最顶层源头
如果你只有两三层的锁等待,手工追一遍就够了。但线上常常出现十几层嵌套,这时候我建议用递归 CTE 把这个链条完整拉出来。下面是我在 PostgreSQL 12 及以上版本常用的一段递归查询:
WITH RECURSIVE lock_chain AS ( SELECT a.pid, a.state, a.query, a.wait_event_type, a.wait_event, pg_blocking_pids(a.pid) AS blocker_pids, 1 AS depth, ARRAY[a.pid] AS path FROM pg_stat_activity a WHERE a.pid = 12345 -- 从受害 PID 开始 UNION ALL SELECT b.pid, b.state, b.query, b.wait_event_type, b.wait_event, pg_blocking_pids(b.pid) AS blocker_pids, c.depth + 1, c.path || b.pid FROM lock_chain c CROSS JOIN LATERAL unnest(c.blocker_pids) AS blocker_pid JOIN pg_stat_activity b ON b.pid = blocker_pid WHERE NOT b.pid = ANY(c.path) ) SELECT * FROM lock_chain ORDER BY depth;这个查询会从你指定的“受害 PID”出发,一层一层往上爬,直到阻塞者不再被任何其他 PID 阻塞。每行会带一个depth深度,方便你看谁在最顶端。最顶端的那个 PID 通常就是彻底的锁源。
但注意,这个递归查询有个前提,就是阻塞链不能有环。如果存在锁环,会陷入无限递归,所以我加了WHERE NOT b.pid = ANY(c.path)来防环。遇到死锁时,Postgres 的 deadlock detector 通常会在几秒内自动解决,如果真遇到长时间死锁,那就得人工介入了。
3.4 第四步:确认锁源后安全处理
找到最顶端的 PID 之后,处理方式无非两种:等它自己结束,或者主动终止会话。我自己的原则是:
- 如果
state是active,而且查询看起来还在正常干活,先评估一下执行时间,再决定要不要等。如果一条 SQL 已经跑了二十分钟还在持锁,那大概率有问题,可以直接联系持有会话的应用方,让他们自行提交或回滚。 - 如果
state是idle in transaction,说明事务卡住不动了,这是风险最高的状态。一般直接终止这个后端是最快的选择。
终止会话我喜欢用pg_terminate_backend,而不是粗暴地kill -9:
SELECT pg_terminate_backend(1234);pg_terminate_backend会向目标后端发送一个信号,让它礼貌地取消当前事务并退出。这比SELECT pg_cancel_backend(1234);更彻底,因为pg_cancel_backend只能取消正在运行的查询,对idle in transaction状态无效。如果pg_terminate_backend都无效,才考虑去操作系统层面kill <pid>。
这里有个重要提示:终止会话前,一定再三确认 PID。我见过有人手滑把正在跑重要迁移的会话给终止了,结果只能从备份恢复。你可以用下面的查询先获取完整的会话信息:
SELECT pid, datname, usename, application_name, client_addr, state, now() - xact_start AS xact_age, query FROM pg_stat_activity WHERE pid = 1234;重点看xact_start,如果事务已经运行了几个小时,多半是异常会话;如果只跑了十几秒,那可能只是正常操作和某个大查询撞上了,需要再权衡一下。
3.5 实战中的参数考量:锁等待超时与连接池保护
这次排查完之后,还必须做点加固,不然下次还会再犯。Postgres 提供了一个参数lock_timeout,可以给每条事务设置锁等待的最大毫秒数。我通常建议在应用侧连接初始化时设置一个合理的值,比如 5 秒:
SET lock_timeout = '5s';这样即使某个会话在等锁,也不会无限期地把连接池占满。但注意,这只对事务内的新语句生效,不会影响已经处于锁等待中的语句。所以更可靠的方案是在连接池层面(比如 PgBouncer)配合设置连接超时,以及在上游限流。
另外,很多锁等待问题其实和事务隔离级别有关。默认的read committed在UPDATE相同行时,如果并发高,行锁等待是不可避免的;但如果是业务逻辑设计不当导致长事务持有锁,那就要考虑把大事务拆小,把计算挪到事务外面去。
4. 高频疑难杂症:这些坑我替你踩过了
4.1 查询看到锁源 PID,但下一秒它就消失了
这种情况很常见。你查pg_stat_activity看到一个 PID 阻塞了很多人,但等你准备pg_terminate_backend的时候,它已经自己退出了。因为被阻塞的会话往往是应用里的超时重试机制触发的,持有锁的会话可能刚好COMMIT了。
我的建议是:排查过程中不要依赖截屏,应该写成一个监控查询持续观察。比如每 5 秒跑一次,把“哪个 PID 阻塞了多少会话”记录下来,观察趋势。如果阻塞源 PID 反复变化,问题大概率是应用层在疯狂重试短事务,而不是某个长事务卡住。
4.2 pg_locks 里有很多锁,但搞不清楚哪个才是关键
pg_locks是一个全局视图,里面包含大量系统的共享锁。如果你直接全表查询,会看到成百上千行,很难分辨。这时候先过滤掉granted = true的对象,只看等待中的锁:
SELECT locktype, database, relation, page, tuple, virtualxid, transactionid, classid, objid, objsubid, mode, pid FROM pg_locks WHERE NOT granted;这个查询结果通常非常少,一般就是几个等锁的会话。再根据pid去pg_stat_activity里看具体 SQL。如果locktype是tuple,说明是行级锁;如果是relation,则是表级锁。理解锁类型能帮你快速判断冲突的原因。
比如最常见的relation锁冲突,通常是因为有会话持有AccessExclusiveLock,也就是在做ALTER TABLE、TRUNCATE、DROP TABLE等 DDL 操作。而普通的INSERT、UPDATE只需要RowExclusiveLock。两者并不兼容,所以 DDL 会堵住所有 DML。
4.3 死锁出现了,但它自己解了,u200b我还需要做什么
Postgres 有 死锁检测机制,默认每秒钟跑一次。一旦检测到死锁,它会主动终止其中一个被牵连的事务,并抛出一条错误信息,日志里长这样:
ERROR: deadlock detected DETAIL: Process 8123 waits for ShareLock on transaction 456; blocked by process 4567. Process 4567 waits for ShareLock on transaction 8123; blocked by process 8123.死锁自动解决后,应用会收到异常,但你不需要额外处理。真正要做的是从代码层面避免死锁:保证所有事务按照相同的顺序访问表或行,尽量不要在一个事务里先更新表 A 再更新表 B,另一个事务却先更新 B 再更新 A。如果死锁频繁,那就得重点审查最核心那几个业务模块的 SQL 顺序。
4.4 autovacuum 进程持锁导致业务卡顿
这是一个容易被忽视的锁源。autovacuumworker 进程的 PID 会在pg_stat_activity里以backend_type = 'autovacuum worker'出现。它通常会持有ShareUpdateExclusiveLock,这个锁本身和普通 DML 不冲突,但如果 autovacuum 正在清理一个超大表,它可能持有较长时间的锁,从而和某些需要更强锁的 DDL 冲突。
处理办法是:先看它的query,确认是不是 autovacuum 在跑大表。如果确实需要,可以调低autovacuum_vacuum_cost_delay,或者暂停业务低峰期的 DDL 计划。更根本的招数是定期主动执行VACUUM,避免 autovacuum 在高峰期突然攒了一大堆旧版本需要清理。
5. 手工排查之外:我推荐的一套监控组合拳
5.1 用 pg_stat_activity 快照 + pg_blocking_pids 做定时记录
排查锁等待,最怕的是事后复现难。我常用的方法是写一个小脚本,每隔几秒把pg_stat_activity和pg_locks的关键字段存储到一张历史表里。问题发生时,直接查历史表,就能还原当时是哪几个 PID 在打架。
一个简单的定时采集 SQL 可以这样写:
CREATE TABLE IF NOT EXISTS lock_snapshot ( captured_at timestamptz, pid int, datname text, usename text, state text, wait_event_type text, wait_event text, blocker_pids int[], query text ); INSERT INTO lock_snapshot SELECT now() AS captured_at, pid, datname, usename, state, wait_event_type, wait_event, pg_blocking_pids(pid) AS blocker_pids, query FROM pg_stat_activity WHERE wait_event_type = 'Lock';配合系统 cron 定时执行,就能形成一条时间线。我从这个方式中受益很多——有一次凌晨的锁问题,用户只反馈“早上有段时间很卡”,系统日志里也没什么异常,就是靠这个快照表把 3:17 到 3:19 之间的阻塞链完整还原了出来。
5.2 直接使用 pg_stat_statements 配合锁等待时间
如果锁等待经常和某些 SQL 绑定在一起,你可以启用pg_stat_statements扩展。它会统计每条 SQL 的执行次数、总耗时和锁等待时间。虽然它本身不直接给出锁源 PID,但可以把那些耗时异常、锁等待占比很高的 SQL 揪出来,提前优化。
启用方法很简单,但需要重启数据库:
CREATE EXTENSION pg_stat_statements;然后修改postgresql.conf里的shared_preload_libraries = 'pg_stat_statements'。之后查询:
SELECT query, calls, total_time, lock_time FROM pg_stat_statements ORDER BY lock_time DESC LIMIT 20;这个查询能帮你从“代码层面”找到总是引发锁冲突的语句。很多时候你不需要半夜爬起来翻 PID,而是改掉一个 SQL,问题就从根上消失了。
5.3 一条建议:把锁监控接入现有告警体系
最后一条是运维经验层面的建议。与其等问题爆发时靠人工跑查询,不如直接把锁等待数量做成监控指标。比如每分钟采集一次pg_stat_activity里wait_event_type = 'Lock'的会话数量,超过阈值就告警。
用什么工具不重要,Prometheus加postgres_exporter,或者直接写个简单脚本往监控系统里推数据都可以。重要的是告警语义要明确:不是“数据库有锁”,而是“有 N 个会话等待锁超过 M 分钟”。这能让你在业务还没明显受损时,就提前处理异常长事务。
6. 后续扩展与一些个人心得
排查锁等待这事,本质上就是一场“找凶手”的游戏。PID就是指纹,pg_locks是案发现场,pg_stat_activity是审讯记录。我从最早在pg_locks里大海捞针,到后来依赖pg_blocking_pids一秒钟定位,最大的感悟就是:工具用得越熟,定位越快,但千万别在操作前省略了“确认 PID 身份”这步。
再分享一个很多人忽略的小技巧:如果你用pg_terminate_backend杀掉了锁源,但业务还是继续卡顿,多半是因为应用连接池会自动重连并重放那条事务,于是新 PID 又重新开始执行同样的 SQL。这时候光杀 PID 不够,你得先和业务方确认是不是有人在重试长事务,或者某个定时任务一直发起冲突的 SQL。
最后补充一点,Postgres 15 之后的pg_stat_activity里多了query_id,可以把同一来自应用的重复 SQL 聚合起来;如果你在用新版本,不妨也把这个字段纳入你的排查脚本里。锁等待排查永远不会是一个“一次搞定”的任务,但只要你能熟练地围绕 PID 做关联分析,再复杂的锁问题也会变得有迹可循。