乐于分享
好东西不私藏

跟着AI学PostgreSQL (1.10):锁与并发——LWLock、自旋锁、死锁检测与 GetSnapshotData 咽喉

跟着AI学PostgreSQL (1.10):锁与并发——LWLock、自旋锁、死锁检测与 GetSnapshotData 咽喉

本篇聊 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:2501snapshot->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:2501snapshot->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 获取 ProcArrayLockprocarray.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 源码位置)

层级
作用
入口/关键符号
死锁检测
等待方式
① 重量级锁 Lock
表锁/行锁/事务锁,可跨事务
LockAcquire
(lock.c:813)/ ProcSleep(proc.c:1459)/ CheckDeadLock(proc.c:1824)
DeadLockCheck
等待队列(dlist)+ 睡眠
② 轻量级锁 LWLock
保护共享内存结构
LWLockAcquire
(lwlock.c:1180)
先自旋、再睡条件变量
③ 自旋锁 spinlock
纳秒级临界区(如 LWLock 自身的锁字)
s_lock
(s_lock.c:98)/ SpinLockAcquire / perform_spin_delay(s_lock.c:126)
忙等(TAS),超时后微睡

LWLock 的「锁」本身靠一把 spinlock 保护LWLockAcquire 先原子尝试拿锁状态,拿不到就在底层 s_lock 上自旋一小会儿(保护等待队列的修改),然后才把进程挂进等待队列睡眠。所以三者是嵌套的——自旋锁是 LWLock 的基座,LWLock 是共享内存并发的基座,Lock 是用户可见的表/行锁的基座。

资深视角:重量级锁表是"分片"的,这正是它能扛高锁并发的原因。 全局锁表不是一个大互斥锁,而是按锁对象的哈希分成 NUM_LOCK_PARTITIONS = 16 个分区(lwlock.h:97,1 << LOG2_NUM_LOCK_PARTITIONSLOG2=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 的最小值作为 xminxmax = 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 文档核对):

参数
PG18 默认
PG19 默认(本机实测)
说明
deadlock_timeout1s
1s
死锁检测前最长等待;到点在等待者上跑 DeadLockCheck
lock_timeout0
(off)
0
单条锁等待超时;建议 OLTP 设几秒防雪崩
log_lock_waitsoffon
锁等待超过 deadlock_timeout 时记日志
max_locks_per_transaction64128
锁表每事务槽位数;PG19 因锁结构变大翻倍才等价
max_connections100
100
直接决定 numProcs 上限,进而影响快照扫描代价
deadlock_timeout
 之外
pg_stat_lock
 视图
PG19 新增:按锁类型统计获取/等待

PG19 实测确认:SHOW max_locks_per_transaction → 128SHOW log_lock_waits → onSELECT count(*) FROM pg_stat_lock → 12(12 类锁各有统计行)。


7. PG19 视角:锁可观测性补强,算法未动

对照 PG19 官方发行说明 + 本机 PG19(5619)实测:

维度
PG18
PG19
log_lock_waits
 默认
off
on
(实测 on)
max_locks_per_transaction
 默认
64
128
(实测 128)
锁统计观测
只能自己查 pg_locks 聚合
新增 pg_stat_lock 系统视图(按锁类型汇总获取/等待,实测 12 行)
DeadLockCheck
 / GetSnapshotData / LWLock / spinlock 算法
当前主线
发行说明未提及改动

要点:

  1. PG19 在「锁可观测性」上是实打实的增强
    ——pg_stat_lock 让你一眼看到哪类锁在等、等了多久,不用再自己 GROUP BY locktype 去啃 pg_locks。这对 §3 那种「高并发变慢」的排查是直接利好。
  2. 锁/快照的核心算法(DeadLockCheck、GetSnapshotData、LWLock、spinlock)在 PG19 没有源码级改动
    ——说明 PG18 这套机制仍是当前主线,PG19 主要在「可观测性 + 外围并行度(autovacuum 并行、TID range scan 并行、NOTIFY 只唤醒监听者)」上补强并发,而非重写锁子系统。
  3. 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
continue

GetSnapshotData 低/高并发对比

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
continue

10. 官方文档核对表

断言
来源
结论
DeadLockCheck
 @ deadlock.c:223,由 CheckDeadLock(proc.c:1824)←ProcSleep(proc.c:1459) 调用
PG18.4 源码 + gdb 实锤
死锁受害者是「后进等待者」,报错 deadlock detected 并给出互锁环
gdb + B 端 ERROR 输出(16706↔16801)
✅(gdb 实锤)
GetSnapshotData
 @ procarray.c:2175,扫描 numProcs 个后端算 xmin
源码 + gdb numProcs=3 / 53
✅(gdb 实锤)
numProcs
 随连接数 1:1 增长,高并发扫描代价约 17 倍
gdb 低/高并发对比
✅(gdb 实锤)
GetSnapshotDataReuse
 @ procarray.c:2095:xactCompletionCount 未变则复用、跳过扫描
源码 + gdb(2501 未命中→强制重算命中 xmin=48478
✅(gdb 实锤)
GetSnapshotData
 内部以 LW_SHARED 获取 ProcArrayLock
gdb LWLockAcquire ← GetSnapshotData(procarray.c:2231)
✅(gdb 实锤)
自旋锁 s_lock(s_lock.c:98) / perform_spin_delay(s_lock.c:126) 低争用不触发
gdb 未命中 + 源码机制
快照经 ComputeXidHorizonsGlobalVisUpdateApply 更新 GlobalVis* 地平线
procarray.c:4166 / 2485-2495
PG19 log_lock_waits 默认 on、max_locks_per_transaction 默认 128
本机 PG19(5619)SHOW 实测
PG19 新增 pg_stat_lock 视图(按锁类型统计)
本机 PG19 SELECT count(*) → 12 行
PG19 未改 DeadLockCheck/GetSnapshotData/LWLock/spinlock 算法
PG19 发行说明 E.1