ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

PostgreSQL锁等待排查:用PID顺藤摸瓜定位锁源

PostgreSQL锁等待排查:用PID顺藤摸瓜定位锁源 生产环境里突然一堆应用连不上库后端日志全是 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 在等 BB 在等 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方便找到是哪个机器发起的会话。stateactive、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 阻塞 BB 阻塞 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 transactionSQL 已经执行完但事务没提交。这是最有嫌疑的情况通常直接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 -9SELECT 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 做关联分析再复杂的锁问题也会变得有迹可循。
返回列表