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

【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 页) COPY、CREATE 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 hit 和 shared read。
  4. 用 pg_stat_bgwriter 监控 buffers_backend——如果这个值持续增长,先调优 bgwriter 参数(bgwriter_lru_maxpages、bgwriter_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_bgwriter 中 buffers_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/unpin:ReadBuffer() 返回 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)防止批量扫描污染 pool:BAS_BULKREAD 和 BAS_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 .