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

【PG 内核】Buffer Manager:为什么 shared_buffers 不是越大越好

文章导航

分类入口
databasekernel
标签入口
#postgresql#pg-kernel#buffer-manager#shared-buffers#clock-sweep#bgwriter#pg-buffercache#buffer-pool#ring-buffer#double-buffering

目录

Buffer Manager:为什么 shared_buffers 不是越大越好

你按照某篇博客的建议把 shared_buffers 从 128MB 调到了 32GB,重启之后 EXPLAIN (ANALYZE, BUFFERS) 里的 shared hit 确实多了——但 TPS 反而跌了,内存占满,OS 开始 swap。问题出在哪里?

答案是 double buffering:PG 的 shared_buffers 和 OS page cache 是两套独立的缓存系统,互不知道对方的存在。同一页 8KB 数据可能同时存在于 shared_buffers 和 OS page cache 中,占用 16KB 物理内存。当 shared_buffers 大到挤压 OS page cache 时,不仅浪费内存,还会因为 checkpoint 写脏页量增大、OS 预读效率下降而拖慢整体性能。

本文从源码路径拆解 Buffer Manager 的完整运作:shared_buffers 的三层组织结构、ReadBuffer 的访问协议和 pin/unpin 机制、Clock Sweep 替换算法的决策逻辑、bgwriter 的写入触发条件、以及批量扫描的 ring buffer 隔离策略。读完你就能用 pg_buffercache 观察 buffer 分布、理解 EXPLAIN (BUFFERS) 输出背后的 buffer 行为,为你的场景找到 shared_buffers 的合理取值。


一、shared_buffers 的内部组织

1.1 三层结构

shared_buffers 在共享内存中被组织为三层,由 InitBufferPool()src/backend/storage/buffer/buf_init.c)在 CreateSharedMemoryAndSemaphores() 中一次性分配:

BufferDescriptors[N]     Buffer Pool (N x 8KB)      Buffer Strategy
     ↓                         ↓                         ↓
  [desc 0]  ──────────→  [page 0, 8KB]           StrategyControl
  [desc 1]  ──────────→  [page 1, 8KB]           (nextVictimBuffer,
  [desc 2]  ──────────→  [page 2, 8KB]             completePasses,
   ...                                               统计字段)
  [desc N-1] ──────────→ [page N-1, 8KB]
```text

其中 $N = \text{shared\_buffers} / 8\text{KB}$。如果你设了 1GB 的 shared_buffers,$N = 131072$ 个 slot。第 $i$ 个 descriptor 指向第 $i$ 个 8KB 数据块,两者一一对应。

### 1.2 BufferDesc:一个 buffer 的完整元数据

```c
// src/include/storage/buf_internals.h
typedef struct BufferDesc {
    BufferTag       tag;                // buffer 中当前页面的标识
    pg_atomic_uint32 state;             // 状态位(见 1.3 节)
    int             buf_id;             // buffer 在数组中的索引 (0..N-1)
    int             freeNext;           // 空闲链表指针(-1 表示不在链表中)
    LWLock          content_lock;       // 页面内容的读写保护锁
    int             wait_backend_pid;   // 正在等待该 buffer pin 的 Backend PID
    int             refcount;           // 当前的 pin 数量
    unsigned short  usage_count;        // Clock Sweep 使用的访问计数 (0-5)
} BufferDesc;

BufferTag 是全局唯一页面标识:

// src/include/storage/buf_internals.h
typedef struct buftag {
    RelFileLocator  rlocator;     // 表空间 OID + 数据库 OID + 关系 OID
    ForkNumber      forkNum;      // 哪个 fork:MAIN / FSM / VM / INIT
    BlockNumber     blockNum;     // 页面在文件中的块号
} BufferTag;
```text

`ForkNumber` 区分同一个关系文件的不同分支:
- `MAIN_FORKNUM`:数据页
- `FSM_FORKNUM`:Free Space Map
- `VISIBILITYMAP_FORKNUM`:Visibility Map
- `INIT_FORKNUM`:unlogged 表的初始化 fork

### 1.3 state 字段的位域编码

`state` 字段用位域编码 buffer 的所有状态信息(`src/include/storage/buf_internals.h`):

| 位域 | 含义 |
|------|------|
| `BM_LOCKED` | buffer header 自旋锁,保护 tag 和 state 的修改 |
| `BM_DIRTY` | 页面已被修改,需要 bgwriter 或 checkpoint 写入磁盘 |
| `BM_VALID` | buffer 中包含有效数据(页面已从磁盘读出) |
| `BM_TAG_VALID` | tag 字段有效(buffer 已分配给某个页面) |
| `BM_IO_IN_PROGRESS` | 正在从磁盘读取该页面到 buffer 中 |
| `BM_IO_ERROR` | 上一次 I/O 操作时发生错误 |
| `BM_JUST_DIRTIED` | 页面刚被标记为脏页,bgwriter 尚未注意到 |
| `BM_PIN_COUNT_WAITER` | 有其他 Backend 正在等待此 buffer 的 pin count 降到零 |
| `BM_CHECKPOINT_NEEDED` | 该 buffer 必须在下一个 checkpoint 写入磁盘 |
| `BM_PERMANENT` | buffer 不会被驱逐——用于 critical 操作 |

这些位域的组合构成了 buffer 的完整状态语义。例如 `BM_IO_IN_PROGRESS` 和 `BM_DIRTY` 在逻辑上互斥——一个正在从磁盘读入的页面不可能同时是脏页。状态转换通过原子 CAS 操作保护。

### 1.4 Buffer Table:从 BufferTag 到 buffer ID 的映射

Buffer Table 是一个共享内存中的 hash table(`src/backend/storage/buffer/buf_table.c`),key 为 `BufferTag`,value 为 `buf_id`。它的存在让缓存查找变成 $O(1)$ 而非 $O(N)$:

```c
// 查找流程(简化)
BufferLookupEnt *entry = BufTableLookup(&tag, &hashcode);
if (entry != NULL) {
    buf_id = entry->id;   // buffer 已在 shared_buffers 中
} else {
    // buffer 不在 shared_buffers 中,需要分配或替换一个 slot
}

为减少锁争用,Buffer Table 采用分区哈希:128 个 partition,每个 partition 有独立的 LWLock。查找时先计算 tag 的 hash、确定 partition、获取该 partition 的共享锁,再查 hash table。


二、Buffer 访问协议:ReadBuffer 的完整路径

2.1 核心接口

所有对数据页的访问都经过一个核心接口:

// src/include/storage/bufmgr.h
Buffer ReadBuffer(Relation reln, BlockNumber blockNum);
Buffer ReadBufferExtended(Relation reln, ForkNumber forkNum,
                          BlockNumber blockNum, ReadBufferMode mode,
                          BufferAccessStrategy strategy);
```text

`ReadBufferMode` 决定对目标页的预期操作:
- `RBM_NORMAL`:从磁盘读入(如不在 buffer pool 中),以 shared mode pin 住。
- `RBM_ZERO_AND_LOCK`:分配一个新页面,返回被 pin 且 content lock 为 exclusive 的 buffer——用于表文件扩展或索引分裂。
- `RBM_ZERO_AND_CLEANUP_LOCK`:类似上面,但使用 cleanup lock,用于 VACUUM 清除 dead tuples。

`strategy` 是可选的 `BufferAccessStrategy`,用于批量扫描的 ring buffer 保护(第六节)。

### 2.2 ReadBuffer 的完整调用链

```text
ReadBuffer(relation, blockNum)
  → ReadBuffer_common(relation, MAIN_FORKNUM, blockNum, RBM_NORMAL, NULL, &hit)
    → 构造 BufferTag (rlocator, forkNum, blockNum)
    → BufTableLookup(&tag)
      ├─ 找到 → PinBuffer(buf, strategy) → 返回 Buffer
      └─ 未找到 → BufferAlloc(smgr, ..., &tag, strategy)
                    → StrategyGetBuffer(strategy)  // 执行 Clock Sweep
                    → 如果 buffer 脏 → FlushBuffer() 写回
                    → 设置 tag 为新页
                    → smgrread() 从磁盘读入
                    → 清除 BM_IO_IN_PROGRESS,设置 BM_VALID
                    → PinBuffer()
                    → 返回 Buffer
// src/backend/storage/buffer/bufmgr.c, ReadBuffer_common()
// 核心逻辑(简化)
static Buffer ReadBuffer_common(SMgrRelation smgr, ...,
                                BlockNumber blockNum, ReadBufferMode mode,
                                BufferAccessStrategy strategy, bool *hit) {
    BufferTag tag;
    InitBufferTag(&tag, &smgr->smgr_rlocator.locator, forkNum, blockNum);

    buf_id = BufTableLookup(&tag, &hashcode);

    if (buf_id >= 0) {
        // 命中:pin 并返回
        *hit = true;
        buf = PinBuffer(bufDesc, strategy);
        return BufferDescriptorGetBuffer(bufDesc);
    }

    // 未命中:分配新 slot
    *hit = false;
    buf = BufferAlloc(smgr, ..., &tag, blockNum, strategy, ...);
    return buf;
}
```text

`Buffer` 类型实际是 `int`,值为 `buf_id + 1`。设计为 non-zero 值避免与 `InvalidBuffer = 0` 混淆。

### 2.3 Pin 和 Unpin

Pin 机制是 Buffer Manager 最基本的协议——标记"有人在用这个 buffer",禁止 Clock Sweep 偷走它:

```c
// src/backend/storage/buffer/bufmgr.c
// Pin:增加 refcount
static void PinBuffer(BufferDesc *buf, BufferAccessStrategy strategy) {
    buf->refcount++;
    if (strategy == NULL) {
        // 普通访问:递增 usage_count(上限 5)
        if (buf->usage_count < BM_MAX_USAGE_COUNT)
            buf->usage_count++;
    }
    // 有 strategy 时走 ring buffer 逻辑(第六节)
}

// Unpin:减少 refcount
void UnpinBuffer(BufferDesc *buf) {
    Assert(buf->refcount > 0);
    buf->refcount--;
    if (buf->refcount == 0) {
        // 如果有等待者(BM_PIN_COUNT_WAITER),唤醒之
        if (pg_atomic_read_u32(&buf->state) & BM_PIN_COUNT_WAITER)
            SignalBufferWaiter(buf);
    }
}

关键规则:

  1. 每次 ReadBuffer 返回的 buffer 都有 refcount = 1,用完必须调用 ReleaseBuffer()(内部调用 UnpinBuffer)。忘 unpin 的 buffer 永久占用 slot,最终导致 ERROR: no unpinned buffers available
  2. 同一个 Backend 可以对同一 buffer 多次 pin(refcount 可大于 1),但必须等量的 unpin 才能释放。
  3. refcount > 0 的 buffer 永远不被 Clock Sweep 选中——它被保护在 buffer pool 中。

2.4 Buffer Content Lock

Pin 表达”我正在使用”,不提供读写互斥。保护页面内容的是 content_lock(一个 LWLock):

ReadBuffer 返回后,content_lock 以 shared 模式持有。调用者如需修改页面内容,需显式调用 LockBuffer(buf, BUFFER_LOCK_EXCLUSIVE) 升级为独占锁。


三、Buffer 状态机

一个 buffer 在其生命周期中经历四种逻辑状态,由 state 字段的 flag 组合决定:

stateDiagram-v2
    direction LR
    [*] --> Unused : 初始化
    Unused --> Pinned_Clean : ReadBuffer() 命中或载入
    Pinned_Clean --> Pinned_Dirty : 写入数据
    Pinned_Dirty --> Unpinned_Dirty : Unpin, refcount=0
    Pinned_Clean --> Unpinned_Clean : Unpin, refcount=0
    Unpinned_Dirty --> Pinned_Clean : bgwriter 刷盘后
    Unpinned_Dirty --> Pinned_Dirty : 再次 pin
    Unpinned_Clean --> Pinned_Clean : 再次 pin
    Unpinned_Clean --> Unused : Clock Sweep 回收 (无需刷盘)
    Unpinned_Dirty --> Unused : Clock Sweep 回收 (先刷盘)
```text

四种状态:

| 状态 | refcount | BM_DIRTY | 含义 |
|------|----------|----------|------|
| **Unused** | 0 | 0 | 空 buffer,`BM_TAG_VALID` 未设置。初始状态或刚被回收 |
| **Unpinned Clean** | 0 | 0 | 包含有效页面,与磁盘一致,无人在用。最便宜的回收目标 |
| **Unpinned Dirty** | 0 | 1 | 包含有效页面,已被修改但未刷盘。回收前必先写盘 |
| **Pinned (Clean/Dirty)** | $>$ 0 | 0/1 | 有人正在使用,不可回收 |

`BM_DIRTY` 的置位发生在 Backend 修改页面内容后、调用 `MarkBufferDirty()` 时。此时 PG 不会立刻写盘,而是等待 bgwriter 或 checkpoint 处理该页。

---

## 四、Clock Sweep 替换算法

### 4.1 为什么是 Clock Sweep

当 `ReadBuffer` 请求的页面不在 shared_buffers 中时,BufferAlloc 必须找一个可驱逐的 buffer slot。可驱逐意味着 refcount == 0 且 usage_count == 0。

PG 选择 Clock Sweep 而非纯 LRU(双向链表),原因很具体:纯 LRU 每次访问都要移动链表节点,在高并发下锁争用严重。Clock Sweep 只需要递增一个全局指针和递减一个计数器,大部分扫描无需持锁——只在最终选中目标时才做一次 CAS。

### 4.2 usage_count:页面的"温度"

每个 buffer 有一个 `usage_count` 字段(0-5,`BM_MAX_USAGE_COUNT = 5`)。它的语义类似于操作系统的页面引用位:

- 每当一个页面被 pin(被访问),`usage_count` 加 1(上限 5)。
- 当 Clock Sweep 指针经过一个 buffer 时,`usage_count` 减 1——减到 0 表示这个 buffer 已经"冷"了,可被回收。

注意:不是每次 unpin 都更新 usage_count。递增逻辑在 `PinBuffer()` 中,且如果调用者使用了 `BAS_BULKREAD` 策略,usage_count **不递增**(第六节解释原因)。

### 4.3 StrategyControl 与 StrategyGetBuffer

```c
// src/backend/storage/buffer/freelist.c
typedef struct {
    int         nextVictimBuffer;    // 时钟指针——下一次扫描的起始 buffer ID
    int         firstFreeBuffer;     // 空闲链表头(-1 表示链表为空)
    int         lastFreeBuffer;      // 空闲链表尾
    uint32      completePasses;      // 完成的完整扫描次数(统计用)
    pg_atomic_uint32 numBufferAllocs; // 累计 buffer 分配次数
} BufferStrategyControl;

static BufferStrategyControl *StrategyControl;

nextVictimBuffer 是时钟指针——一个递增的 int,在 [0, NBuffers-1] 范围内循环:

// src/backend/storage/buffer/freelist.c, StrategyGetBuffer()
// Clock Sweep 的核心——寻找一个可回收的 buffer
BufferDesc *StrategyGetBuffer(BufferAccessStrategy strategy, int *buf_state) {
    // 1. 如果 strategy 有自己的 ring buffer,先尝试从那里获取
    if (strategy != NULL) {
        buf = GetBufferFromRing(strategy);
        if (buf != NULL) return buf;
    }

    // 2. 尝试全局空闲链表
    buf = GetFreeBuffer();
    if (buf != NULL) return buf;

    // 3. Clock Sweep:从 nextVictimBuffer 开始扫描
    for (;;) {
        buf = GetBufferDescriptor(StrategyControl->nextVictimBuffer);

        // 推进时钟指针(到达末尾绕回 0)
        StrategyControl->nextVictimBuffer++;
        if (StrategyControl->nextVictimBuffer >= NBuffers) {
            StrategyControl->nextVictimBuffer = 0;
            StrategyControl->completePasses++;
        }

        // 检查是否可用
        state = pg_atomic_read_u32(&buf->state);
        if ((state & BM_LOCKED) || buf->refcount > 0)
            continue;  // 被锁或有人用——跳过

        if (buf->usage_count > 0) {
            buf->usage_count--;   // 第二次机会——降温
            continue;
        }

        // usage_count == 0 且 refcount == 0:可以回收
        if (pg_atomic_compare_exchange_u32(&buf->state, &state,
                                           state | BM_LOCKED)) {
            *buf_state = state;
            return buf;  // 找到一个可回收 buffer
        }
        // CAS 失败——被其他 Backend 抢先了,继续扫描
    }
}
```bash

### 4.4 性能特点

1. **无锁扫描**:大多数 buffer 在扫描时不会被锁——只有命中可回收 buffer 时才做 CAS 获取 `BM_LOCKED`。被锁或 refcount > 0 的 buffer 直接跳过。
2. **摊销 $O(1)$**:虽然单次 sweep 可能遍历多个 buffer,但指针不回溯。在正常负载下平均遍历十几个 buffer 就能找到目标。
3. **退化场景**:当几乎所有 buffer 的 usage_count 都很高且 refcount > 0 时(例如大量 Backend 同时 pin 了很多页),sweep 可能遍历大半圈。`completePasses` 的激增就是这个退化信号。

### 4.5 与其他替换算法的对比

| 算法 | 多进程友好度 | 命中率 | 实现复杂度 |
|------|-------------|--------|-----------|
| **Clock Sweep (PG)** | 极好(无锁扫描) | 接近 LRU ||
| 纯 LRU(双向链表) | 差(每次访问需移链表节点) | 理论最优 ||
| LFU | 中等 | 对低频扫描友好 ||
| ARC(自适应) | 差(复杂状态机) | 较好 ||

PG 选择 Clock Sweep 是在高并发环境下用"略低于最优的命中率""每次访问几乎零争用"

---

## 五、bgwriter:后台写入器

### 5.1 bgwriter 的角色

bgwriter(Background Writer)是 PG 中唯一负责"定期将 shared_buffers 脏页写到磁盘"的进程。它存在的原因很简单:**Backend 的时间应该花在执行查询上,而不是做 `write()`**

当一个 Backend 修改了页面内容后,它只调用 `MarkBufferDirty()` 设置 `BM_DIRTY` 位。实际的写入由两个进程分工:
- **bgwriter**:定期扫描 shared_buffers,将脏页写到 OS(`write()`,不 `fsync()`)。
- **checkpointer**:在 checkpoint 时确保所有在 checkpoint 开始前被标记为脏的页面已 `fsync()` 到磁盘。

### 5.2 触发条件与写入策略

bgwriter 的主循环在 `src/backend/postmaster/bgwriter.c` 的 `BackgroundWriterMain()` 中,核心策略函数 `BgBufferSync()` 在 `src/backend/storage/buffer/bufmgr.c`:

```c
// BgBufferSync() 的简化逻辑
recent_alloc = 最近几轮平均每次分配的新 buffer 数量;
scan_target  = recent_alloc * bgwriter_lru_multiplier;
if (scan_target > bgwriter_lru_maxpages)
    scan_target = bgwriter_lru_maxpages;

// 从 StrategyControl->nextVictimBuffer 开始扫描 scan_target 个 buffer
// 将遇到的脏页 (BM_DIRTY set) 刷入磁盘

关键 GUC:

GUC 默认值 含义
bgwriter_delay 200ms bgwriter 每轮扫描之间的休眠时间
bgwriter_lru_maxpages 100 每轮最多刷多少个脏页
bgwriter_lru_multiplier 2.0 根据最近分配 buffer 速率,决定本轮扫描强度

bgwriter 不是逐出算法——它只把脏页写盘,不改变 buffer 的 tag 或状态。buffer 的回收仍然由 Clock Sweep 负责。bgwriter 的作用是确保 Clock Sweep 在需要回收一个 dirty buffer 时不需要等待磁盘 I/O。

5.3 关键监控指标

SELECT buffers_checkpoint,   -- checkpoint 期间写了多少个 buffer
       buffers_clean,         -- bgwriter 的增量写入量
       maxwritten_clean,      -- bgwriter 达到 maxpages 上限的次数
       buffers_backend,       -- Backend 被迫自己写脏页的次数
       buffers_backend_fsync, -- Backend 被迫自己 fsync 的次数
       buffers_alloc          -- 已分配 buffer 的累计次数
FROM pg_stat_bgwriter;
```text

`buffers_backend` 是最需要关注的指标——如果这个值持续增长,说明 bgwriter 跟不上 dirty buffer 产生速度,Backend 在 `BufferAlloc()` 中被迫自己写盘。这通常是 `bgwriter_lru_maxpages` 设太小或磁盘 I/O 能力不足的信号。

---

## 六、Buffer Access Strategy:Ring Buffer 隔离

### 6.1 问题:Seq Scan 污染 buffer pool

一个 `SELECT * FROM big_table` 的顺序扫描会依次读取表中每一页。如果表是 100GB 而 shared_buffers 只有 8GB,一次全表扫描就能把所有热页驱逐出去,换上 big_table 的页面——这些页面读完之后很可能再也不会被用到。这就是 **buffer pool 污染**

PG 的防御机制是 **Buffer Access Strategy with Ring Buffer**:为批量扫描操作分配一个小的环形缓冲区,限制它们能使用的 buffer slot 数量,隔离整个 buffer pool 不受影响。

### 6.2 环形缓冲区的设计与三种策略

```c
// src/include/storage/freelist.h
typedef struct BufferAccessStrategyData {
    BufferAccessStrategyType btype;  // 策略类型
    int         ring_size;           // 环形缓冲区大小(buffer 个数)
    int         current;             // 当前在环形缓冲区中的位置
    bool        current_was_in_ring;
    Buffer      buffers[FLEXIBLE_ARRAY_MEMBER];  // 环形缓冲区中的 buffer
} BufferAccessStrategyData;

三种策略类型及其使用场景(src/backend/storage/buffer/freelist.c):

策略类型 ring_size 典型触发场景 效果
BAS_NORMAL 0(无 ring) 随机访问(索引扫描) 不限制
BAS_BULKREAD 256KB(32 页) SELECT * FROM big 的 Seq Scan 限制在 32 页小环内
BAS_BULKWRITE 16MB(2048 页) COPYCREATE INDEX 限制在 2048 页中环内
BAS_VACUUM 256KB(32 页) VACUUM 限制在 32 页小环内,类似 BULKREAD

BufferAlloc 发生 cache miss 需要从磁盘读入新页面时,如果调用者提供了 ring strategy,先在 ring 内找可用 slot 或按 ring 内的简化 Clock Sweep 替换——ring 之外的 buffer 完全不受影响

6.3 Ring Buffer 中的 Clock Sweep

Ring buffer 内部有自己的简化版 Clock Sweep:

// src/backend/storage/buffer/freelist.c, GetBufferFromRing()
static BufferDesc *GetBufferFromRing(BufferAccessStrategy strategy) {
    for (;;) {
        buf = strategy->buffers[strategy->current];
        strategy->current = (strategy->current + 1) % strategy->ring_size;

        if (buf == NULL || buf->refcount != 0)
            continue;

        return buf;  // 找到可用 slot
    }
}
```text

Ring 内的 usage_count 被限制为 1(只有 1 bit 的"最近用过"标志),因为 ring 内的页面不应该占据太高的 usage_count 来抵御多轮全局 Clock Sweep。

### 6.4 对缓存命中率的实际影响

Ring buffer 解释了为什么 `pg_stat_bgwriter` 的统计要小心解读——一次全表扫描被限制在 ring 内,不影响其他查询的命中率。但如果你反复执行同一张表的全表扫描(比如监控脚本每 30 秒跑一次),ring 内的 32 个 slot 不断被替换,每次都是 miss——这就是 `EXPLAIN (ANALYZE, BUFFERS)` 输出中 `shared read` 的来源。

---

## 七、为什么 shared_buffers 不是越大越好

### 7.1 Double Buffering:同一页占用两倍内存

PG 的 shared_buffers 和 OS page cache 是**两套独立的缓存**——PG 读文件使用 `read()` 系统调用,OS 在文件系统层自动缓存读取的页面到 page cache。PG 并不知道 OS 也缓存了同样的数据,OS 也不知道 PG 在共享内存中已经有一份。

```text
物理内存布局(double buffering 示意)
+-----------------------------------+
| shared_buffers (e.g. 8GB)         |
| page A | page B | page C | ...    |
+-----------------------------------+
| OS page cache                     |
| page A | page B | page C | ...    |  ← 相同的页面!
+-----------------------------------+
| 剩余可用内存                       |
+-----------------------------------+

当 shared_buffers 占物理内存的 25% 以下时(例如 128GB RAM 设 8GB shared_buffers),double buffering 的影响可控。但当 shared_buffers 超过约 40% 可用内存时,OS page cache 被严重挤压,不但浪费内存,还会影响 OS 对 WAL 文件、索引文件、PG 可执行文件本身的缓存能力。

7.2 Checkpoint I/O 尖峰

shared_buffers 越大,checkpoint 时需要刷的脏页就越多。PG 的 checkpoint 必须在 checkpoint_timeout(默认 5 分钟)内完成。1GB shared_buffers 的 checkpoint 可能需要写 200MB 脏页(取决于工作负载),而 32GB 可能需要写 10GB+——这 10GB I/O 必须在短时间内完成,形成明显的 I/O 尖峰,拖慢所有正在执行的查询。

checkpoint_completion_target(默认 0.9)可以把写脏页摊开到 checkpoint_timeout * 0.9 的时间里。但如果 shared_buffers 太大、脏页数量远超磁盘带宽,摊开也无济于事。

7.3 Clock Sweep 的扫描延迟

当 shared_buffers 很大(比如 32GB = 4,194,304 个 8KB buffer),且大量 buffer 的 usage_count 都大于 0 时,clock sweep 找到空闲 buffer 的预期扫描距离变长。在大量 Backend 同时分配新 buffer 的场景下(如并发 seqscan 读冷数据),clock sweep 的争用可能放大。

7.4 调优原则

不要用经验公式(“shared_buffers = 25% RAM”)。正确的方法是:

  1. 从保守值开始(1-4GB)。
  2. pg_buffercache 观察 usage count 分布——如果大部分 buffer 的 usage count 集中在 0-1,说明 shared_buffers 已经超过实际工作集大小,可以适当减小。
  3. EXPLAIN (ANALYZE, BUFFERS) 观察查询的 shared hitshared read
  4. pg_stat_bgwriter 监控 buffers_backend——如果这个值持续增长,先调优 bgwriter 参数(bgwriter_lru_maxpagesbgwriter_delay)而非加大 shared_buffers。
  5. 逐步调大,用实际负载测试,找 TPS 的平台期。

一条在无法实测时参考的经验规则:初始值不超过物理内存的 25%,且不超过 8-10GB。超过 10GB 的 shared_buffers 在大多数场景下不是最优选择。


八、实验:用 pg_buffercache 观察 buffer 分布

8.1 启用扩展

CREATE EXTENSION pg_buffercache;
```bash

### 8.2 整体分布概览

```sql
-- 按 usage_count 和 dirty 状态统计 buffer 分布
SELECT
    isdirty  AS dirty,
    usagecount,
    COUNT(*) AS buffers
FROM pg_buffercache
GROUP BY isdirty, usagecount
ORDER BY isdirty, usagecount;

典型的 OLTP 数据库输出:

 dirty | usagecount | buffers
-------+------------+--------
 f     | 0          |   3241   ← 易被回收的"冷"缓存
 f     | 1          |   4512
 f     | 2          |   2103
 f     | 3          |   1500
 f     | 4          |    900
 f     | 5          |   1200   ← 最"热"的 clean 页
 t     | 0          |     45   ← 脏页(等待 bgwriter 刷盘)
 t     | 1          |     12
```text

`usagecount = 5` 的 buffer 需要经过 5 次 Clock Sweep 扫描才能被回收,是最受保护的热页。`usagecount = 0` 的 clean buffer 是最便宜的被回收目标。

如果大部分 buffer 的 usage count 集中在 0-1,说明 shared_buffers 对当前工作集来说偏大——很多 slot 分配给了不常用的页面。

### 8.3 按 relation 统计 buffer 占用

```sql
SELECT
    c.relname,
    COUNT(*) AS buffers,
    pg_size_pretty(COUNT(*) * 8192) AS total_size
FROM pg_buffercache b
JOIN pg_class c ON b.relfilenode = pg_relation_filenode(c.oid)
JOIN pg_database d ON b.reldatabase = d.oid
WHERE d.datname = current_database()
GROUP BY c.relname
ORDER BY 2 DESC
LIMIT 10;

如果某个大表占用了大量 buffer 但查询并不频繁访问它,可能是被 Seq Scan 污染了——检查该查询是否走了 BAS_BULKREAD(32 页 ring 不应该导致大量占用)。

8.4 观察 Clock Sweep 行为

-- 第一次快照
CREATE TEMP TABLE buf_snap_1 AS
SELECT usagecount, COUNT(*) AS cnt FROM pg_buffercache GROUP BY usagecount;

-- 执行压力查询(如 pgbench 或实际业务查询)
-- 等待约 30 秒

-- 第二次快照
CREATE TEMP TABLE buf_snap_2 AS
SELECT usagecount, COUNT(*) AS cnt FROM pg_buffercache GROUP BY usagecount;

-- 对比
SELECT
    s1.usagecount,
    s1.cnt AS before_cnt,
    s2.cnt AS after_cnt,
    s2.cnt - s1.cnt AS diff
FROM buf_snap_1 s1
JOIN buf_snap_2 s2 ON s1.usagecount = s2.usagecount
ORDER BY s1.usagecount;
```text

如果 `usagecount = 0` 的 count 在增大,说明 Clock Sweep 正在主动"降温"冷页——这是正常行为。如果 `usagecount = 5` 持续高位不降,说明有一批页面被频繁访问,被 Clock Sweep 完美保护在了池中。

### 8.5 构造 Seq Scan 观察 Ring Buffer 效果

```sql
-- 创建一个测试大表
CREATE TABLE test_big AS
SELECT generate_series(1, 5000000) AS id, repeat('x', 200) AS padding;

-- 记录 seqscan 前该表在 shared_buffers 中的页数
-- (执行 seqscan 前先清空缓存或重启 PG)
SELECT COUNT(*) AS pages_in_cache
FROM pg_buffercache b
JOIN pg_class c ON b.relfilenode = pg_relation_filenode(c.oid)
WHERE c.relname = 'test_big';

-- 执行 seqscan
SELECT COUNT(*) FROM test_big;

-- 再次查看该表在 shared_buffers 中的页数
SELECT COUNT(*) AS pages_in_cache
FROM pg_buffercache b
JOIN pg_class c ON b.relfilenode = pg_relation_filenode(c.oid)
WHERE c.relname = 'test_big';

你会观察到 seqscan 之后 test_big 在 shared_buffers 中的页面数不会等于表的总页面数——它被 BAS_BULKREAD ring buffer(32 页)限制住了,最多只占约 32 个 buffer,其余页面在 ring 循环中被挤出,usage count 保持为 0。


九、运维要点与故障模式

9.1 bgwriter 跟不上脏页产生速度

症状pg_stat_bgwriterbuffers_backend 持续增长,buffers_backend / buffers_clean 比率大于 10。

根因:bgwriter 每轮最多写 bgwriter_lru_maxpages(默认 100)个页面,每轮间隔 bgwriter_delay(默认 200ms)。如果脏页产生速度超过 100 pages / 0.2s = 500 pages/s = 4MB/s,backend 被迫自己写脏页。

修复:先降低 bgwriter_delay(如 50ms),再增大 bgwriter_lru_maxpages(如 500-1000)。同时检查 max_wal_size 是否太小——过频繁的 checkpoint 也会导致脏页集中写入。

9.2 大量 Backend 等待 BufferPin

症状:大量 Backend 的 wait_event_type = 'BufferPin'wait_event = 'BufferPin'pg_stat_activity 中该等待类型的连接数持续上升。

根因:一个 Backend 持有某个 buffer 的 pin(且 buffer 正在 I/O 操作中),其他需要同一 buffer 的 Backend 在 BufferPin 上等待。如果持 pin 的 Backend 本身在等一个重量级锁(Lock),就形成锁等待链。

排查

SELECT pid, wait_event_type, wait_event, query
FROM pg_stat_activity
WHERE wait_event_type = 'BufferPin' AND state = 'active';
```text

**修复**:定位持 pin 的 Backend,用 `pg_blocking_pids()` 追踪锁链。如果是因为磁盘 I/O 慢导致的 buffer I/O 时间过长,检查存储系统。

### 9.3 忘 unpin 导致 buffer 泄漏

**症状**:`pg_stat_bgwriter` 的 `buffers_alloc` 增长但 TPS 下降,`pg_buffercache` 中大量 buffer 的 `pinning_backends > 0` 且长时间不变。

**根因**:代码中没有正确配对的 `ReadBuffer` / `ReleaseBuffer`。忘 unpin 的 buffer 永远不能被 Clock Sweep 回收。

**排查**

```sql
SELECT COUNT(*) AS leaked_buffers
FROM pg_buffercache
WHERE pinning_backends > 0;

虽然有 pinning_backends > 0 的 buffer 也可能是正在被正常使用的,但如果这个数值持续高位且不随查询完成而下降,就是泄漏信号。

修复:通常不是 DBA 能修复的——需要定位是哪个扩展或自定义函数持有 buffer 不放。临时缓解手段是杀掉对应 Backend(pg_terminate_backend()),Postmaster 会在回收子进程时释放其所有 pin。


十、关键要点

  1. shared_buffers 由三层结构组成:BufferDescriptors 数组(元数据) + Buffer Pool(数据页) + StrategyControl(Clock Sweep 控制)。通过 Buffer Table 实现 \(O(1)\) 缓存查找。

  2. Buffer 访问协议是 pin/unpinReadBuffer() 返回 pin count = 1 的 buffer,用完必须 ReleaseBuffer() unpin。忘 unpin 等于永久占用一个 slot。

  3. Clock Sweep 是用并发性能换命中率的务实选择:无锁扫描、5 级 usage count 梯度降温、支持 ring buffer 的局部 sweep。completePasses 的增长速度是替换压力的直接信号。

  4. bgwriter 只负责提前写出脏页,不做逐出:它的作用是让 Clock Sweep 在需要回收脏页时立即复用,无需等待磁盘 I/O。buffers_backend 是 bgwriter 跟不上的第一信号。

  5. Ring Buffer(BufferAccessStrategy)防止批量扫描污染 poolBAS_BULKREADBAS_VACUUM 限制在 32 页小环内,BAS_BULKWRITE 限制在 2048 页中环内。

  6. shared_buffers 超过 8-10GB 后回报递减,三个原因:double buffering 浪费内存、checkpoint I/O 尖峰加剧、clock sweep 扫描延迟增大。正确值需通过 pg_buffercache 和实际负载实测确定。

上一章:WAL 内部机制 下一章:锁管理器


参考资料

源码(PG 17)

官方文档

论文

相关文章

同主题继续阅读

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

2026-06-16 · database / kernel

【PG 内核】配置陷阱与生产最佳实践:11 个最危险的 GUC 和它们的正确设置

逐一拆解 11 个最容易被误解和配错的 PostgreSQL GUC 参数:shared_buffers 的 double buffering 反噬、work_mem 作为'每个操作'而非'每个查询'的内存炸弹、effective_cache_size 和 random_page_cost 如何误导优化器走向灾难计划、fsync=off 和 synchronous_commit=off 的数据丢失边界、huge_pages 在容器中的静默退化、maintenance_work_mem 不足导致 VACUUM 瘫痪、idle_in_transaction_session_timeout 为什么必须设、log_lock_waits 与 deadlock_timeout 的联动、以及 log_min_duration_statement 与 auto_explain 的日志洪水叠加。每条配查验 SQL 和 shell 命令——不是'设成 X 就好了',而是'通过什么视图和日志确认当前设置有问题'。

2026-06-16 · database / kernel

【PG 内核】PostgreSQL 内核机制深度拆解

从进程模型到磁盘页面、从 MVCC 到流复制——对 PostgreSQL 内核做完整的源码级拆解。不止步于源码分析:26 篇中 6 篇是运维实战——经典故障的根因与排查路径、性能调查的五层工具链、配置陷阱与恢复边界。面向想读懂 PG 内核源码、在生产环境排查过问题、准备给 PG 贡献代码的工程师。

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 内核】页面布局与元组格式:PG 如何把一行数据塞进 8KB

拆解 PostgreSQL 的物理存储层:Page 的 8KB 布局(PageHeaderData、ItemId 数组、special space)、HeapTupleHeaderData 的字段语义(xmin/xmax/ctid/t_infomask/t_infomask2)、TOAST 外存机制的压缩阈值与四种策略(PLAIN/EXTENDED/EXTERNAL/MAIN),以及用 pageinspect 扩展直接观察页面字节。理解页面格式是理解 VACUUM、Index Scan、MVCC 可见性判断的共同前提。


By .