Skip to content

PostgreSQL 缓冲区管理器

为什么需要缓冲区管理器

应用执行: SELECT * FROM users WHERE id = 1;

问题:每次查询都读磁盘?
回答:不行,太慢了

解决:把热点数据放在内存里

PostgreSQL 使用 共享缓冲区(shared_buffers) 来缓存磁盘上的数据页。

缓冲区管理器架构

┌─────────────────────────────────────────────────┐
│                 PostgreSQL                      │
│  ┌─────────────────────────────────────────┐    │
│  │           共享缓冲区                       │    │
│  │  ┌─────┬─────┬─────┬─────┬─────┬─────┐  │    │
│  │  │ 0   │ 1   │ 2   │ ... │n-1  │ n   │  │    │
│  │  │8KB  │8KB  │8KB  │     │8KB  │8KB  │  │    │
│  │  └──┬──┴──┬──┴──┬──┴──┬──┴──┬──┴──┬──┘  │    │
│  │     │     │     │     │     │     │      │    │
│  │     ↓     ↓     ↓     ↓     ↓     ↓      │    │
│  │  ┌──────────────────────────────────┐    │    │
│  │  │       缓冲区描述符数组             │    │    │
│  │  │  (tag, pin_count, is dirty, etc)  │    │    │
│  │  └──────────────────────────────────┘    │    │
│  └─────────────────────────────────────────┘    │
│                        ↓                        │
│  ┌─────────────────────────────────────────┐    │
│  │           操作系统缓存                   │    │
│  └─────────────────────────────────────────┘    │
│                        ↓                        │
│                    磁盘                          │
└─────────────────────────────────────────────────┘

核心概念

数据页(Page/Block)

  • 每个页大小:8KB(不可配置)
  • 所有数据(表、索引)都按页存储
  • 读磁盘的最小单位

缓冲区描述符

每个缓冲区有一个描述符,包含:

tag:        -- 数据页的标识 (relfilenode, forkNum, blockNum)
state:      -- 状态 (pinned, dirty, etc)
usage_count: -- 使用频率(用于淘汰)
refcount:   -- 正在访问的进程数

页面替换策略

使用 时钟扫描算法(Clock-Sweep)

sql
-- 等效于 LRU,但更高效
-- 每个缓冲区有一个 usage_count
-- 每被访问一次 count++
-- 需要淘汰时,扫描一圈,count-- 
-- count 归零的可以被淘汰

工作流程

读取页面

sql
SELECT * FROM users WHERE id = 1;

-- 1. 计算页面的 tag(哪个表,哪个块)
-- 2. 在缓冲区中查找 tag
--    找到 → 命中 (hit)
--    未找到 → 需要加载 (miss)
-- 3. 如果 miss:
--    a. 找一个空闲/可淘汰的缓冲区
--    b. 如果淘汰的缓冲区是脏的,先写盘
--    c. 从磁盘读取目标页到缓冲区
--    d. 返回数据

写入页面

sql
UPDATE users SET name = '新名字' WHERE id = 1;

-- 1. 找到对应的缓冲区
-- 2. 在缓冲区中直接修改(写的是内存)
-- 3. 标记页面为 dirty(需要写盘)
-- 4. 后续由 CHECKPOINT 或后台进程刷盘

脏页刷新

sql
-- 检查点触发全量刷新
CHECKPOINT;

-- 或者 bgwriter 后台慢慢刷
-- 配置项:
show bgwriter_delay;          -- 刷新间隔 (200ms)
show bgwriter_lru_maxpages;   -- 每次最多刷多少页
show bgwriter_lru_multiplier; -- 预估下次需要的缓冲区的倍数

shared_buffers 配置

sql
-- 查看当前配置
SHOW shared_buffers;

-- 设置(需要重启)
-- postgresql.conf:
-- shared_buffers = 4GB  (建议:系统内存的 25%)

配置建议

系统内存shared_buffers说明
1GB256MB最小建议
2GB512MB
4GB1GB
8GB2GB
16GB4GB
32GB8GB
64GB16GB
128GB32GB最大建议

注意:不是越大越好,超过 40% 可能适得其反。

与操作系统缓存的关系

PostgreSQL 缓冲区 ←→ OS 缓存
     ↓                    ↓
   直接 IO?           间接 IO
     ↓                    ↓
   磁盘                磁盘

两种 IO 模式

直接 IO(Direct IO)

  • PostgreSQL 直接读写磁盘
  • 绕过 OS 缓存
  • 需要自己管理缓存
  • Linux 支持,PostgreSQL 不常用

间接 IO(Buffered IO)

  • PostgreSQL 通过 OS 缓存读写
  • 默认模式
  • OS 负责缓存管理

典型读取过程

sql
-- 查询
SELECT * FROM users WHERE id = 1;

-- 第一次读取:
-- shared_buffers miss → OS cache miss → 磁盘读取 → 放入 OS cache 和 shared_buffers

-- 第二次读取:
-- shared_buffers hit → 直接返回(快!)

缓存命中率

sql
-- 查看表的缓存命中率
SELECT 
    relname,
    heap_blks_read,     -- 从磁盘/ OS 读取
    heap_blks_hit,      -- 从 shared_buffers 命中
    round(100 * heap_blks_hit / 
        nullif(heap_blks_hit + heap_blks_read, 0), 2) as hit_ratio
FROM pg_statio_user_tables
WHERE schemaname = 'public'
ORDER BY heap_blks_read DESC;

命中率参考

  • 95%+:很好
  • 80-95%:一般,需要优化
  • <80%:糟糕,需要关注

索引缓存命中率

sql
SELECT 
    relname,
    indexrelname,
    idx_blks_read,
    idx_blks_hit,
    round(100 * idx_blks_hit / 
        nullif(idx_blks_hit + idx_blks_read, 0), 2) as hit_ratio
FROM pg_statio_user_indexes
WHERE schemaname = 'public'
ORDER BY idx_blks_read DESC;

常见问题

缓存太小

sql
-- 症状:频繁的磁盘 IO
-- 解决:增加 shared_buffers 或减少数据量

-- 查看 IO 情况
SELECT * FROM pg_stat_activity 
WHERE state = 'active' 
  AND wait_event_type = 'BufferPin';

脏页过多

sql
-- 症状:写入变慢,CHECKPOINT 耗时过长
-- 解决:调整 checkpoint 参数

SHOW checkpoint_timeout;          -- 5min
SHOW checkpoint_completion_target; -- 0.9 (分摊刷新,0.1 的时间完成)

-- checkpoint 不是瞬间完成,而是渐进式

内存碎片

sql
-- 症状:shared_buffers 很多但效率不高
-- 解决:VACUUM 清理碎片,定期 REINDEX

内部结构(进阶)

缓冲区表(Buffer Table)

c
// 内部实现(简化)
typedef struct BufferDesc {
    BufferTag   tag;        // 唯一标识
    uint32      usage_count; // 使用频率
    unsigned    shared:1;    // 共享标记
    unsigned    dirty:1;     // 脏页标记
    unsigned    pinned:1;    // 固定标记
    LocalBufferPriority occ:16; // 用于轻量锁
} BufferDesc;

BufferTag 结构

c
typedef struct BufferTag {
    Oid         relfilenode;   // 表/索引文件
    ForkNumber  forkNum;       // 哪个 fork(main, fsm, vm)
    BlockNumber blockNum;      // 第几个块
} BufferTag;

IO 操作类型

sql
-- 普通读取
-- 1. 尝试从 shared_buffers 读
-- 2. miss 时从 OS cache 读
-- 3. OS cache miss 才读磁盘

-- 同步读取(VISIBILITYMAP 之类)
-- 可能直接读磁盘绕过 shared_buffers

-- 批量读取(DECLARE CURSOR FORWARD)
-- 预读机制,读取相邻页面

监控与调优

关键监控指标

sql
-- 1. 缓冲区使用统计
SELECT * FROM pg_buffer_cache();

-- 2. 脏页比例
SELECT 
    (SELECT COUNT(*) FROM pg_buffer_cache WHERE isdirty) as dirty_pages,
    (SELECT COUNT(*) FROM pg_buffer_cache) as total_pages;

-- 3. 等待缓冲区的事件
SELECT * FROM pg_stat_activity 
WHERE wait_event = 'BufferPin';

调优参数汇总

sql
-- postgresql.conf

shared_buffers = 4GB          -- 建议 RAM 的 25%
effective_cache_size = 12GB   -- 建议 RAM 的 75%
work_mem = 64MB               -- 每个排序/哈希操作可用内存
maintenance_work_mem = 512MB  -- VACUUM, CREATE INDEX 等操作

-- 刷脏页策略
checkpoint_timeout = 10min
checkpoint_completion_target = 0.9
bgwriter_delay = 200ms
bgwriter_lru_maxpages = 100
bgwriter_lru_multiplier = 2.0

设计思想

1. 局部性原理
   - 刚访问的数据很可能再次访问
   - 访问过的数据附近的数据很可能被访问
   
2. 时间局部性
   - 热点数据常驻内存
   
3. 空间局部性
   - 预读相邻页面
   - 利用顺序 IO

4. 写合并
   - 多次小写变成一次大写
   - 通过 WAL 保证持久性
   - 脏页由后台慢慢刷盘

与其他数据库对比

特性PostgreSQLMySQL (InnoDB)Oracle
缓冲单位8KB 页16KB 页可配置
缓冲算法Clock-SweepLRU + 改进LRU
日志WALredo logredo log
脏页刷新bgwriter/checkpointmaster threadDBWn
内存管理固定大小动态SGA/PGA

基于 MIT 许可发布