本篇聊 PostgreSQL 多进程模型下的「并发三件套」:重量级锁(Lock)、轻量级锁(LWLock)、自旋锁(spinlock);以及兜住死锁的 DeadLockCheck,和那个「连数涨了、明明没锁冲突却变慢」的元凶——GetSnapshotData。我们用 gdb 拆出一次真实死锁、一次高低并发快照对比、一次 LWLock 获取,逐一定死。
0. 先说结论(给赶时间的你)
- 锁分三层,各管一摊
:
1. 重量级锁(Lock):保护表、行、事务,带等待队列,能跨事务、能死锁检测(走 lock.c / proc.c)。 2. 轻量级锁(LWLock):保护共享内存结构(BufferDesc、ProcArray、WAL 缓冲……),没有死锁检测,靠「自旋一小会儿 + 睡到条件变量」(lwlock.c:1180)。 3. 自旋锁(spinlock / slock_t):CPU 级的 TAS(test-and-set),只在纳秒~微秒级临界区用,几乎不睡眠(s_lock.c:98)。
- 死锁检测是 Lock 层的兜底
:进程准备睡在锁上之前, CheckDeadLock(proc.c:1824)调用DeadLockCheck(deadlock.c:223)递归找环;找到就 kill 掉「正在等待的那个进程」(受害者)。我们实锤了一个16706 ↔ 16801的环。 GetSnapshotData(procarray.c:2175)是快照获取的咽喉:它持 ProcArrayLock(LW_SHARED),扫描全部numProcs个后端算xmin。连接数越高,扫描越贵——实测低并发numProcs=3、高并发(50 个空闲连接)numProcs=53,约 17 倍。- PG18 已加
GetSnapshotDataReuse(procarray.c:2095)缓解:若 xactCompletionCount自上次构建后没变,直接复用上次快照、跳过 O(N) 扫描。但并发写提交会让它失效、重新扫描——瓶颈仍在,只是被推后。 - PG19 在锁可观测性上补强
:新增 pg_stat_lock视图、log_lock_waits默认 on、max_locks_per_transaction默认 64→128(已在本机 PG19 实测确认)。
下面用 gdb 把这几条逐一定死。
1. 场景:两类「莫名其妙变慢 / 报错」
场景 A——死锁:两个事务各持有一行,又去改对方那行。
-- 会话 A: -- 会话 B:
BEGIN; BEGIN;
UPDATE dltest SET v=1 WHERE id=1; UPDATE dltest SET v=2 WHERE id=2;
UPDATE dltest SET v=1 WHERE id=2; UPDATE dltest SET v=2 WHERE id=1; -- 互锁A 等 B 的 id=2,B 等 A 的 id=1,谁也不放——这就是死锁。
场景 B——连数涨了却没锁冲突,查询变慢:一个 OLTP 系统连接池从 20 涨到 200,没有锁等待,但 P99 延迟整体抬升。根因往往不是锁,而是每个新事务都要拿一次快照,而拿快照要扫一遍所有后端(见 §3)。
2. gdb 实测之一:死锁检测 DeadLockCheck
复现场景 A(dltest(id int primary key, v int) 两行),对会话 B 挂 gdb,断 deadlock.c:223。当 B 的第二条 UPDATE 试图改 id=1(被 A 持有)而进入锁等待时,断点命中:
Breakpoint 1, DeadLockCheck (proc=0x7fffedd50140) at deadlock.c:223
223 nCurConstraints = 0;
===== DeadLockCheck entered: proc->pid=16706 =====
#0 DeadLockCheck (proc=0x7fffedd50140) at deadlock.c:223
#1 CheckDeadLock () at proc.c:1824
#2 ProcSleep (locallock=0x1326a98) at proc.c:1459
#3 WaitOnLock (locallock=0x1326a98, owner=0x1309488) at lock.c:1971
#4 LockAcquireExtended (locktag=..., lockmode=5, ...) at lock.c:1220
#5 LockAcquire (locktag=..., lockmode=5, ...) at lock.c:813
#6 XactLockTableWait (xid=48475, ...) at lmgr.c:697
#7 heap_update (relation=..., otid=..., newtup=..., cid=1, ...) at heapam.c:3630关键事实:
DeadLockCheck不是被「加锁」直接调用的,而是进程准备睡在锁上时,由 ProcSleep(proc.c:1459)→CheckDeadLock(proc.c:1824)调用的最后一道检查。调用链:heap_update → XactLockTableWait(等对方事务结束)→LockAcquire→WaitOnLock→ProcSleep→CheckDeadLock→DeadLockCheck。lockmode=5是「对对方事务 id 的 ShareLock」——PostgreSQL 的行锁本质是「等持有者事务结束」, DETAIL里写得很直白:
ERROR: deadlock detected
DETAIL: Process 16706 waits for ShareLock on transaction 48475; blocked by process 16801.
Process 16801 waits for ShareLock on transaction 48474; blocked by process 16706.- 受害者是 B(16706)
: DeadLockCheck递归DeadLockCheckRecurse(deadlock.c:312)找到环后返回DS_HARD_DEADLOCK,调用方LockAcquireExtended立刻报错——正在等待、且后被检测到的那个进程被杀,另一个(A,16801)随后解除阻塞、两 UPDATE 均成功提交。
3. gdb 实测之二:GetSnapshotData 高并发瓶颈
一个事务启动时(exec_simple_query / PortalStart)要拿快照,最终落到 GetSnapshotData。我们对一个 SELECT count(*) FROM pg_class 挂 gdb,断 procarray.c:2233(复用检查点)和 procarray.c:2501(snapshot->xmin 赋值点),对比「低并发」与「开 50 个空闲连接」:
-- 低并发(仅本连接 + 少量系统/后台进程)
===== GetSnapshotData numProcs=3 =====
#0 GetSnapshotData (snapshot=0x128cca0 <CurrentSnapshotData>) at procarray.c:2233
#1 GetTransactionSnapshot () at snapmgr.c:342
#2 exec_simple_query (query_string="SELECT pg_sleep(8); SELECT count(*) FROM pg_class;") at postgres.c:1163
-- 高并发(再开 50 个空闲连接 SELECT pg_sleep(120))
===== GetSnapshotData numProcs=53 =====
#0 GetSnapshotData (snapshot=0x128cca0 <CurrentSnapshotData>) at procarray.c:2233
#1 GetTransactionSnapshot () at snapmgr.c:342
#2 exec_simple_query (...) at postgres.c:1163关键事实:
numProcs就是从 ProcArrayStruct->numProcs取出的「参与快照计算的 PGPROC 数量」,它随连接数 1:1 增长(低并发 3,高并发 53,约 17 倍)。GetSnapshotData内部的主体就是一个for (pgxactoff = 0; pgxactoff < numProcs; pgxactoff++)的扫描。- 这就是「连数涨了、没锁冲突也变慢」的咽喉
:每个要拿快照的事务,都要在持 ProcArrayLock(LW_SHARED)下,把全部numProcs个后端扫一遍、取最小xid当xmin。连接越多,单次快照越贵。 - PG18 的缓解:
GetSnapshotDataReuse(procarray.c:2095)。当它发现 TransamVariables->xactCompletionCount自上次构建快照后没变,就直接复用上次快照、跳过整个 O(N) 扫描。我们前两次实验里procarray.c:2501(snapshot->xmin赋值)一次都没命中——就是因为被复用了。佐证如下(制造并发写强制重算后,2501才命中):
===== GetSnapshotData REUSE-check numProcs=3 =====
Breakpoint 2, GetSnapshotData (...) at procarray.c:2501
2501 snapshot->xmin = xmin;
===== snapshot->xmin=48478 (RECOMPUTED) =====只要期间有带 xid 的事务提交(xactCompletionCount 递增),GetSnapshotDataReuse 返回 false,主体重新扫描、xmin 被重算。所以并发写越频繁,快照重算越频繁,O(N) 代价越容易被实付。
4. gdb 实测之三:LWLock 获取 + 自旋锁
对一条 INSERT ... SELECT 挂 gdb,断 lwlock.c:1182(第一次 LWLockAcquire 命中即停),并保留 s_lock.c:98 / s_lock.c:126 两个自旋锁断点:
Breakpoint 1, LWLockAcquire (lock=0x7fffe50b5f00, mode=LW_SHARED) at lwlock.c:1182
1182 PGPROC *proc = MyProc;
===== LWLockAcquire mode=1 =====
#0 LWLockAcquire (lock=0x7fffe50b5f00, mode=LW_SHARED) at lwlock.c:1182
#1 GetSnapshotData (snapshot=0x128cca0 <CurrentSnapshotData>) at procarray.c:2231
#2 GetTransactionSnapshot () at snapmgr.c:342
#3 exec_simple_query (...) at postgres.c:1163关键事实:
第一次 LWLockAcquire的命中点,正是GetSnapshotData内部以LW_SHARED获取ProcArrayLock(procarray.c:2231)。这把前文两个主题串起来了:快照扫描本身,就持有一把轻量级锁——ProcArrayLock是 LWLock,高并发下它也是争用点(虽是 LW_SHARED 可并发读,但写者要 LW_EXCLUSIVE)。- 自旋锁断点(
s_lock.c:98/perform_spin_delay:126)在低争用下单客户端测试下全程未触发。这恰恰印证了自旋锁的特性:它只在极短临界区忙等,几乎不会走到「自旋超时后睡眠」的分支( perform_spin_delay在自旋超过NUM_SPINLOCK_DELAY_CHECKS后才pg_usleep)。单客户端无争用时,LWLock 的底层自旋一两个 cycle 就拿到,根本到不了睡眠逻辑——所以没触发。
5. 源码核对:三层锁与死锁算法
5.1 三层锁的边界(PG18 源码位置)
LockAcquireProcSleep(proc.c:1459)/ CheckDeadLock(proc.c:1824) | 有DeadLockCheck) | |||
LWLockAcquire | 无 | |||
s_lockSpinLockAcquire / perform_spin_delay(s_lock.c:126) |
LWLock 的「锁」本身靠一把 spinlock 保护:LWLockAcquire 先原子尝试拿锁状态,拿不到就在底层 s_lock 上自旋一小会儿(保护等待队列的修改),然后才把进程挂进等待队列睡眠。所以三者是嵌套的——自旋锁是 LWLock 的基座,LWLock 是共享内存并发的基座,Lock 是用户可见的表/行锁的基座。
资深视角:重量级锁表是"分片"的,这正是它能扛高锁并发的原因。 全局锁表不是一个大互斥锁,而是按锁对象的哈希分成 NUM_LOCK_PARTITIONS = 16 个分区(lwlock.h:97,1 << LOG2_NUM_LOCK_PARTITIONS,LOG2=4,源码注释明确要求"必须是 2 的幂"),每个分区一把独立的 LWLock。申请一把表锁/行锁,只短暂拿它所属分区的那把 LWLock——分区之间互不阻塞。这跟缓冲区映射表(buffer mapping)用同样的分片哲学避免全局热点。 由此带来一个容量约束:max_locks_per_transaction × max_connections(再乘后端数相关的系数)就是锁表能容纳的锁槽总量。锁太多超过容量会报 out of shared memory / maximum number of locks exceeded——所以 PG19 把 max_locks_per_transaction 默认从 64 翻到 128(§6 已实机确认),正是锁结构变大 + 分区内需要更多余量。调大连接数时,记得同步评估 max_locks_per_transaction。
5.2 死锁检测算法(deadlock.c)
DeadLockCheck(deadlock.c:223)入口先清 nCurConstraints / nPossibleConstraints / nWaitOrders;核心递归DeadLockCheckRecurse(:312)沿「等待边」DFS:
- TestConfiguration(proc) 返回 <0 → 硬死锁(无可行解); - 返回 0 → 找到无死锁的合法排序; - 否则尝试每条「软边」作为约束继续递归。
返回 DS_HARD_DEADLOCK时调用方LockAcquireExtended直接ereport(ERROR, "deadlock detected");若只是需要重排等待队列(DS_SOFT_DEADLOCK)则静默重排、不报错。- 受害者选择
:检测时正在等待的那个进程被 kill(除非它是环中的「领导者」/最低 pid)。本篇 §2 的 B(16706,后进等待者)正是如此。
5.3 GetSnapshotData 的扫描与全局可见性
主体循环(procarray.c 约 2243–2360): for (pgxactoff = 0; pgxactoff < numProcs; pgxactoff++),跳过「无 xid / xid≥xmax / 正在逻辑解码 / 正在 LAZY VACUUM」的后端,取所有运行中 xid 的最小值作为xmin;xmax = latestCompletedXid + 1。GlobalVis(全局可见性地平线)也在这里更新: GetSnapshotData算完快照后,经ComputeXidHorizons→GlobalVisUpdateApply(procarray.c:4166)刷新GlobalVis{Shared,Catalog,Data,Temp}Rels的definitely_needed / maybe_needed。这就是专家提到的「GlobalVis.OldestXmin更新」——它给 VACUUM / 可见性判断提供一个可复用的全局最老可见 xid 地平线,避免各处重复算。
6. 实例里的锁相关配置
调试实例(PG18.4,端口 5618)的关键锁参数默认值(PG18 文档核对):
deadlock_timeout | 1s | DeadLockCheck | |
lock_timeout | 0 | ||
log_lock_waits | off | on | deadlock_timeout 时记日志 |
max_locks_per_transaction | 64 | 128 | |
max_connections | 100 | numProcs 上限,进而影响快照扫描代价 | |
deadlock_timeout | pg_stat_lock |
PG19 实测确认:SHOW max_locks_per_transaction → 128;SHOW log_lock_waits → on;SELECT count(*) FROM pg_stat_lock → 12(12 类锁各有统计行)。
7. PG19 视角:锁可观测性补强,算法未动
对照 PG19 官方发行说明 + 本机 PG19(5619)实测:
log_lock_waits | on | |
max_locks_per_transaction | 128 | |
pg_locks 聚合 | pg_stat_lock 系统视图(按锁类型汇总获取/等待,实测 12 行) | |
DeadLockCheckGetSnapshotData / LWLock / spinlock 算法 | 发行说明未提及改动 |
要点:
- PG19 在「锁可观测性」上是实打实的增强
—— pg_stat_lock让你一眼看到哪类锁在等、等了多久,不用再自己GROUP BY locktype去啃pg_locks。这对 §3 那种「高并发变慢」的排查是直接利好。 - 锁/快照的核心算法(DeadLockCheck、GetSnapshotData、LWLock、spinlock)在 PG19 没有源码级改动
——说明 PG18 这套机制仍是当前主线,PG19 主要在「可观测性 + 外围并行度(autovacuum 并行、TID range scan 并行、NOTIFY 只唤醒监听者)」上补强并发,而非重写锁子系统。 max_locks_per_transaction默认翻倍,是因为 PG19 锁表每条记录变大了——若从 PG18 升级且曾用满 64,注意容量感知要按新默认重新评估。
8. 小结
锁是三层金字塔:Lock(可死锁检测)→ LWLock(自旋+睡眠)→ spinlock(CPU 级忙等)。 DeadLockCheck(deadlock.c:223)是 Lock 层的兜底,在进程准备睡锁时被CheckDeadLock(proc.c:1824)调用,递归找环、kill 后进等待者。重量级锁表按NUM_LOCK_PARTITIONS=16分片(lwlock.h:97),分区独立加锁,所以高锁并发不会砸在一个全局互斥上;max_locks_per_transaction × max_connections决定锁槽总量。GetSnapshotData(procarray.c:2175)是高并发 RW 混合负载的咽喉:持 ProcArrayLock扫描numProcs个后端算xmin,代价 O(N)。实测numProcs从 3(低并发)到 53(50 空闲连接),约 17 倍。- PG18 的
GetSnapshotDataReuse(procarray.c:2095)已大幅缓解: xactCompletionCount不变就复用快照、跳过扫描;但并发写提交会逼出重算(snapshot->xmin=48478实测)。 - LWLock 获取本身就在
GetSnapshotData内发生(以 LW_SHARED拿ProcArrayLock),把「锁」和「快照瓶颈」串成一体;自旋锁在低争用下不触发,印证其极短。 实操建议:连数高时用连接池(如 pgbouncer)收敛后端数,直接压低 numProcs;用lock_timeout防单条锁等待雪崩;PG19 上直接用pg_stat_lock看锁争用。
9. 复现附录(gdb 配方)
调试环境:PG18.4(--enable-debug --enable-cassert),端口 5618,用户 postgres18,数据目录 /pgdata/postgres18/data,源码树 /tmp/postgresql-18.4。
关键坑(踩过):
多语句 psql -c "BEGIN; ...; SELECT pg_sleep(N); ..."在pg_stat_activity.query里以BEGIN开头,所以取后端 PID 要用LIKE '%pg_sleep(N)%'(包含匹配),不能用LIKE 'SELECT pg_sleep(N)%'(前缀匹配扑空)。GetSnapshotData的 2501行(snapshot->xmin)默认不命中——GetSnapshotDataReuse把它跳过了。要逼出真实xmin,必须在读取期间制造并发写提交(本篇用CALL writeloop(2000)持续提交,使xactCompletionCount变化)。自旋锁( s_lock.c:98/perform_spin_delay:126)低争用下不会触发——这不是 bug,是它「极短临界区」特性的体现;想看它,需要人为制造 LWLock 级别的强争用。老规矩: handle SIGUSR1 nostop noprint pass;入口函数用pg_sleep(N)前缀抢在命中点前gdb -p <PID>挂上。
死锁场景(对会话 B 挂 gdb):
sql CREATE TABLE dltest(id int primary key, v int);
INSERT INTO dltest VALUES (1,0),(2,0);
-- 会话 A: BEGIN; UPDATE dltest SET v=1 WHERE id=1; UPDATE dltest SET v=1 WHERE id=2;
-- 会话 B: BEGIN; UPDATE dltest SET v=2 WHERE id=2; SELECT pg_sleep(12); UPDATE dltest SET v=2 WHERE id=1;
handle SIGUSR1 nostop noprint pass
break deadlock.c:223
commands
printf "===== DeadLockCheck proc->pid=%d =====\n", proc->pid
bt 8
detach
quit
end
continueGetSnapshotData 低/高并发对比:
handle SIGUSR1 nostop noprint pass
break procarray.c:2233
commands
printf "===== GetSnapshotData numProcs=%d =====\n", arrayP->numProcs
continue
end
break procarray.c:2501
commands
printf "===== snapshot->xmin=%u (RECOMPUTED) =====\n", snapshot->xmin
continue
end
continue高并发侧:先 for i in $(seq 1 50); do psql -c "SELECT pg_sleep(120)"; done & 开 50 空闲连接,再跑上述带 pg_sleep(8) 前缀的读查询。
LWLock 获取:
handle SIGUSR1 nostop noprint pass
break lwlock.c:1182
commands
printf "===== LWLockAcquire mode=%d =====\n", mode
bt 8
disable 1
continue
end
break s_lock.c:98
commands
printf "===== s_lock (spin) file=%s line=%d =====\n", file, line
bt 8
continue
end
break s_lock.c:126
commands
printf "===== perform_spin_delay =====\n"
bt 6
continue
end
continue10. 官方文档核对表
DeadLockCheckCheckDeadLock(proc.c:1824)←ProcSleep(proc.c:1459) 调用 | ||
deadlock detected 并给出互锁环 | ERROR 输出(16706↔16801) | |
GetSnapshotDatanumProcs 个后端算 xmin | numProcs=3 / 53 | |
numProcs | ||
GetSnapshotDataReusexactCompletionCount 未变则复用、跳过扫描 | xmin=48478) | |
GetSnapshotDataLW_SHARED 获取 ProcArrayLock | LWLockAcquire ← GetSnapshotData(procarray.c:2231) | |
s_lock(s_lock.c:98) / perform_spin_delay(s_lock.c:126) 低争用不触发 | ||
ComputeXidHorizons→GlobalVisUpdateApply 更新 GlobalVis* 地平线 | ||
log_lock_waits 默认 on、max_locks_per_transaction 默认 128 | SHOW 实测 | |
pg_stat_lock 视图(按锁类型统计) | SELECT count(*) → 12 行 | |
DeadLockCheck/GetSnapshotData/LWLock/spinlock 算法 |
夜雨聆风