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);
}
}关键规则:
- 每次
ReadBuffer返回的 buffer 都有 refcount = 1,用完必须调用ReleaseBuffer()(内部调用UnpinBuffer)。忘 unpin 的 buffer 永久占用 slot,最终导致ERROR: no unpinned buffers available。 - 同一个 Backend 可以对同一 buffer 多次 pin(refcount 可大于 1),但必须等量的 unpin 才能释放。
- refcount > 0 的 buffer 永远不被 Clock Sweep 选中——它被保护在 buffer pool 中。
2.4 Buffer Content Lock
Pin 表达”我正在使用”,不提供读写互斥。保护页面内容的是
content_lock(一个 LWLock):
- 读取页面:
LWLockAcquire(buf->content_lock, LW_SHARED) - 修改页面:
LWLockAcquire(buf->content_lock, LW_EXCLUSIVE)
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-4GB)。
- 用
pg_buffercache观察 usage count 分布——如果大部分 buffer 的 usage count 集中在 0-1,说明 shared_buffers 已经超过实际工作集大小,可以适当减小。 - 用
EXPLAIN (ANALYZE, BUFFERS)观察查询的shared hit和shared read。 - 用
pg_stat_bgwriter监控buffers_backend——如果这个值持续增长,先调优 bgwriter 参数(bgwriter_lru_maxpages、bgwriter_delay)而非加大 shared_buffers。 - 逐步调大,用实际负载测试,找 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。
十、关键要点
shared_buffers 由三层结构组成:BufferDescriptors 数组(元数据) + Buffer Pool(数据页) + StrategyControl(Clock Sweep 控制)。通过 Buffer Table 实现 \(O(1)\) 缓存查找。
Buffer 访问协议是 pin/unpin:
ReadBuffer()返回 pin count = 1 的 buffer,用完必须ReleaseBuffer()unpin。忘 unpin 等于永久占用一个 slot。Clock Sweep 是用并发性能换命中率的务实选择:无锁扫描、5 级 usage count 梯度降温、支持 ring buffer 的局部 sweep。
completePasses的增长速度是替换压力的直接信号。bgwriter 只负责提前写出脏页,不做逐出:它的作用是让 Clock Sweep 在需要回收脏页时立即复用,无需等待磁盘 I/O。
buffers_backend是 bgwriter 跟不上的第一信号。Ring Buffer(BufferAccessStrategy)防止批量扫描污染 pool:
BAS_BULKREAD和BAS_VACUUM限制在 32 页小环内,BAS_BULKWRITE限制在 2048 页中环内。shared_buffers 超过 8-10GB 后回报递减,三个原因:double buffering 浪费内存、checkpoint I/O 尖峰加剧、clock sweep 扫描延迟增大。正确值需通过
pg_buffercache和实际负载实测确定。
参考资料
源码(PG 17)
src/backend/storage/buffer/bufmgr.c:ReadBuffer_common(), BufferAlloc(), PinBuffer(), UnpinBuffer(), MarkBufferDirty(), FlushBuffer(), BgBufferSync()src/backend/storage/buffer/freelist.c:StrategyGetBuffer(), GetBufferFromRing(), GetAccessStrategy(), FreeAccessStrategy(), StrategyInitialize()src/backend/storage/buffer/buf_init.c:InitBufferPool(), 分区哈希表初始化src/backend/storage/buffer/buf_table.c:BufTableLookup(), BufTableInsert(), BufTableDelete()src/include/storage/buf_internals.h:BufferDesc, BufferTag, BufMappingPartition 定义src/include/storage/bufmgr.h:ReadBuffer(), ReleaseBuffer(), ReadBufferMode, BufferAccessStrategyType 接口定义src/include/storage/freelist.h:BufferAccessStrategyData 结构体定义src/backend/postmaster/bgwriter.c:BackgroundWriterMain()
官方文档
- PostgreSQL Documentation, Chapter 19: Server
Configuration —
shared_buffers,bgwriter_lru_maxpages,bgwriter_lru_multiplier,bgwriter_delay - PostgreSQL Documentation, Chapter 27: Monitoring
Database Activity —
pg_stat_bgwriter,pg_buffercache - PostgreSQL Documentation, Appendix F: Additional Supplied Modules — pg_buffercache 扩展手册
- PostgreSQL Documentation, Chapter 68: Database Physical Storage — 页面格式与 Buffer Manager 的关系
论文
- Effelsberg, W. & Haerder, T. Principles of Database Buffer Management. ACM Transactions on Database Systems, 1984. — Buffer 替换算法的经典论文,Clock Sweep 的理论基础。
- O’Neil, E. J., O’Neil, P. E. & Weikum, G. The LRU-K Page Replacement Algorithm for Database Disk Buffering. SIGMOD 1993. — LRU-K 算法,PG usage_count 梯度设计的思想来源。
- Johnson, T. & Shasha, D. 2Q: A Low Overhead High Performance Buffer Management Replacement Algorithm. VLDB 1994. — 2Q 算法,另一种”接近 LRU 但更低开销”的思路。
相关文章
- 本站存储工程系列:Buffer Pool 设计 — 通用 Buffer Pool 原理,可作为本篇的前置背景阅读。
同主题继续阅读
把当前热点继续串成多页阅读,而不是停在单篇消费。
【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 就好了',而是'通过什么视图和日志确认当前设置有问题'。
【PG 内核】PostgreSQL 内核机制深度拆解
从进程模型到磁盘页面、从 MVCC 到流复制——对 PostgreSQL 内核做完整的源码级拆解。不止步于源码分析:26 篇中 6 篇是运维实战——经典故障的根因与排查路径、性能调查的五层工具链、配置陷阱与恢复边界。面向想读懂 PG 内核源码、在生产环境排查过问题、准备给 PG 贡献代码的工程师。
【PG 内核】进程模型与共享内存:Postmaster 如何管理 100 个 Backend
拆解 PostgreSQL 多进程架构的核心:Postmaster 的启动与信号处理、Backend 进程的 fork()→InitPostgres→主循环生命周期、CreateSharedMemoryAndSemaphores() 的共享内存初始化流程、PGPROC/ProcArray/PGXACT 等关键共享内存结构的内存布局,以及 Background Worker 的注册与调度。理解了这个地基,才能理解 PG 为什么用进程而不是线程,以及 max_connections 为什么不能随便调大。
【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 可见性判断的共同前提。