锁管理器:从 SpinLock 到死锁检测的三层体系
一条 UPDATE 语句执行时,PG
内核里至少有三种不同粒度的锁在工作:CPU
指令级的自旋锁保护共享内存计数器、LWLock 保护 Buffer 的 pin
状态和页面内容、Heavyweight Lock
保证两个事务不能同时修改同一行。它们不是”选一个用”,而是按持锁时间和争用模式逐层分工。
三层体系的核心思路是:持锁越短,实现越底层。SpinLock 的临界区只有几十个 CPU 周期,用硬件原子指令自旋等待;LWLock 的临界区覆盖一次共享内存结构的读写,自旋几轮后进内核等待;Heavyweight Lock 的临界区覆盖整个事务,等待队列、死锁检测、锁升级全在这一层处理。
本文从源码路径拆解这三层锁:它们各自解决什么问题、在什么场景下使用、实现上经历了哪些演进,以及在生产环境中如何通过
pg_locks 和 pg_blocking_pids()
追踪锁等待链。
一、三层体系总览
PG 在共享内存中维护三类锁,选择哪一层取决于持锁时间和争用可能性:
| 层级 | 实现 | 临界区时间 | 等待方式 | 典型用途 |
|---|---|---|---|---|
| SpinLock | TAS/CAS + 自旋 | ~几十 ns | CPU 空转,不切换上下文 | 保护共享内存计数器、LWLock 内部状态 |
| LWLock | 原子操作 + 自旋 + semop | ~几 us 到 ~几 ms | 先自旋,超限后 semop 阻塞 | Buffer pin、CLOG 页面、WAL 插入槽位 |
| Heavyweight Lock | 哈希表 + 等待队列 + 死锁检测 | ~ms 到事务级 | 立即进入等待队列 | 表锁 (ACCESS SHARE/EXCLUSIVE)、行锁 (FOR UPDATE/FOR SHARE) |
选择逻辑不是性能偏好,而是物理约束:SpinLock 的临界区不能有任何可能导致上下文切换的操作(包括内存分配、errno 检查、elog),因为它可能在中断上下文或信号处理器中执行。LWLock 允许持有者发生短暂的上下文切换,但不允许跨事务持锁。Heavyweight Lock 支持事务结束时自动释放、死锁检测和锁升级,代价是获取和释放都需要查询哈希表并操作等待队列。
这三层锁构成了 PG 并发控制的地基:MVCC 解决了”读不阻塞写,写不阻塞读”,但 UPDATE 冲突、DDL 保护、共享内存结构的并发访问,仍然需要锁来协调。
二、SpinLock:最底层的自旋锁
实现原理
SpinLock 是 PG 锁层级的最底层。它的核心语义是:用硬件原子指令(TAS 或 CAS)争用一个内存字,没拿到就原地空转(spin),拿到后执行临界区,执行完释放。整个过程没有系统调用,没有上下文切换。
PG 的 SpinLock 实现在
src/include/storage/s_lock.h
和对应平台的汇编文件中。核心数据结构是一个
slock_t,通常是 unsigned char 或
int:
// src/include/storage/s_lock.h
typedef unsigned char slock_t; // x86_64 平台
// 关键的 TAS (Test-And-Set) 宏:测试并设置锁
// 返回 0(拿到锁)或 1(锁被占用)
// 在 x86_64 上用 xchgb 指令实现:
// movb $1, %al
// xchgb %al, (%rdi) // 原子交换
// testb %al, %al // 检查原值
```text
获取 SpinLock 的函数签名和调用路径:
```c
// src/include/storage/spin.h
extern int s_lock(volatile slock_t *lock, const char *file, int line);
// SpinLockAcquire 宏展开为 s_lock()
#define SpinLockAcquire(lock) s_lock(lock, __FILE__, __LINE__)s_lock() 执行一个忙等循环,每次迭代尝试原子
TAS,如果失败就执行 pg_spin_delay()
空转等待。在无竞争时,获取只需要一次原子交换(~10-20ns);在有竞争时,等待者消耗
CPU 但不释放调度器。
释放 SpinLock 则是简单的原子写:
#define SpinLockRelease(lock) do { *(lock) = 0; } while (0)
// 在某些平台上使用原子 store 保证内存屏障
```bash
### 使用边界
SpinLock 的临界区必须满足极严格的约束:
1. **临界区极短**:几十个 CPU 指令周期以内。自旋等待不释放 CPU,持锁时间越长,浪费的 CPU 周期越多。
2. **临界区不能触发上下文切换**:不能调用 `palloc()`(可能触发 malloc 和系统调用)、不能写 elog/ereport(可能触发 syslog 写)、不能调用任何可能导致 `errno` 被修改的函数、不能获取 LWLock 或 Heavyweight Lock。
3. **持有 SpinLock 时不能再次获取同一 SpinLock**:不是可重入锁,会死锁。
4. **在中断上下文中也可能需要 SpinLock**:部分信号处理器会竞争 SpinLock,所以临界区必须信号安全。
PG 中 SpinLock 的典型使用场景:
```c
// 例 1:更新共享内存中的统计计数器
// src/backend/postmaster/pgstat.c
SpinLockAcquire(&pgStatDBHashLock);
// ... 更新几个整数字段 ...
SpinLockRelease(&pgStatDBHashLock);
// 例 2:保护 LWLock 的内部状态
// src/backend/storage/lmgr/lwlock.c
// LWLock 自身用 SpinLock 保护 wait list 操作
SpinLockAcquire(&lock->waitlist_lock);
// ... 修改等待列表的指针 ...
SpinLockRelease(&lock->waitlist_lock);PG 在可能竞争的场景下使用
SpinLock,但在单进程访问的路径下直接操作——例如初始化时,ShmemInitStruct()
不需要任何锁,因为 Postmaster 在 fork 之前是串行执行的。
三、LWLock:轻量级锁的三代演进
定位与使用场景
LWLock(Lightweight Lock)是 PG 中使用最频繁的锁。它位于 SpinLock 和 Heavyweight Lock 之间:临界区长到用 SpinLock 不划算(需要跨越几次函数调用),但又不需要事务语义和死锁检测。
LWLock 保护 PG 内部共享内存结构:
- 每个 Buffer 的 content lock(读写页面内容时持有)
- Buffer 的 I/O lock(从磁盘读取页面时持有)
- WAL 插入锁(
WALInsertLocks,分段保护 WAL buffer 的不同区域) - CLOG 页面的 SLRU 锁
ProcArrayLock(快照构建时需要)- 子事务(
Subtrans)和 multixact 的 SLRU 锁 - 关系扩展锁(表新增页面时)
LWLock 支持两种模式: - LW_SHARED(共享):多个进程可以同时持有,用于只读访问。例如多个 Backend 可以同时以共享模式持有 Buffer content lock 来读取同一个页面。 - LW_EXCLUSIVE(排他):只有一个进程持有。例如在页面中写入数据、修改共享内存结构。
第一代:PG 9.4 之前的信号量实现
PG 9.4 之前,LWLock 的实现直接依赖 System V 信号量。每个
LWLock 绑定一个
semaphore,获取锁时如果能立即拿到(原子自旋),就直接返回;如果拿不到,就
semop() 阻塞等待。
这个实现的主要问题是信号量不可伸缩:System
V 信号量集有数量上限(SEMMNI /
SEMMNS),而 PG 中 LWLock 的数量随
shared_buffers 增大而增长(每个 Buffer 至少 2
个 LWLock:content lock 和 I/O lock)。一个 8GB
shared_buffers 的实例(约 1048576 个 8KB
页面),需要 200 万个 LWLock——远超 System V
信号量的上限。
PG 9.4 引入的方案是”信号量池”:不再给每个 LWLock 分配独立的 semaphore,而是维护一个信号量池,等待者从池中获取一个信号量 ID。这在功能上解决了数量上限问题,但信号量分配和回收的簿记开销很高。
第二代:PG 9.5+ 的原子操作实现
PG 9.5 开始,LWLock 的实现从依赖 System V
信号量彻底转向依赖 CPU 原子操作。核心思路是:LWLock
的状态字(state)用
pg_atomic_uint32 表示,通过
CAS(Compare-And-Swap)操作获取和释放锁,只有需要等待时才使用信号量。
// src/include/storage/lwlock.h
typedef struct LWLock {
pg_atomic_uint32 state; // 锁状态:是否被持有、等待者计数
pg_atomic_uint32 nwaiters; // 等待者数量(用于决定是否需要信号量唤醒)
int sema; // 信号量索引(仅在需要阻塞等待时使用)
// ...
} LWLock;
```text
获取 LWLock 的流程(简化):
```c
// src/backend/storage/lmgr/lwlock.c, LWLockAttemptLock()
// 尝试用原子 CAS 获取锁
static bool LWLockAttemptLock(LWLock *lock, LWLockMode mode) {
uint32 old_state = pg_atomic_read_u32(&lock->state);
while (true) {
uint32 desired_state;
if (mode == LW_EXCLUSIVE) {
// 排他模式:如果当前 lock 未被持有(state 的 lock count 为 0),则获取
if (old_state & LW_FLAG_HAS_WAITERS || old_state & LW_LOCK_MASK)
return false; // 锁已被持有
desired_state = old_state | LW_FLAG_HAS_WAITERS | 1;
} else { // LW_SHARED
// 共享模式:如果当前没有被排他持有,则递增共享计数
if (old_state & LW_LOCK_MASK) {
// 排他锁持有中,不能获取共享锁
return false;
}
desired_state = old_state + (1 << LW_SHARED_SHIFT);
}
// CAS 尝试设置新状态
if (pg_atomic_compare_exchange_u32(&lock->state, &old_state, desired_state))
return true;
// CAS 失败(其他进程同时修改了 state),重试
}
}如果 LWLockAttemptLock() 返回
false,调用者进入等待路径
LWLockWaitForVar()。PG 9.5+
的等待策略是先自旋再阻塞:
- 循环调用
LWLockAttemptLock()尝试 CAS 获取锁,最多自旋spins_per_delay次(默认 100 次,可通过pg_test_spin_delay校准)。 - 自旋仍失败后,递增
nwaiters,然后用pg_atomic_fetch_or_u32()在state中设置LW_FLAG_HAS_WAITERS标志位。 - 调用
semop()在信号量上阻塞等待。 - 被唤醒后,回到步骤 1。
这意味着当 LWLock 竞争激烈时,等待者不是一直空转——自旋仅作为”锁可能在几 microsecond 内释放”的优化,之后就让出 CPU。这种设计非常适合 PG 的典型负载:大多数 LWLock 的持锁时间非常短(一次内存读写),少数情况(I/O)才需要真正阻塞。
释放 LWLock 时,释放者检查 nwaiters 和
LW_FLAG_HAS_WAITERS:
// src/backend/storage/lmgr/lwlock.c, LWLockRelease()
void LWLockRelease(LWLock *lock) {
// 更新 state,清除持有标志
old_state = pg_atomic_sub_fetch_u32(&lock->state, ...);
// 如果有等待者,唤醒它们
if (old_state & LW_FLAG_HAS_WAITERS) {
pg_atomic_fetch_and_u32(&lock->state, ~LW_FLAG_HAS_WAITERS);
// 用 semop() 唤醒等待者
PGSemaphoreUnlock(&lock->sema);
}
}
```bash
### 第三代:PG 16 的 LWLockWaitListLock 优化
PG 16 引入了一个微架构改进:**`LWLockWaitListLock`**。在 PG 9.5-15 的实现中,等待者共享同一个信号量并执行"惊群"(thundering herd)式的唤醒:释放者调用 `semop()` 唤醒所有等待者,每个等待者从 `semop()` 返回后重新竞争 CAS。这在高并发时造成大量不必要的 CPU 消耗和缓存颠簸。
PG 16 在 LWLock 结构中新增了 per-lock 的等待列表锁:
```c
// PG 16 的 LWLock 结构(简化)
typedef struct LWLock {
pg_atomic_uint32 state;
pg_atomic_uint32 nwaiters;
proclist_head waiters; // 等待者的双向链表
slock_t waitlist_lock; // PG 16 新增:保护 waiters 链表的 spinlock
} LWLock;关键改进:等待者不是全部醒来竞争
CAS,而是排队进入 waiters
链表,释放者按顺序逐个唤醒。这从根本上消除了惊群效应,在高并发
Buffer 访问场景下,LWLock 竞争的吞吐量提升可达
20-40%(具体数字因负载而异)。
LWLock 的排查
pg_stat_activity 的 wait_event
字段记录了 LWLock 等待:
SELECT pid, wait_event_type, wait_event, state, query
FROM pg_stat_activity
WHERE wait_event_type = 'LWLock';
```text
常见的 LWLock 等待事件含义:
| wait_event | 对应的锁 | 常见原因 |
|------------|---------|---------|
| `WALWriteLock` | WAL 写入锁 | 大量事务提交时 WAL 写入竞争 |
| `WALInsertLock` | WAL 插入锁 | PG 9.4- 是瓶颈,PG 9.5+ 分段后大幅缓解 |
| `BufferContent` | Buffer 页面内容锁 | 并发读写同一页面(热点页) |
| `LockManager` | 重量级锁分区的 LWLock | 高并发锁获取/释放 |
| `ProcArrayLock` | 进程数组锁 | 大量连接时 GetSnapshotData() 竞争 |
| `CLogControl` | CLOG SLRU 锁 | 大量事务状态查询/更新 |
---
## 四、Heavyweight Lock:事务级的重量级锁
### 设计目标
Heavyweight Lock(在 PG 源码中直接称为 `LOCK`)是锁层级的最上层。它解决 SpinLock 和 LWLock 解决不了的问题:
1. **事务语义**:锁在事务结束时自动释放,支持 `ROLLBACK` 和子事务(savepoint)回滚。
2. **死锁检测**:自动检测等待环路,选择一个事务回滚。
3. **锁升级/转换**:支持在共享锁和排他锁之间转换,支持锁强度的比较(`ACCESS SHARE` < `ROW SHARE` < `ROW EXCLUSIVE` < `SHARE UPDATE EXCLUSIVE` < `SHARE` < `SHARE ROW EXCLUSIVE` < `EXCLUSIVE` < `ACCESS EXCLUSIVE`)。
4. **等待队列**:等待者按 FIFO 排队,支持队列跳跃(在特定条件下)。
PG 定义了 8 种锁模式,按强度递增:
| 锁模式 | 数值 | 典型 SQL |
|-------|------|---------|
| `AccessShareLock` | 1 | `SELECT` |
| `RowShareLock` | 2 | `SELECT ... FOR SHARE` |
| `RowExclusiveLock` | 3 | `INSERT` / `UPDATE` / `DELETE` |
| `ShareUpdateExclusiveLock` | 4 | `VACUUM`(不 FULL)、`ANALYZE`、`CREATE INDEX CONCURRENTLY` |
| `ShareLock` | 5 | `CREATE INDEX`(非 CONCURRENTLY) |
| `ShareRowExclusiveLock` | 6 | `CREATE TRIGGER` |
| `ExclusiveLock` | 7 | `REFRESH MATERIALIZED VIEW CONCURRENTLY` |
| `AccessExclusiveLock` | 8 | `ALTER TABLE`、`DROP TABLE`、`VACUUM FULL` |
这 8 种模式通过**冲突矩阵**(conflict table,`src/backend/storage/lmgr/lock.c` 中的 `LockConflicts[]`)定义哪些模式可以共存。例如,`AccessShareLock` 和 `RowExclusiveLock` 不冲突——读和写可以并行——这是 MVCC 的核心优势。但 `AccessExclusiveLock` 和所有模式都冲突,所以 `ALTER TABLE` 会阻塞一切。
### Lock hash table 的结构
所有 Heavyweight Lock 存储在共享内存中的一个哈希表中,称为 `LockMethodLockHash`。哈希键由**锁目标(locktag)**组成:
```c
// src/include/storage/lock.h
typedef struct LOCKTAG {
uint32 locktag_field1; // 数据库 OID
uint32 locktag_field2; // 关系 OID
uint32 locktag_field3; // 页面号(页级锁)或 0
uint32 locktag_field4; // 元组号(行级锁)或 0
uint16 locktag_type; // 锁类型:LOCKTAG_RELATION / LOCKTAG_PAGE / LOCKTAG_TUPLE
// ...
} LOCKTAG;哈希表的 entry 是 LOCK 结构体:
// src/include/storage/lock.h
typedef struct LOCK {
LOCKTAG tag; // 锁目标(哈希键)
LOCKMASK grantMask; // 已授予的锁模式位掩码
LOCKMASK waitMask; // 等待中的锁模式位掩码
SHM_QUEUE procLocks; // 持有本锁的所有 PROCLOCK(链表)
SHM_QUEUE waitProcs; // 等待本锁的进程队列
int requested[8]; // 每种锁模式的总请求数
int granted[8]; // 每种锁模式的已授予数
int nRequested; // 总请求数
int nGranted; // 总授予数
} LOCK;
```text
每个进程对每个锁的持有信息存储在 `PROCLOCK` 结构体中:
```c
typedef struct PROCLOCK {
SHM_QUEUE lockLink; // 链入 LOCK.procLocks
SHM_QUEUE procLink; // 链入 PGPROC.procLocks
PROCLOCKTAG tag; // { LOCK, PGPROC }
LOCKMASK holdMask; // 本进程持有的锁模式位掩码
// ...
} PROCLOCK;PROCLOCK
同时存在于两个链表中:LOCK.procLocks(从锁角度,哪些进程持有)和
PGPROC.procLocks(从进程角度,持有哪些锁)。这种双向索引使得锁释放和事务中止时的清理效率很高。
LockAcquire() 的完整路径
LockAcquire() 是获取 Heavyweight Lock
的入口函数,定义在
src/backend/storage/lmgr/lock.c。调用路径:
LockAcquire(locktag, lockmode, ...)
→ LockAcquireExtended(locktag, lockmode, sessionLock, ...)
→ SetupLockInTable() // 1. 在共享内存哈希表中查找或创建 LOCK entry
→ GrantLock() // 2. 尝试授予锁
→ 如果不冲突 → 拿到锁,返回 LOCKACQUIRE_OK
→ 如果冲突 → 进入等待路径
→ WaitOnLock() // 3. 进入等待队列
→ 将 PGPROC 加入 LOCK.waitProcs
→ 设置 PGPROC.waitLock = lock
→ 设置 PGPROC.waitStatus = STATUS_WAITING
→ semop() 阻塞
→ 被唤醒后 → GrantLock() 重新检查冲突
GrantLock() 的逻辑是核心:
// src/backend/storage/lmgr/lock.c(简化)
static bool GrantLock(LOCK *lock, PROCLOCK *proclock, LOCKMODE lockmode) {
// 检查请求的锁模式是否与已授予的模式冲突
if (!(LockConflicts[lockmode] & lock->grantMask)) {
// 不冲突:授予锁
proclock->holdMask |= LOCKBIT_ON(lockmode);
lock->grantMask |= LOCKBIT_ON(lockmode);
lock->granted[lockmode]++;
return true;
}
// 冲突:不能授予,等待者继续等待
return false;
}
```bash
### 锁获取的 fast path 优化
对于最常用的弱锁模式(通常是表级的 `AccessShareLock`),PG 提供了 **fast path** 优化:如果锁不冲突且锁请求不过多,`LockAcquire()` 跳过哈希表查找,直接把锁记录在 `PGPROC` 的一个固定大小数组中(`fpLockBits`,默认 16 个槽位)。
```c
// 判断是否能走 fast path
static bool FastPathLocalUseAllowed(LOCKTAG *locktag, LOCKMODE lockmode) {
// 只有表级锁、行级锁可以使用 fast path
// 且锁模式不能强于 ShareUpdateExclusiveLock
if (lockmode >= ShareUpdateExclusiveLock)
return false;
// 弱锁:尝试 fast path
return true;
}fast path 的关键前提是 AccessShareLock 是 PG
中获取最频繁的锁——每条 SELECT
语句都需要它。跳过哈希表查找意味着不需要获取
LockMgrLock(保护哈希表的 LWLock),大幅减少该
LWLock 的竞争。
五、死锁检测:DeadLockCheck() 的等待图算法
触发时机
死锁检测由 CheckDeadLock() 触发,在
ProcSleep()(进程等待锁时进入)中被调用。关键
GUC 是 deadlock_timeout(默认
1s):进程在等待队列中阻塞时,每隔
deadlock_timeout
毫秒被唤醒一次,执行死锁检测。
// src/backend/storage/lmgr/proc.c, ProcSleep()
// 等待进程在等待队列中的主循环
while (waiting) {
// 等待 deadlock_timeout 毫秒
timeout = deadlock_timeout;
WaitOnLock(&locallock, lock, proclock, &timeout);
// 超时唤醒:执行死锁检测
if (CheckDeadLock()) {
// 检测到死锁且本进程被选为牺牲者
ereport(ERROR, (errcode(ERRCODE_T_R_DEADLOCK_DETECTED),
errmsg("deadlock detected"),
errdetail_hint(...)));
}
// 检查锁是否可用
if (GrantLock(lock, proclock, lockmode)) {
waiting = false; // 拿到锁,退出循环
}
// 否则继续等待
}
```text
注意 `deadlock_timeout` 的双重作用:它同时控制死锁检测间隔和 `log_lock_waits` 的报告阈值。当一个进程等待锁超过 `deadlock_timeout` 时,如果 `log_lock_waits = on`,PG 会记录一条日志。
### 等待图(Wait-For Graph)的构建
`DeadLockCheck()` 的核心算法在 `src/backend/storage/lmgr/deadlock.c` 中。它是一个基于 DFS(深度优先搜索)的环路检测算法。
等待图是隐式构建的,不预先创建完整图:
```c
// src/backend/storage/lmgr/deadlock.c, DeadLockCheck()
// 从当前进程出发,BFS 搜索等待图中的环路
bool DeadLockCheck(PGPROC *proc) {
// 初始化:从当前等待进程出发
nDeadlockDetails = 0;
visitedProcs = 0;
nPossibleConstraints = 0;
curConstraints = waitingConstraints;
// 第一个被访问的节点是当前进程
// 检查从它出发是否能在等待图中走回自身
if (!FindLockCycle(proc, possibleConstraints, &nSoftEdges))
return false; // 没有环路
// 找到了环路:确定牺牲者
// 筛选环路中最适合回滚的事务
return true;
}核心搜索函数 FindLockCycle() 做递归
DFS:
// src/backend/storage/lmgr/deadlock.c(简化)
static bool FindLockCycle(PGPROC *checkProc,
EDGE *softEdges, int *nSoftEdges) {
// 1. 找到 checkProc 正在等待的锁
lock = checkProc->waitLock;
if (lock == NULL)
return false;
// 2. 遍历持有该锁的所有进程(lockedBy)
for each PROCLOCK held on this lock {
PGPROC *holder = proclock->tag.myProc;
// 如果 holder 也在等待某个锁,继续向深处搜索
if (holder->waitLock != NULL) {
if (holder == startProc) {
// 找到了回到起点的环路!
return true;
}
if (FindLockCycle(holder, softEdges, nSoftEdges))
return true;
}
}
return false;
}
```bash
### 牺牲者选择
检测到环路后,不是任意回滚一个事务。`DeadLockCheck()` 通过以下策略选择牺牲者:
1. **选择所有环路中涉及的事务集合**。
2. **排除已经在回滚的事务**(因为它们会自动释放锁)。
3. **选择回滚代价最小的事务**,优先考虑:已经执行时间最短的、锁请求最少的、事务 ID 最新的。
当死锁被检测到时,PG 中止选中的事务并返回错误:ERROR: deadlock detected DETAIL: Process 12345 waits for ShareLock on transaction 67890; blocked by process 12346. Process 12346 waits for ShareLock on transaction 67890; blocked by process 12345. HINT: See server log for query details.
### 行级锁的死锁检测
行级锁(FOR UPDATE / FOR SHARE)也会触发死锁检测。行级锁的等待信息存储在 `PGPROC` 的 `waitLock` 中,`LOCKTAG` 的 `locktag_type` 为 `LOCKTAG_TUPLE`,`locktag_field4` 是等待的元组的事务 ID。死锁检测算法对行级锁和表级锁使用相同的 BFS 路径。
---
## 六、行级锁:FOR UPDATE/FOR SHARE 与 t_infomask
### 行级锁的语义
PG 的 MVCC 使得 `SELECT` 不需要读锁——每个查询通过快照看到数据库的一致版本。但 `SELECT ... FOR UPDATE` 和 `SELECT ... FOR SHARE` 需要一种机制阻止其他事务修改行,同时不需要在共享内存中为每行创建独立锁对象。
PG 的方案是**把锁信息写在行本身**:Heap Tuple Header 中的 `t_infomask` 字段包含三个 lock bit:
```c
// src/include/access/htup_details.h
#define HEAP_XMAX_KEYSHR_LOCK 0x0010 // xmax 是 key-shared 锁(FOR KEY SHARE,FK 检查用)
#define HEAP_XMAX_EXCL_LOCK 0x0040 // xmax 是排他锁(FOR UPDATE)
#define HEAP_XMAX_LOCK_ONLY 0x0080 // xmax 是锁而非删除
#define HEAP_XMAX_IS_MULTI 0x1000 // xmax 是 MultiXactId三个标志位的组合对应三种行级锁:
FOR UPDATE:HEAP_XMAX_LOCK_ONLY | HEAP_XMAX_EXCL_LOCK(0x00C0),xmax设为当前事务 XIDFOR SHARE:HEAP_XMAX_LOCK_ONLY(0x0080),不设KEYSHR也不设EXCLFOR KEY SHARE:HEAP_XMAX_LOCK_ONLY | HEAP_XMAX_KEYSHR_LOCK(0x0090),FK 检查时自动获取
当执行 SELECT ... FOR UPDATE 时,PG 设置
tuple header 的 xmax 为当前事务的
xid,并设置
HEAP_XMAX_LOCK_ONLY | HEAP_XMAX_EXCL_LOCK。后续的其他事务在尝试获取该行的冲突锁时,会看到
xmax 被一个活跃事务持有,并进入等待队列。
MultiXact:多事务同时持有行共享锁
当多个事务同时对同一行执行
SELECT ... FOR SHARE 时,xmax
只能存一个事务 ID。PG 的解决方案是
MultiXact(多事务 ID):把多个事务 ID
打包成一个 MultiXactId,存储在 tuple header 的
xmax 位置,同时设置
HEAP_XMAX_IS_MULTI 标志位。
#define HEAP_XMAX_IS_MULTI 0x1000 // xmax 是 MultiXactId,不是单独的事务 ID
```text
MultiXact 的状态存储在 `src/backend/access/transam/multixact.c` 管理的 SLRU(Simple LRU)结构中,包含成员事务列表和每个成员的锁模式。这是一个复杂的子系统,涉及自己的 freezing 策略和 wraparound 风险。
### 行级锁的等待机制
行级锁等待和表级锁使用相同的 Heavyweight Lock 基础设施。每个 `PGPROC` 通过 `waitLock` 字段指向等待的 `LOCK` 对象:
```c
// src/backend/access/heap/heapam.c(简化)
// XactLockTableWait() 用于等待目标事务提交或回滚
// 这是行级锁等待的核心
void XactLockTableWait(TransactionId xid, ...) {
LOCKTAG tag;
SET_LOCKTAG_TRANSACTION(tag, xid); // 锁目标是事务 ID
// 在锁表中获取锁(等待目标事务完成)
LockAcquire(&tag, ShareLock, false, false);
// 拿到锁意味着目标事务已经提交或回滚
LockRelease(&tag, ShareLock, false);
}当一个事务试图对一行执行 FOR UPDATE 而该行被另一个事务以 FOR UPDATE 锁住时,等待的是目标事务的事务 ID,而不是行本身。这种间接等待机制使得死锁检测的等待图以事务为节点,而不是以行为节点——大大简化了检测算法。
七、运维与排查:追踪锁等待链
pg_locks 视图
pg_locks
是诊断锁问题的核心视图。它直接读取共享内存中的锁表:
SELECT
locktype, -- relation, tuple, transactionid, virtualxid, object, ...
database, -- 数据库 OID
relation::regclass, -- 关系名
page, -- 页面号(页级锁)
tuple, -- 元组号(行级锁)
virtualxid, -- 虚拟事务 ID
transactionid, -- 事务 ID
mode, -- 锁模式:AccessShareLock, RowExclusiveLock, ...
granted, -- t = 已授予, f = 等待中
pid, -- 持有或等待锁的进程 PID
waitstart -- 等待开始时间(仅 waiting 的进程有值)
FROM pg_locks
ORDER BY pid, granted;
```text
这个查询给出当前所有的锁状态。"正在等待"的锁(`granted = false`)是排查锁等待链的入口。
### 阻塞链追踪:pg_blocking_pids()
`pg_blocking_pids(pid)` 是最实用的锁诊断函数:给定一个进程 PID,返回阻塞它的所有进程的 PID:
```sql
SELECT
pid,
pg_blocking_pids(pid) AS blocked_by,
wait_event_type,
wait_event,
state,
query
FROM pg_stat_activity
WHERE wait_event_type = 'Lock'
AND pid != pg_backend_pid();追踪完整的等待链——A 等 B,B 等 C:
-- 递归查找锁等待链
WITH RECURSIVE wait_chain AS (
-- 基准:所有正在等待锁的进程
SELECT
pid,
pg_blocking_pids(pid) AS blockers,
state,
query,
1 AS level
FROM pg_stat_activity
WHERE wait_event_type = 'Lock'
AND pid != pg_backend_pid()
UNION
-- 递归:找到阻塞者,以及阻塞者的阻塞者
SELECT
blocked.pid,
pg_blocking_pids(blocked.pid),
blocked.state,
blocked.query,
wc.level + 1
FROM pg_stat_activity blocked
JOIN wait_chain wc ON blocked.pid = ANY(wc.blockers)
WHERE pg_blocking_pids(blocked.pid) != ARRAY[]::int[]
)
SELECT DISTINCT pid, blockers, state, level, query
FROM wait_chain
ORDER BY level, pid;
```bash
### 哪些进程持锁最久
```sql
SELECT
l.pid,
a.usename,
a.application_name,
a.state,
a.query_start,
now() - a.query_start AS query_duration,
a.xact_start,
now() - a.xact_start AS xact_duration,
l.mode,
l.locktype,
l.relation::regclass AS relation,
l.granted,
a.query
FROM pg_locks l
JOIN pg_stat_activity a ON l.pid = a.pid
WHERE l.granted = true
AND l.locktype = 'relation'
AND l.relation IS NOT NULL
ORDER BY a.xact_start NULLS LAST;重点看 xact_duration
大的进程——长事务是锁积压的根源。
log_lock_waits 与 deadlock_timeout 的联合使用
在 postgresql.conf 中配置:
log_lock_waits = on
deadlock_timeout = 1s # 默认值
log_lock_waits = on
的效果:当一个进程等待锁定超过 deadlock_timeout
毫秒时,PG 生成一条日志:
LOG: process 12345 still waiting for ShareLock on transaction 67890 after 1000.123 ms
DETAIL: Process holding the lock: 12346. Wait queue: ...
这让你在死锁检测之外,也能及时发现”一个进程等锁等了很久”的问题——它可能不是死锁(等待图没有环),但可能是长事务持锁不释放引起的等待雪崩。
调整 deadlock_timeout 的权衡: -
设得太小(如 100ms):死锁检测更频繁,CPU
开销增加(每次检测都要扫描锁表构建等待图),log_lock_waits
日志也可能过于频繁。 - 设得太大(如
10s):死锁检测变慢,死锁事务等待更久才被回滚。但对于已知死锁概率极低的负载(如以只读查询为主、很少出现并发
DML 冲突的场景),适当调大可以减少开销。
终止阻塞进程
当确认某个进程是锁等待链的根源(例如一个 idle in transaction 的进程持锁不释放):
-- 先看目标进程的会话信息
SELECT pid, state, query, xact_start,
now() - xact_start AS xact_age
FROM pg_stat_activity
WHERE pid = 12345;
-- 终止进程(发送 SIGTERM,允许事务清理)
SELECT pg_terminate_backend(12345);
-- 更强制的方式(发送 SIGKILL 信号等价物,不做清理)
-- SELECT pg_cancel_backend(12345); -- 仅取消当前查询,不终止连接
```text
`pg_terminate_backend()` 发送 `SIGTERM` 信号给目标进程,目标进程在下一个 `CHECK_FOR_INTERRUPTS()` 调用处检测到并执行事务回滚和锁释放。如果目标进程卡在内核 I/O 中(如阻塞在磁盘读取),可能需要等待 I/O 超时才能响应。
---
## 八、实验:构造并观察锁等待
### 构造表级锁等待
在 Session A 中:
```sql
BEGIN;
LOCK TABLE test_table IN ACCESS EXCLUSIVE MODE;
-- 不要 COMMIT,保持持锁状态在 Session B 中:
SELECT * FROM test_table;
-- 阻塞,等待 AccessShareLock
```text
在 Session C(诊断窗口)中观察:
```sql
SELECT
pid,
mode,
granted,
relation::regclass,
waitstart
FROM pg_locks
WHERE relation = 'test_table'::regclass
AND NOT granted;
SELECT pid, pg_blocking_pids(pid), wait_event, query
FROM pg_stat_activity
WHERE pid = (SELECT pid FROM pg_locks
WHERE relation = 'test_table'::regclass
AND NOT granted
LIMIT 1);构造死锁
Session A:
BEGIN;
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
-- 持有 id=1 的 RowExclusiveLock
```text
Session B:
```sql
BEGIN;
UPDATE accounts SET balance = balance - 50 WHERE id = 2;
-- 持有 id=2 的 RowExclusiveLock然后 Session A 试图更新 id=2:
UPDATE accounts SET balance = balance + 100 WHERE id = 2;
-- 等待 id=2 的锁,被 Session B 阻塞
```text
Session B 试图更新 id=1:
```sql
UPDATE accounts SET balance = balance + 50 WHERE id = 1;
-- 等待 id=1 的锁,被 Session A 阻塞,形成环路
-- 等待 deadlock_timeout 后,PG 检测到死锁,回滚其中一个事务:
-- ERROR: deadlock detected观察行级锁
sql -- 看到 tuple 级别的锁 SELECT locktype, mode, granted, transactionid, pid FROM pg_locks WHERE locktype IN ('transactionid', 'tuple');text
行级锁的 locktype 通常是
transactionid(等待目标事务)或
tuple(等待元组)。tuple
锁用于当多个等待者竞争同一个元组时,确保它们按顺序醒来。
九、关键要点
三层锁体系各有不可替代的定位。SpinLock 用硬件指令保护微秒级临界区,不能发生上下文切换。LWLock 用 CAS + 信号量保护共享内存结构,先自旋后阻塞。Heavyweight Lock 提供事务语义、死锁检测和等待队列。
LWLock 经历了三代演进。PG 9.4 前用 System V 信号量,9.5+ 转向 CAS 原子操作,PG 16 的
LWLockWaitListLock用 per-lock 等待链表消除了惊群效应。Heavyweight Lock 的核心是锁表和冲突矩阵。
LockAcquire()通过哈希表查找 locktag、用冲突矩阵判断能否授予、不能授予则进入 FIFO 等待队列。fast path 优化跳过了大部分AccessShareLock的哈希表操作。死锁检测是 BFS 等待图算法。每隔
deadlock_timeout毫秒触发一次,从等待进程出发搜索环路,选择回滚代价最小的事务作为牺牲者。行级锁通过 t_infomask 的 lock bits 实现。
HEAP_XMAX_LOCK_ONLY和HEAP_XMAX_EXCL_LOCK区分 FOR SHARE 和 FOR UPDATE,MultiXact 处理多个事务同时持有行共享锁。pg_locks+pg_blocking_pids()是排查锁问题的首要工具。结合pg_stat_activity和log_lock_waits,可以追踪完整的锁等待链并定位根因。
上一章:Buffer Manager 下一章:事务与子事务,拆解事务状态机、子事务(savepoint)的栈式实现、2PC 的 WAL 记录和状态文件,以及事务 ID 分配的回卷保护。
参考资料
源码(PG 17)
src/backend/storage/lmgr/lwlock.c:LWLockAcquire(), LWLockRelease(), LWLockWaitForVar(), LWLockInitialize()src/backend/storage/lmgr/lock.c:LockAcquire(), LockRelease(), LockReleaseAll(), GrantLock(), FastPathGrantRelationLock()src/backend/storage/lmgr/deadlock.c:DeadLockCheck(), FindLockCycle(), DeadLockReport()src/backend/storage/lmgr/proc.c:ProcSleep(), ProcWakeup(), CheckDeadLock()src/backend/storage/lmgr/lmgr.c:LockRelationOid(), UnlockRelationOid() – 高层锁管理接口src/include/storage/lock.h:LOCK, LOCKTAG, PROCLOCK, LockMethodData 结构体定义src/include/storage/lwlock.h:LWLock, LWLockPadded 结构体定义src/include/storage/s_lock.h:SpinLock TAS/CAS 实现(平台依赖部分)src/include/storage/spin.h:SpinLockAcquire(), SpinLockRelease() 宏src/include/access/htup_details.h:HEAP_XMAX_LOCK_ONLY / HEAP_XMAX_EXCL_LOCK / HEAP_XMAX_KEYSHR_LOCK / HEAP_XMAX_IS_MULTI 标志位src/backend/access/heap/heapam.c:heap_lock_tuple() – 行级锁的入口src/backend/access/transam/multixact.c:MultiXact 管理
官方文档
- PostgreSQL Documentation, Chapter 13: Concurrency Control(锁模式、冲突矩阵、行级锁语义)
- PostgreSQL Documentation, Chapter 28:
Reliability(
deadlock_timeout,log_lock_waits的 GUC 定义) - PostgreSQL Documentation, Chapter 50: Overview of PostgreSQL Internals, Section 50.3: Lock Management
- PostgreSQL Documentation, Section 28.3.1: Lock
Monitoring(
pg_locks视图字段说明) - PostgreSQL Documentation, Section 9.27: System
Administration Functions(
pg_blocking_pids(),pg_terminate_backend()等)
开发者讨论与文章
- pgsql-hackers 邮件列表:PG 9.5 LWLock
原子操作实现(commit
ab5192a及其讨论线程) - pgsql-hackers 邮件列表:PG 16
LWLockWaitListLock(commit4f299e4的讨论与设计权衡) - Andres Freund, “Improving LWLock Scalability” (PGCon 2015) – 原子操作 LWLock 的设计思路
同主题继续阅读
把当前热点继续串成多页阅读,而不是停在单篇消费。
【PG 内核】进程模型与共享内存:Postmaster 如何管理 100 个 Backend
拆解 PostgreSQL 多进程架构的核心:Postmaster 的启动与信号处理、Backend 进程的 fork()→InitPostgres→主循环生命周期、CreateSharedMemoryAndSemaphores() 的共享内存初始化流程、PGPROC/ProcArray/PGXACT 等关键共享内存结构的内存布局,以及 Background Worker 的注册与调度。理解了这个地基,才能理解 PG 为什么用进程而不是线程,以及 max_connections 为什么不能随便调大。
【PG 内核】监控体系与告警设计:从内核机制出发定义该监控什么
不从 Grafana 模板照抄,而是从 PG 内核机制推导出必须监控的六个维度:连接与 wait_event、存储膨胀与 XID wraparound、WAL 与复制延迟、查询性能突变、锁等待链、以及 shared_buffers 命中率骗局。每个维度配具体 SQL 和指标解读,告警阈值给出内核依据而非拍脑袋数字,同时盘点 pg_stat_statements queryid 冲突、track_io_timing 开销、pg_stat_activity 自身代价等监控工具本身的陷阱。
【PG 内核】经典故障模式与排查手册:五个真实事故的内核根因
拆解 PG 生产环境中最危险的五种故障模式——连接风暴与 work_mem 连锁效应、事务 ID wraparound 危机完整时间线、replication slot 溢出多米诺效应、OOM 连锁 kill、长事务 idle in transaction 隐性破坏。每个故障给出可复现的触发方法、Mermaid 时序图标注事件节点和排查断点、排查 SQL 脚本和修复边界,以及监控埋点策略让下次提前发现而非事后救火。
【PG 内核】性能异常调查方法论:从现象到内核根因的五层调查链
不是工具箱罗列,而是一条按顺序推进的调查链:从 pg_stat_statements 定位可疑 queryid,到 EXPLAIN (ANALYZE, BUFFERS) 解剖执行计划,到 pg_stat_activity + wait_event 诊断等待类型,到 pg_locks + pg_blocking_pids() 追踪锁等待树,最后用 OS 层工具(iostat/perf/bpftrace)确认物理瓶颈。覆盖三个特殊场景:计划缓存的快慢切换、CPU 100% 无慢查询的 LWLock 自旋根因、命中率 99% 但 IO 打满的统计骗局。