在高并发环境中,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_table
echo ">>> 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.2
done
echo "\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. ---"
# 等待所有后台任务完成
wait
echo "\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 0636635778600
10549812740536 0177748 0 0 0 0762825788500
00549812739780 0177748 0 0 0 8700672998300
00549812738028 0177764 0 0 0 4607634668900
00549812737524 0177764 0 0 0 0384453339400
00549812737524 0177764 0 0 0 0168273039700
10549812737524 0177764 0 0 0 0148264019900
00549812737272 0177764 0 0 0 0177278029900
00549812737272 0177764 0 0 0 0142260019900
00549812737272 0177764 0 0 0 015425901990 0
第二步:数据库初探(发现"烟雾")
我们连接数据库,执行基础的活动查询:
SELECT pid, wait_event_type, wait_event, state, query
FROM 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_pid
FROM pg_stat_activity AS wait_act
JOIN 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.query
FROM
lock_hierarchy lh
JOIN
pg_stat_activity a ON a.pid = lh.pid
ORDER 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 st
10549556733508 0177792 0 0 0 0144259019900
00549556733508 0177792 0 0 0 0149267029800
00549556733508 0177792 0 0 0 0164282019900
00549556733508 0177792 0 0 0 0235330229600
00549556733508 0177792 0 0 0 0150267019900
00549556733508 0177792 0 0 0 0146260019900
00549556733508 0177792 0 0 0 0150261019900
00549556733508 0177792 0 0 0 0145254019900
00549556733508 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 -- 列出所有受害者的PID
FROM
pg_stat_activity AS wait_act
JOIN
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_query
ORDER 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_duration
FROM
pg_stat_activity AS wait_act
JOIN
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 query
FROM lock_hierarchy lh
ORDER BY path;
写在最后
这次故障的关键,并不在于数据库负载,而在于等待关系的层层传递。
当高并发系统出现"CPU 正常却整体不可用"的现象时,应优先考虑锁等待结构,而不是资源瓶颈。
阻塞链问题的隐蔽性在于:它不会触发死锁检测,也不会显著消耗 CPU,却足以让系统吞吐归零。
一次完整的阻塞链还原,比单条 SQL 分析更重要。
在复杂事务系统中,控制事务边界与缩短持锁时间,往往比优化执行计划更关键。
案例的价值不在于现象本身,而在于可复用的排查路径。
作者介绍
大家好,我是刘峰,安丫科技创始人 & 数据库技术高级讲师,专注于 PostgreSQL、国产数据库运维与迁移、数据库性能优化 等方向。
作为 PG中国分会官方授权讲师、PostgreSQL ACE 讲师认证专家,我长期活跃在****一线项目实战中,拥有 10年以上大型数据库管理与优化经验,曾深度参与电信、金融、政务等多个行业的数据库性能调优与迁移项目。
欢迎关注我,一起深入探索数据库的无限可能,技术交流不设限!
📌 觉得有收获的话,记得点赞、收藏、转发支持一下哦,别忘了关注我获取更多数据库干货~
安呀智数据坊|我们能做什么
无论你是业务系统的技术负责人,还是数据部门的第一响应人,我们都能为你提供可靠的支持:
- 数据库类型支持
Oracle / MySQL / PostgreSQL / SQL Server 等主流数据库
- 核心服务内容
性能优化 / 故障处理 / 数据迁移 / 备份恢复 / 版本升级 / 补丁管理
- 系统性支持
深度巡检 / 高可用架构设计 / 应用层兼容评估 / 运维工具集成
- 专项能力补充
定制课程培训 / 甲方团队辅导 / 复杂问题协作排查 / 紧急救援支持
📮 如果你有一张删不掉的表、一个跑不动的查询,或者一场说不清的升级风险,欢迎来找我们聊聊。