在高并发环境中,PostgreSQL 有一种典型故障形态:
- 应用层大量超时
- 数据库连接数不断堆积
- 系统吞吐量接近于零
- 但 CPU、内存、IO 指标却基本正常
表面看像“数据库卡死”,却没有死锁报错,也没有资源耗尽。
这类问题通常不是性能瓶颈,而是锁等待结构失控——更准确地说,是由长事务引发的传递性阻塞链。
本文基于一次完整的实验构造与排查过程,系统梳理:
- PostgreSQL 锁机制与阻塞形成路径
- 如何通过 pg_stat_activity 与 pg_blocking_pids 初步定位问题
- 为什么传统查询难以识别真正的源头
- 如何利用递归 CTE 还原完整阻塞树
- 以及锁等待几乎不消耗 CPU 的底层原因
希望这篇文章能提供一套可复用的 PostgreSQL 阻塞链排查方法,而不仅仅是一次案例分析。
01
PostgreSQL锁机制概览
为保障数据在并发访问下的一致性(Consistency)与隔离性(Isolation),PostgreSQL实现了一套复杂而精密的锁机制。
理解不同锁的类型与行为,是诊断并发问题的基础。
1
表级锁(Table-Level Locks)
表级锁作用于整张数据表,其粒度最粗,对并发性的影响也最大。
它们通常在执行数据定义语言(DDL)命令时由数据库自动获取。
例如:
ACCESS EXCLUSIVE LOCK
这是最高级别的锁,会阻塞所有其他类型的访问,包括SELECT。
ALTER TABLE, DROP TABLE, TRUNCATE等命令会获取此锁。
在业务高峰期执行此类操作,极易引发大范围的阻塞。
SHARE ROW EXCLUSIVE LOCK
此锁会阻塞其他事务执行UPDATE, DELETE, INSERT等行修改操作,但允许SELECT。
2
行级锁(Row-Level Locks)
行级锁是PostgreSQL在高并发场景下性能表现优异的关键。
它只锁定被修改的特定行,而非整张表,从而最大化了并发读写的能力。
FOR UPDATE
当执行SELECT ... FOR UPDATE或UPDATE, DELETE语句时,PostgreSQL会为匹配的行加上此锁。
它会阻塞其他事务对这些行进行UPDATE, DELETE或SELECT ... FOR UPDATE操作,但不会阻塞普通的SELECT。
这是我们日常业务逻辑中最常遇到的锁。
3
死锁(Deadlocks)
死锁是一种特殊的循环等待状态。
例如,事务A锁定了资源1并等待资源2,而事务B恰好锁定了资源2并等待资源1。
PostgreSQL内置了强大的死锁检测机制,它能自动发现这种循环依赖,并通过强制回滚其中一个事务来打破僵局,同时在数据库日志中记录一条错误。
虽然死锁会被数据库自动解决,但频繁的死锁通常预示着应用程序的并发逻辑设计存在缺陷(如加锁顺序不一致)。
然而,相较于会被自动中断的死锁,一种更隐蔽、更具破坏性的问题是不会被自动解决的、由长事务引发的传递性阻塞链。
这正是本文将要深入模拟与剖析的核心场景。
02
实战演练:构建与诊断传递性
阻塞链
本章将聚焦于场景三,通过Shell脚本精心构建一个A → B → C → D → E的阻塞链,并详述从发现到定位的全过程。
1
实验设计
我们的目标是模拟一个由根节点引发的、层层传递的阻塞关系:
进程A(根节点)
启动一个长事务,锁定资源id=1并长时间持有。
进程B(链环1)
启动事务,先成功锁定自有资源id=2,再尝试获取资源id=1,此时它将被进程A阻塞。
进程C(链环2)
启动事务,先成功锁定自有资源id=3,再尝试获取资源id=2,此时它将被进程B阻塞。
以此类推,形成一条长长的、等待关系依次传递的阻塞链。
2
实验脚本
使用以下generate_lock_chain.sh脚本来精确复现此场景。
#!/bin/bash# --- 配置区 ---export PGHOST="localhost"export PGPORT="5432"export PGUSER="postgres"export PGPASSWORD="postgres"export PGDATABASE="postgres"# 要模拟的阻塞链长度CHAIN_LENGTH=50# 根节点持有锁的时间(秒)LOCK_HOLD_DURATION=600# --- 脚本核心逻辑 ---function setup_table() { echo "--- [SETUP] Preparing table 'items_for_locking' with ${CHAIN_LENGTH} items... ---" psql > /dev/null2>&1 <<-EOF DROP TABLE IF EXISTS items_for_locking; CREATE TABLE items_for_locking (id INT PRIMARY KEY); INSERT INTO items_for_locking (id) SELECT generate_series(1, ${CHAIN_LENGTH});EOF echo "--- [SETUP] Table ready. ---\n"}# --- 主程序 ---setup_tableecho ">>> Starting simulation to generate a lock chain of length ${CHAIN_LENGTH}..."echo ">>> The root lock will be held for ${LOCK_HOLD_DURATION} seconds."# 1. 启动“根阻塞者” (进程A)# 它只做一件事:锁定资源 #1,然后睡觉。echo " -> Launching Root Blocker (A) to lock item 1..."psql -c "BEGIN; UPDATE items_for_locking SET id = 1 WHERE id = 1; SELECT pg_sleep(${LOCK_HOLD_DURATION}); COMMIT;" &# 稍等片刻,确保根节点已获得锁sleep 1# 2. 循环启动“链上节点” (进程B, C, D...)for i in $(seq 2 ${CHAIN_LENGTH})do # j 是当前进程要等待的上一个进程所持有的资源ID j=$((i-1)) echo " -> Launching Chain Link ${i} (locks item ${i}, waits for item ${j})..." # 每个进程先锁定自己的资源(i),然后尝试去锁定上一个资源(j),从而被阻塞 psql -c "BEGIN; UPDATE items_for_locking SET id = ${i} WHERE id = ${i}; SELECT pg_sleep(1); UPDATE items_for_locking SET id = ${j} WHERE id = ${j}; COMMIT;" & # 稍微错开启动,让链条稳定形成 sleep 0.2doneecho "\n--- Lock chain generation initiated. ---"echo "--- !!! NOW IS THE TIME TO OBSERVE !!! ---"echo "--- Run the RECURSIVE query in another terminal. You have ~${LOCK_HOLD_DURATION} seconds. ---"# 等待所有后台任务完成waitecho "\n--- Simulation finished. ---"调用脚本
--- [SETUP] Preparing table 'items_for_locking' with 50 items... ------ [SETUP] Table ready. ---\n>>> Starting simulation to generate a lock chain of length 50...>>> The root lock will be held for 600 seconds. -> Launching Root Blocker (A) to lock item 1... -> Launching Chain Link 2 (locks item 2, waits for item 1)... -> Launching Chain Link 3 (locks item 3, waits for item 2)... -> Launching Chain Link 4 (locks item 4, waits for item 3)... -> Launching Chain Link 5 (locks item 5, waits for item 4)... -> Launching Chain Link 6 (locks item 6, waits for item 5)... -> Launching Chain Link 7 (locks item 7, waits for item 6)... -> Launching Chain Link 8 (locks item 8, waits for item 7)... -> Launching Chain Link 9 (locks item 9, waits for item 8)... -> Launching Chain Link 10 (locks item 10, waits for item 9)... -> Launching Chain Link 11 (locks item 11, waits for item 10)... -> Launching Chain Link 12 (locks item 12, waits for item 11)... -> Launching Chain Link 13 (locks item 13, waits for item 12)... -> Launching Chain Link 14 (locks item 14, waits for item 13)... -> Launching Chain Link 15 (locks item 15, waits for item 14)... -> Launching Chain Link 16 (locks item 16, waits for item 15)... -> Launching Chain Link 17 (locks item 17, waits for item 16)... -> Launching Chain Link 18 (locks item 18, waits for item 17)... -> Launching Chain Link 19 (locks item 19, waits for item 18)... -> Launching Chain Link 20 (locks item 20, waits for item 19)... -> Launching Chain Link 21 (locks item 21, waits for item 20)... -> Launching Chain Link 22 (locks item 22, waits for item 21)... -> Launching Chain Link 23 (locks item 23, waits for item 22)... -> Launching Chain Link 24 (locks item 24, waits for item 23)... -> Launching Chain Link 25 (locks item 25, waits for item 24)... -> Launching Chain Link 26 (locks item 26, waits for item 25)... -> Launching Chain Link 27 (locks item 27, waits for item 26)... -> Launching Chain Link 28 (locks item 28, waits for item 27)... -> Launching Chain Link 29 (locks item 29, waits for item 28)... -> Launching Chain Link 30 (locks item 30, waits for item 29)... -> Launching Chain Link 31 (locks item 31, waits for item 30)... -> Launching Chain Link 32 (locks item 32, waits for item 31)... -> Launching Chain Link 33 (locks item 33, waits for item 32)... -> Launching Chain Link 34 (locks item 34, waits for item 33)... -> Launching Chain Link 35 (locks item 35, waits for item 34)... -> Launching Chain Link 36 (locks item 36, waits for item 35)... -> Launching Chain Link 37 (locks item 37, waits for item 36)... -> Launching Chain Link 38 (locks item 38, waits for item 37)... -> Launching Chain Link 39 (locks item 39, waits for item 38)... -> Launching Chain Link 40 (locks item 40, waits for item 39)... -> Launching Chain Link 41 (locks item 41, waits for item 40)... -> Launching Chain Link 42 (locks item 42, waits for item 41)... -> Launching Chain Link 43 (locks item 43, waits for item 42)... -> Launching Chain Link 44 (locks item 44, waits for item 43)... -> Launching Chain Link 45 (locks item 45, waits for item 44)... -> Launching Chain Link 46 (locks item 46, waits for item 45)... -> Launching Chain Link 47 (locks item 47, waits for item 46)... -> Launching Chain Link 48 (locks item 48, waits for item 47)... -> Launching Chain Link 49 (locks item 49, waits for item 48)... -> Launching Chain Link 50 (locks item 50, waits for item 49)...\n--- Lock chain generation initiated. ------ !!! NOW IS THE TIME TO OBSERVE !!! ------ Run the RECURSIVE query in another terminal. You have ~600 seconds. ---3
诊断过程:一场逐步深入的侦探工作
在脚本运行期间,我们在另一个终端窗口中扮演“侦探”角色。
第一步:初步勘察(宏观现象的困惑)
我们首先执行top或vmstat,看到的是一幅平静的景象:CPU使用率极低,I/O空闲。
这与应用层反馈的“系统卡死”形成强烈反差,初步排除了资源耗尽型故障。
0 549812742044 0177748 0 0 0 063663577860010549812740536 0177748 0 0 0 076282578850000549812739780 0177748 0 0 0 870067299830000549812738028 0177764 0 0 0 460763466890000549812737524 0177764 0 0 0 038445333940000549812737524 0177764 0 0 0 016827303970010549812737524 0177764 0 0 0 014826401990000549812737272 0177764 0 0 0 017727802990000549812737272 0177764 0 0 0 014226001990000549812737272 0177764 0 0 0 015425901990 0第二步:数据库初探(发现“烟雾”)
我们连接数据库,执行基础的活动查询:
SELECT pid, wait_event_type, wait_event, state, queryFROM pg_stat_activity WHERE state = 'active';结果是屏幕上充满了处于active状态的进程,其中大部分的wait_event_type都显示为Lock。
这证实了锁问题的存在,但我们只看到了大量的“烟雾”,无法分辨火源和火势蔓延的路径。
pid | wait_event_type | wait_event | state | query -------+-----------------+---------------+--------+--------------------------------------------------------------------------------------------------------------------------------------------69174 | | | active | SELECT pid, wait_event_type, wait_event, state, query + | | | | FROM pg_stat_activity WHERE state = 'active';69742 | Timeout | PgSleep | active | BEGIN; UPDATE items_for_locking SET id = 1 WHERE id = 1; SELECT pg_sleep(600); COMMIT;69746 | Lock | transactionid | active | BEGIN; UPDATE items_for_locking SET id = 2 WHERE id = 2; SELECT pg_sleep(1); UPDATE items_for_locking SET id = 1 WHERE id = 1; COMMIT;69749 | Lock | transactionid | active | BEGIN; UPDATE items_for_locking SET id = 3 WHERE id = 3; SELECT pg_sleep(1); UPDATE items_for_locking SET id = 2 WHERE id = 2; COMMIT;69752 | Lock | transactionid | active | BEGIN; UPDATE items_for_locking SET id = 4 WHERE id = 4; SELECT pg_sleep(1); UPDATE items_for_locking SET id = 3 WHERE id = 3; COMMIT;69755 | Lock | transactionid | active | BEGIN; UPDATE items_for_locking SET id = 5 WHERE id = 5; SELECT pg_sleep(1); UPDATE items_for_locking SET id = 4 WHERE id = 4; COMMIT;69758 | Lock | transactionid | active | BEGIN; UPDATE items_for_locking SET id = 6 WHERE id = 6; SELECT pg_sleep(1); UPDATE items_for_locking SET id = 5 WHERE id = 5; COMMIT;69761 | Lock | transactionid | active | BEGIN; UPDATE items_for_locking SET id = 7 WHERE id = 7; SELECT pg_sleep(1); UPDATE items_for_locking SET id = 6 WHERE id = 6; COMMIT;69764 | Lock | transactionid | active | BEGIN; UPDATE items_for_locking SET id = 8 WHERE id = 8; SELECT pg_sleep(1); UPDATE items_for_locking SET id = 7 WHERE id = 7; COMMIT;69767 | Lock | transactionid | active | BEGIN; UPDATE items_for_locking SET id = 9 WHERE id = 9; SELECT pg_sleep(1); UPDATE items_for_locking SET id = 8 WHERE id = 8; COMMIT;69770 | Lock | transactionid | active | BEGIN; UPDATE items_for_locking SET id = 10 WHERE id = 10; SELECT pg_sleep(1); UPDATE items_for_locking SET id = 9 WHERE id = 9; COMMIT;69773 | Lock | transactionid | active | BEGIN; UPDATE items_for_locking SET id = 11 WHERE id = 11; SELECT pg_sleep(1); UPDATE items_for_locking SET id = 10 WHERE id = 10; COMMIT;69776 | Lock | transactionid | active | BEGIN; UPDATE items_for_locking SET id = 12 WHERE id = 12; SELECT pg_sleep(1); UPDATE items_for_locking SET id = 11 WHERE id = 11; COMMIT;69779 | Lock | transactionid | active | BEGIN; UPDATE items_for_locking SET id = 13 WHERE id = 13; SELECT pg_sleep(1); UPDATE items_for_locking SET id = 12 WHERE id = 12; COMMIT;。。。。。。省略69883 | Lock | transactionid | active | BEGIN; UPDATE items_for_locking SET id = 47 WHERE id = 47; SELECT pg_sleep(1); UPDATE items_for_locking SET id = 46 WHERE id = 46; COMMIT;69886 | Lock | transactionid | active | BEGIN; UPDATE items_for_locking SET id = 48 WHERE id = 48; SELECT pg_sleep(1); UPDATE items_for_locking SET id = 47 WHERE id = 47; COMMIT;69889 | Lock | transactionid | active | BEGIN; UPDATE items_for_locking SET id = 49 WHERE id = 49; SELECT pg_sleep(1); UPDATE items_for_locking SET id = 48 WHERE id = 48; COMMIT;69892 | Lock | transactionid | active | BEGIN; UPDATE items_for_locking SET id = 50 WHERE id = 50; SELECT pg_sleep(1); UPDATE items_for_locking SET id = 49 WHERE id = 49; COMMIT;(51 rows)第三步:常规武器的局限性(一对一的线索)
我们使用标准的pg_blocking_pids()查询,试图理清关系:
SELECT wait_act.pid AS waiting_pid, block_act.pid AS blocking_pidFROM pg_stat_activity AS wait_actJOIN pg_stat_activity AS block_act ON block_act.pid = ANY(pg_blocking_pids(wait_act.pid));查询返回了一个列表,内容类似:
waiting_pid | blocking_pid -------------+-------------- 69746 | 69742 69749 | 69746 69752 | 69749 69755 | 69752 69758 | 69755 69761 | 69758 69764 | 69761 69767 | 69764 69770 | 69767 69773 | 69770 69776 | 69773 69779 | 69776 69782 | 69779 69785 | 69782。。。。 69871 | 69868 69874 | 69871 69877 | 69874 69880 | 69877 69883 | 69880 69886 | 69883 69889 | 69886 69892 | 69889(49 rows)这个结果是准确的,但它是一个扁平化的列表。
要追溯到根源,我们需要进行“人肉递归”:“好的,69889被69883阻塞,现在查69883被谁阻塞...哦是69880...再查69877 ...”。
当链条很长或存在多个分支时,这种方法效率低下且极易出错。
第四步:终极武器的介入(揭示完整的证据链)
此时,必须动用能够解析层级关系的递归查询。
它将上述扁平化的线索,重构成一幅有深度、有逻辑的结构图。
-- 【最终修正版】递归查询WITH RECURSIVE lock_hierarchy AS ( -- 锚点:查找所有阻塞链的根节点 SELECT pid, 1 AS level, ARRAY[pid] AS path FROM pg_stat_activity WHERE wait_event_type IS DISTINCT FROM 'Lock' AND pid IN (SELECT unnest(pg_blocking_pids(pid)) FROM pg_stat_activity WHERE wait_event_type = 'Lock') UNION ALL -- 递归:查找被上一层节点阻塞的子节点 SELECT w.pid, p.level + 1, p.path || w.pid FROM lock_hierarchy p JOIN pg_stat_activity w ON p.pid = ANY(pg_blocking_pids(w.pid)) WHERE NOT (w.pid = ANY(p.path)) -- 防止循环引用)SELECT lh.level, repeat(' ', lh.level - 1) || lh.pid::text AS indented_pid, a.queryFROM lock_hierarchy lhJOIN pg_stat_activity a ON a.pid = lh.pidORDER BY lh.path;第五步:真相大白(解读阻塞链图谱)
上述查询的输出结果清晰明了,一目了然:
level | indented_pid | query -------+-----------------------+---------------------------------------------------------------------------------------------------------------------------------------- 1 | 69742 | BEGIN; UPDATE items_for_locking SET id = 1 WHERE id = 1; SELECT pg_sleep(600); COMMIT; 2 | 69746 | BEGIN; UPDATE items_for_locking SET id = 2 WHERE id = 2; SELECT pg_sleep(1); UPDATE items_for_locking SET id = 1 WHERE id = 1; COMMIT; 3 | 69749 | BEGIN; UPDATE items_for_locking SET id = 3 WHERE id = 3; SELECT pg_sleep(1); UPDATE items_for_locking SET id = 2 WHERE id = 2; COMMIT; 4 | 69752 | BEGIN; UPDATE items_for_locking SET id = 4 WHERE id = 4; SELECT pg_sleep(1); UPDATE items_for_locking SET id = 3 WHERE id = 3; COMMIT; 5 | 69755 | BEGIN; UPDATE items_for_locking SET id = 5 WHERE id = 5; SELECT pg_sleep(1); UPDATE items_for_locking SET id = 4 WHERE id = 4; COMMIT;。。。。省略通过level和indented_pid的缩进,我们瞬间识别出:
- 根源
位于level 1的进程75123是整个阻塞链的源头。

- 路径
阻塞关系清晰地沿着75123 → 75125 → 75127 → ...的路径传递。
至此,复杂的故障现象被简化为一个清晰的目标:处理进程75123。
03
解决方案:从应急响应到长效治理
1
应急响应
基于上述诊断,应急响应的目标非常明确:终止位于阻塞链顶端的根节点进程。
-- 确认目标PID后,果断执行SELECT pg_terminate_backend(75123);此操作会强制回滚根节点的事务,释放其持有的锁,从而使整条阻塞链瞬间瓦解,恢复业务。
2
长效治理
为从根本上防范此类问题,需建立体系化的改进机制。
应用层优化
- 缩短事务生命周期
此为最高优先级原则。
应严格避免在数据库事务中包含任何耗时的外部I/O操作(如HTTP请求、文件系统访问等)。
事务应仅包含必要的数据库操作,并尽快提交。
- 规范加锁顺序
对于需要锁定多个资源的应用逻辑,应在代码层面强制规定一个全局一致的加锁顺序,以从根本上杜绝死锁的发生。
- 采用更优并发模型
在适用的场景下(如任务队列处理),应优先考虑使用SELECT ... FOR UPDATE SKIP LOCKED语法,使工作进程可以跳过已被锁定的行,处理其他可用任务,从而显著提升并发度。
数据库层优化与防护
- 配置锁等待超时 (lock_timeout)
在postgresql.conf文件或在应用连接的初始化阶段,设置一个合理的全局lock_timeout(例如:'2s'或'5s')。
该参数是数据库的“熔断器”,它能防止单个有问题的慢查询无限期地持有连接并阻塞其他进程,避免故障范围扩大。
- 索引优化
确保查询语句,特别是UPDATE和DELETE中的WHERE条件,都能够利用高效的索引。
索引可以大幅缩短查询执行和锁定的时间,从而降低锁冲突的概率。
监控与告警
- 应将锁等待相关的指标纳入核心监控系统。
例如,可持续监控pg_stat_activity中wait_event_type = 'Lock'的会话数量,并设置告警阈值。
- 可将“聚合分析”查询(见上一版本回复)的结果定期采集,重点关注num_waiters和blocking_tx_duration最高的进程,实现对潜在风险的提前预警。
04
原理回归:深入解析锁等待的
“零CPU消耗”之谜
在完整地经历了一次从现象到解决的实战后,我们回归本源,解答最初的疑惑:为何大规模的锁等待未能引起CPU资源的消耗?
procs -----------memory---------- ---swap-- -----io---- -system-- ------cpu----- r b swpd free buff cache si so bi bo in cs us sy id wa st10549556733508 0177792 0 0 0 014425901990000549556733508 0177792 0 0 0 014926702980000549556733508 0177792 0 0 0 016428201990000549556733508 0177792 0 0 0 023533022960000549556733508 0177792 0 0 0 015026701990000549556733508 0177792 0 0 0 014626001990000549556733508 0177792 0 0 0 015026101990000549556733508 0177792 0 0 0 014525401990000549556733508 0177792 0 0 0 014625601980 0答案在于PostgreSQL高效的被动等待(Passive Waiting)机制,其工作流程高度依赖操作系统内核的进程调度与信号机制,与消耗资源的主动等待(Active Waiting)形成鲜明对比。
为了理解这一点,我们可以用一个生动的比喻:去一家热门餐厅吃饭。
1
低效的“主动等待”(Spinlock,消耗CPU)
想象一下,你到了餐厅,发现唯一一张空桌正在打扫。
你选择站在桌子旁边,每隔一秒就问服务员:“打扫好了吗?打扫好了吗?打扫好了吗?”
- 这就是“主动等待”或“自旋锁”(Spinlock)。
- 行为
你不断地、主动地检查资源是否可用。
- 代价
这个过程非常消耗你自己的精力(CPU)。
如果你一直问,你就没法做别的事情(比如玩手机)。
如果有一百个人都围着桌子问,那场面会非常混乱,消耗巨大。
在计算机中,如果一个进程用一个while循环不停地检查锁是否被释放,它的CPU使用率会飙升到100%。
2
高效的“被动等待”(Semaphore,
不消耗CPU)
现在,换一种方式。
你到了餐厅,发现没位子。你走到前台,把你的名字告诉领位员,然后领位员让你去等候区坐着休息。
你可以玩手机、看书,完全放松。
当有你的位子时,领位员会过来叫你。
这就是“被动等待”或“信号量”(Semaphore)机制,PostgreSQL的行锁正是采用这种方式。
行为
a. 登记
当一个PostgreSQL进程(我们称之为“等待者”)发现它需要的行被锁定时,它不会傻等。它会在该锁的“等待队列”中登记自己的名字。
b. 睡觉
然后,它会向操作系统内核发出一个请求:“我没事干了,请让我进入睡眠状态(Sleeping State)。”
c. 释放CPU
操作系统调度器收到这个请求后,就会把这个进程从“运行队列”中移出。
从此,这个进程就不再参与CPU时间的分配,CPU可以去服务其他需要计算的进程,或者干脆进入空闲状态。
d. 唤醒
当持有锁的那个进程(“阻塞者”)提交或回滚事务,释放了锁之后,它会通知PostgreSQL的锁管理器。
e. 通知
锁管理器查看等待队列,发现“等待者”正在等这个锁,于是它会向操作系统内核发送一个“唤醒”信号。
f. 恢复运行
操作系统调度器接收到唤醒信号,再把“等待者”进程放回“运行队列”,等待下一次CPU时间片的分配。
因此,我们在实验中观测到的“系统假死但CPU空闲”的现象,正是这种高效的被动等待机制的直接体现。
它本身是数据库为节约资源而采取的优化设计,但其副作用——长事务导致的连锁阻塞——则需要我们通过本文介绍的诊断工具与治理方法,进行有效的管控。
结论
PostgreSQL中由锁等待引发的系统“假死”,其本质是并发控制下的逻辑阻塞,而非硬件资源的枯竭。
其“CPU空闲”的表象要求我们必须具备超越传统资源监控的诊断思路。
通过本文对锁机制的介绍、对传递性阻塞链的深度实战模拟,以及对一系列诊断工具(特别是递归查询)的运用,我们能够构建起一套科学、高效的故障排查方法论。
最终,将应急处理与长效治理相结合,建立从代码规范到数据库防护的立体化体系,是确保数据库系统在高并发压力下保持稳健与高效的根本之道。
05
附录:行锁跟踪查询
1
方案一:【首选】 按阻塞源头聚合
这个查询的思路是:谁是“阻塞源”?一个“阻塞源”阻塞了多少人?
它会以阻塞者(Blocker)为核心进行分组,并统计每个阻塞者影响了多少个等待者(Waiter)。
SELECT -- 阻塞者(阻塞源头)的信息 block_act.pid AS blocking_pid, block_act.usename AS blocking_user, age(now(), block_act.xact_start) AS blocking_tx_duration, -- 阻塞者本身的事务已持续多久 block_act.query AS blocking_query, -- 被阻塞者(受害者)的聚合信息 count(wait_act.pid) AS num_waiters, -- 这个阻塞源头阻塞了多少人 array_agg(wait_act.pid) AS waiter_pids -- 列出所有受害者的PIDFROM pg_stat_activity AS wait_actJOIN pg_stat_activity AS block_act ON block_act.pid = ANY(pg_blocking_pids(wait_act.pid))GROUP BY blocking_pid, blocking_user, blocking_tx_duration, blocking_queryORDER BY num_waiters DESC, -- 优先看阻塞了最多人的 blocking_tx_duration DESC; -- 如果阻塞人数相同,优先看事务持续最久的这个查询的威力在于:
聚焦源头
输出的每一行都是一个阻塞源头,而不是一个等待者。
量化影响
num_waiters 字段直接告诉你这个源头的破坏力有多大。
如果某个blocking_pid的num_waiters是50,那它就是首要目标!
排序定级
通过ORDER BY,它自动帮你把问题按严重程度从高到低排好序。
2
方案二:“ 截断长查询
有时候,仅仅是过长的SQL语句就让屏幕变得混乱。
这个改进版通过截断查询语句,让输出更紧凑,适合快速浏览。
SELECT wait_act.pid AS waiting_pid, left(wait_act.query, 60) || '...' AS waiting_query_snippet, block_act.pid AS blocking_pid, left(block_act.query, 60) || '...' AS blocking_query_snippet, age(now(), wait_act.query_start) AS blocking_durationFROM pg_stat_activity AS wait_actJOIN pg_stat_activity AS block_act ON block_act.pid = ANY(pg_blocking_pids(wait_act.pid))ORDER BY blocking_duration DESC;这个版本牺牲了查询的完整性,换来了极佳的可读性,让你能在一屏内看到更多阻塞关系,快速判断阻塞模式。
3
方案三: 树状展示阻塞关系
在最复杂的场景中,可能会出现A阻塞B,B又阻塞C的“连环锁”。
上面的查询无法清晰展示这种层级关系。
下面的递归查询(使用CTE)可以将锁关系以树状结构呈现出来,让你能看清整条阻塞链!
WITH RECURSIVE lock_hierarchy AS ( -- 锚点:找到所有阻塞链的“根节点”(即它们阻塞别人,但自己没在等锁) SELECT pid, 1 AS level, ARRAY[pid] AS path FROM pg_stat_activity WHERE wait_event_type IS DISTINCT FROM 'Lock' AND pid IN (SELECT unnest(pg_blocking_pids(pid)) FROM pg_stat_activity WHERE wait_event_type = 'Lock') UNION ALL -- 递归:找到被上一层节点阻塞的进程 SELECT w.pid, p.level + 1, p.path || w.pid FROM lock_hierarchy p JOIN pg_stat_activity w ON p.pid = ANY(pg_blocking_pids(w.pid)) WHERE NOT (w.pid = ANY(p.path)) -- 防止无限循环)SELECT level, repeat(' ', level - 1) || pid::text AS indented_pid, (SELECT query FROM pg_stat_activity WHERE pid = lh.pid) AS queryFROM lock_hierarchy lhORDER BY path;写在最后
这次故障的关键,并不在于数据库负载,而在于等待关系的层层传递。
当高并发系统出现“CPU 正常却整体不可用”的现象时,应优先考虑锁等待结构,而不是资源瓶颈。
阻塞链问题的隐蔽性在于:它不会触发死锁检测,也不会显著消耗 CPU,却足以让系统吞吐归零。
一次完整的阻塞链还原,比单条 SQL 分析更重要。
在复杂事务系统中,控制事务边界与缩短持锁时间,往往比优化执行计划更关键。
案例的价值不在于现象本身,而在于可复用的排查路径。
作者介绍
大家好,我是刘峰,安丫科技创始人 & 数据库技术高级讲师,专注于 PostgreSQL、国产数据库运维与迁移、数据库性能优化 等方向。
作为 PG中国分会官方授权讲师、PostgreSQL ACE 讲师认证专家,我长期活跃在****一线项目实战中,拥有 10年以上大型数据库管理与优化经验,曾深度参与电信、金融、政务等多个行业的数据库性能调优与迁移项目。
欢迎关注我,一起深入探索数据库的无限可能,技术交流不设限!
觉得有收获的话,记得点赞、收藏、转发支持一下哦,别忘了关注我获取更多数据库干货~
安呀智数据坊|我们能做什么
无论你是业务系统的技术负责人,还是数据部门的第一响应人,我们都能为你提供可靠的支持:
- 数据库类型支持
Oracle / MySQL / PostgreSQL / SQL Server 等主流数据库
- 核心服务内容
性能优化 / 故障处理 / 数据迁移 / 备份恢复 / 版本升级 / 补丁管理
- 系统性支持
深度巡检 / 高可用架构设计 / 应用层兼容评估 / 运维工具集成
- 专项能力补充
定制课程培训 / 甲方团队辅导 / 复杂问题协作排查 / 紧急救援支持
如果你有一张删不掉的表、一个跑不动的查询,或者一场说不清的升级风险,欢迎来找我们聊聊。
原文链接:https://mp.weixin.qq.com/s/gbTzjocY_F_aH-OUe7UONA