土法炼钢兴趣小组的算法知识备份

【PG 内核】锁管理器:从 SpinLock 到死锁检测的三层体系

文章导航

分类入口
databasekernel
标签入口
#postgresql#pg-kernel#lock-manager#spinlock#lwlock#heavyweight-lock#deadlock-detection#row-locks#pg-locks#concurrency

目录

锁管理器:从 SpinLock 到死锁检测的三层体系

一条 UPDATE 语句执行时,PG 内核里至少有三种不同粒度的锁在工作:CPU 指令级的自旋锁保护共享内存计数器、LWLock 保护 Buffer 的 pin 状态和页面内容、Heavyweight Lock 保证两个事务不能同时修改同一行。它们不是”选一个用”,而是按持锁时间和争用模式逐层分工。

三层体系的核心思路是:持锁越短,实现越底层。SpinLock 的临界区只有几十个 CPU 周期,用硬件原子指令自旋等待;LWLock 的临界区覆盖一次共享内存结构的读写,自旋几轮后进内核等待;Heavyweight Lock 的临界区覆盖整个事务,等待队列、死锁检测、锁升级全在这一层处理。

本文从源码路径拆解这三层锁:它们各自解决什么问题、在什么场景下使用、实现上经历了哪些演进,以及在生产环境中如何通过 pg_lockspg_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 charint

// 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 内部共享内存结构:

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+ 的等待策略是先自旋再阻塞

  1. 循环调用 LWLockAttemptLock() 尝试 CAS 获取锁,最多自旋 spins_per_delay 次(默认 100 次,可通过 pg_test_spin_delay 校准)。
  2. 自旋仍失败后,递增 nwaiters,然后用 pg_atomic_fetch_or_u32()state 中设置 LW_FLAG_HAS_WAITERS 标志位。
  3. 调用 semop() 在信号量上阻塞等待。
  4. 被唤醒后,回到步骤 1。

这意味着当 LWLock 竞争激烈时,等待者不是一直空转——自旋仅作为”锁可能在几 microsecond 内释放”的优化,之后就让出 CPU。这种设计非常适合 PG 的典型负载:大多数 LWLock 的持锁时间非常短(一次内存读写),少数情况(I/O)才需要真正阻塞。

释放 LWLock 时,释放者检查 nwaitersLW_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_activitywait_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

三个标志位的组合对应三种行级锁:

当执行 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 锁用于当多个等待者竞争同一个元组时,确保它们按顺序醒来。


九、关键要点

  1. 三层锁体系各有不可替代的定位。SpinLock 用硬件指令保护微秒级临界区,不能发生上下文切换。LWLock 用 CAS + 信号量保护共享内存结构,先自旋后阻塞。Heavyweight Lock 提供事务语义、死锁检测和等待队列。

  2. LWLock 经历了三代演进。PG 9.4 前用 System V 信号量,9.5+ 转向 CAS 原子操作,PG 16 的 LWLockWaitListLock 用 per-lock 等待链表消除了惊群效应。

  3. Heavyweight Lock 的核心是锁表和冲突矩阵LockAcquire() 通过哈希表查找 locktag、用冲突矩阵判断能否授予、不能授予则进入 FIFO 等待队列。fast path 优化跳过了大部分 AccessShareLock 的哈希表操作。

  4. 死锁检测是 BFS 等待图算法。每隔 deadlock_timeout 毫秒触发一次,从等待进程出发搜索环路,选择回滚代价最小的事务作为牺牲者。

  5. 行级锁通过 t_infomask 的 lock bits 实现HEAP_XMAX_LOCK_ONLYHEAP_XMAX_EXCL_LOCK 区分 FOR SHARE 和 FOR UPDATE,MultiXact 处理多个事务同时持有行共享锁。

  6. pg_locks + pg_blocking_pids() 是排查锁问题的首要工具。结合 pg_stat_activitylog_lock_waits,可以追踪完整的锁等待链并定位根因。

上一章:Buffer Manager 下一章:事务与子事务,拆解事务状态机、子事务(savepoint)的栈式实现、2PC 的 WAL 记录和状态文件,以及事务 ID 分配的回卷保护。


参考资料

源码(PG 17)

官方文档

开发者讨论与文章

同主题继续阅读

把当前热点继续串成多页阅读,而不是停在单篇消费。

2026-06-16 · database / kernel

【PG 内核】进程模型与共享内存:Postmaster 如何管理 100 个 Backend

拆解 PostgreSQL 多进程架构的核心:Postmaster 的启动与信号处理、Backend 进程的 fork()→InitPostgres→主循环生命周期、CreateSharedMemoryAndSemaphores() 的共享内存初始化流程、PGPROC/ProcArray/PGXACT 等关键共享内存结构的内存布局,以及 Background Worker 的注册与调度。理解了这个地基,才能理解 PG 为什么用进程而不是线程,以及 max_connections 为什么不能随便调大。

2026-06-16 · database / kernel

【PG 内核】监控体系与告警设计:从内核机制出发定义该监控什么

不从 Grafana 模板照抄,而是从 PG 内核机制推导出必须监控的六个维度:连接与 wait_event、存储膨胀与 XID wraparound、WAL 与复制延迟、查询性能突变、锁等待链、以及 shared_buffers 命中率骗局。每个维度配具体 SQL 和指标解读,告警阈值给出内核依据而非拍脑袋数字,同时盘点 pg_stat_statements queryid 冲突、track_io_timing 开销、pg_stat_activity 自身代价等监控工具本身的陷阱。

2026-06-16 · database / kernel

【PG 内核】经典故障模式与排查手册:五个真实事故的内核根因

拆解 PG 生产环境中最危险的五种故障模式——连接风暴与 work_mem 连锁效应、事务 ID wraparound 危机完整时间线、replication slot 溢出多米诺效应、OOM 连锁 kill、长事务 idle in transaction 隐性破坏。每个故障给出可复现的触发方法、Mermaid 时序图标注事件节点和排查断点、排查 SQL 脚本和修复边界,以及监控埋点策略让下次提前发现而非事后救火。

2026-06-16 · database / kernel

【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 打满的统计骗局。


By .