mobile wallpaper 1mobile wallpaper 2mobile wallpaper 3mobile wallpaper 4
1238 字
3 分钟
缓冲池
2025-08-07

一次磁盘随机读 10ms,一次内存读 100ns——5 个数量级的差距。数据库 90% 以上的读取命中缓冲池(Buffer Pool),真正走到磁盘的请求不到 10%。可以说,缓冲池就是数据库的”真实磁盘”——调优缓冲池,比换一块更快的 SSD 见效更快。

但缓冲池远不只是一块缓存。LRU 在什么场景下会失效?脏页刷盘如何避免拖垮前台请求?双写缓冲解决什么问题?这一章拆解缓冲池的设计——内存如何弥合磁盘与 CPU 的性能鸿沟。

一、缓冲池概述#

1.1 为什么需要缓冲池#

graph TB subgraph 没有缓冲池 REQ1["读取请求"] --> DISK1["磁盘 I/O<br/>10ms/次"] end subgraph 有缓冲池 REQ2["读取请求"] --> BP["Buffer Pool<br/>命中: 0.1ms<br/>未命中: 10ms"] BP -->|"未命中"| DISK2["磁盘 I/O"] end style BP fill:#c8e6c9,stroke:#2e7d32 style DISK1 fill:#ffcdd2,stroke:#c62828 style DISK2 fill:#ffe0b2,stroke:#e65100
场景无缓冲池有缓冲池(99% 命中率)
1000 次读取10 秒0.11 秒
100 万次读取10,000 秒1.1 秒
Important

缓冲池是数据库性能最重要的单一因素。InnoDB 的 innodb_buffer_pool_size 通常建议设为物理内存的 50–80%。缓冲池命中率低于 99% 通常意味着需要增加内存或优化查询。

1.2 缓冲池架构#

graph TB subgraph BufferPool["InnoDB Buffer Pool 架构"] subgraph Instance0["Instance 0"] LRU0["LRU List<br/>Old + Young 子列表"] FLUSH0["Flush List<br/>脏页列表"] FREE0["Free List<br/>空闲页列表"] end subgraph Instance1["Instance 1"] LRU1["LRU List"] FLUSH1["Flush List"] FREE1["Free List"] end HASH["Page Hash<br/>快速查找页面"] HASH --> Instance0 HASH --> Instance1 end style HASH fill:#e3f2fd,stroke:#1565c0 style LRU0 fill:#c8e6c9,stroke:#2e7d32 style FLUSH0 fill:#fff9c4,stroke:#f9a825

1.3 缓冲池的核心数据结构#

// Buffer Pool 核心数据结构
struct BufferPool {
// 页面哈希表:page_id → buffer_block
hash_table_t *page_hash;
// LRU 列表(改进型)
struct {
ut_list_base(buf_page_t) young; // 热数据(前 5/8)
ut_list_base(buf_page_t) old; // 冷数据(后 3/8)
} lru;
// 脏页列表
ut_list_base(buf_page_t) flush_list;
// 空闲页列表
ut_list_base(buf_page_t) free_list;
// 统计信息
uint64_t stat_n_page_reads; // 读取次数
uint64_t stat_n_page_read_hits; // 命中次数
};
// 缓冲块
struct buf_block_t {
page_id_t page_id; // 页面 ID
byte *frame; // 数据帧(16KB)
lsn_t newest_lsn; // 最新修改 LSN
bool is_dirty; // 是否脏页
buf_page_state_t state; // 状态
rw_lock_t lock; // 读写锁
};

二、LRU 替换策略#

2.1 经典 LRU 的问题#

经典 LRU(Least Recently Used)在数据库场景中有两个严重问题:

问题说明后果
全表扫描污染一次全表扫描会将所有热点数据挤出缓冲池扫描后缓存命中率骤降
预读污染预读的页面可能不会被访问浪费缓存空间

2.2 InnoDB 改良 LRU#

InnoDB 将 LRU 列表分为两个子列表:

graph LR subgraph InnoDB_LRU["InnoDB 改良 LRU"] direction LR YOUNG["Young 子列表(热数据)<br/>前 5/8<br/>最近被访问的页面"] OLD["Old 子列表(冷数据)<br/>后 3/8<br/>新读入的页面先放这里"] end NEW_PAGE["新读入的页面"] -->|"插入 Old 头部"| OLD OLD -->|"再次被访问<br/>且超过停留时间"| YOUNG YOUNG -->|"淘汰"| EVICT["淘汰页面"] style YOUNG fill:#c8e6c9,stroke:#2e7d32 style OLD fill:#fff9c4,stroke:#f9a825 style EVICT fill:#ffcdd2,stroke:#c62828
// InnoDB LRU 操作
void buf_page_accessed(buf_block_t *block) {
if (block->in_old_list) {
// 页面在 Old 子列表
// 检查是否在 Old 列表停留足够长时间
if (time_since_first_access(block) > innodb_old_blocks_time) {
// 停留时间足够长,移到 Young 子列表头部
move_to_young_head(block);
block->in_old_list = false;
}
// 否则留在 Old 子列表(防止全表扫描污染)
} else {
// 页面在 Young 子列表
// 移到 Young 子列表头部
move_to_young_head(block);
}
}

2.3 LRU 变体对比#

变体核心思想抗扫描污染实现复杂度使用场景
LRU最近访问的保留简单缓存
LRU-2记录最近 2 次访问时间通用
2Q两个队列:A1(最近)+ Am(频繁)数据库
LIRS区分热数据和冷数据最好学术
InnoDB LRUOld/Young 分区 + 停留时间InnoDB
Clock近似 LRU,环形扫描一般操作系统

2.4 2Q 算法详解#

# 2Q(Two Queues)算法
class TwoQ:
def __init__(self, capacity):
self.capacity = capacity
self.a1_in = [] # 最近访问队列(FIFO)
self.a1_out = [] # 最近驱逐队列(只存 Key)
self.am = OrderedDict() # 频繁访问队列(LRU)
self.a1_in_max = int(capacity * 0.25) # A1in 占 25%
self.am_max = int(capacity * 0.75) # Am 占 75%
def get(self, key):
if key in self.am:
# 命中 Am:移到头部
self.am.move_to_end(key)
return self.am[key]
if key in self.a1_out:
# 命中 A1out:提升到 Am(第二次访问)
self.a1_out.remove(key)
self.am[key] = load_from_disk(key)
self.am.move_to_end(key)
self._evict_if_needed()
return self.am[key]
# 未命中:从磁盘读取,放入 A1in
self.a1_in.append(key)
self._evict_if_needed()
return load_from_disk(key)
def _evict_if_needed(self):
# A1in 满了:移到 A1out
if len(self.a1_in) > self.a1_in_max:
old_key = self.a1_in.pop(0)
self.a1_out.append(old_key)
# A1out 满了:丢弃最旧的
while len(self.a1_out) > self.a1_in_max:
self.a1_out.pop(0)
# Am 满了:LRU 淘汰
while len(self.am) > self.am_max:
self.am.popitem(last=False)

三、脏页刷盘#

3.1 脏页的产生与刷盘#

graph TB subgraph 脏页生命周期["脏页生命周期"] READ["读取页面<br/>从磁盘到 Buffer Pool"] --> MODIFY["修改页面<br/>标记为脏页"] MODIFY --> FLUSH_LIST["加入 Flush List"] FLUSH_LIST --> BG_FLUSH["后台刷盘<br/>Page Cleaner 线程"] BG_FLUSH --> DISK["写入磁盘<br/>Doublewrite → 数据文件"] DISK --> CLEAN["标记为干净页"] end style MODIFY fill:#fff9c4,stroke:#f9a825 style BG_FLUSH fill:#e3f2fd,stroke:#1565c0 style DISK fill:#c8e6c9,stroke:#2e7d32

3.2 刷盘策略#

策略触发条件刷盘量影响
自适应刷盘脏页比例超过阈值动态调整平衡 I/O
紧急刷盘脏页比例超过 75%大量可能阻塞写入
检查点刷盘检查点推进指定 LSN 之前的脏页推进检查点
LRU 刷盘需要空闲页LRU 尾部的脏页为新页面腾空间
# InnoDB 自适应刷盘算法
def adaptive_flushing(dirty_page_ratio, redo_generation_rate,
io_capacity):
"""自适应刷盘速率"""
# 基于脏页比例
flush_rate_by_ratio = dirty_page_ratio * io_capacity * 2
# 基于 Redo Log 生成速率
# 如果 Redo 生成很快,需要加速刷盘以推进检查点
flush_rate_by_lsn = redo_generation_rate * 1.1
# 取较大值
flush_rate = max(flush_rate_by_ratio, flush_rate_by_lsn)
# 不超过最大 I/O 容量
flush_rate = min(flush_rate, io_capacity * 2)
return flush_rate

3.3 刷盘与 Doublewrite#

// InnoDB Doublewrite 流程
void buf_flush_page(buf_block_t *block) {
// 1. 写入 Doublewrite Buffer
// Doublewrite 是共享表空间中 2MB 的连续区域
// 可以容纳 128 个页面(128 × 16KB = 2MB)
doublewrite_write(block);
fdatasync(doublewrite_fd); // 确保 Doublewrite 先落盘
// 2. 写入数据文件
pwrite(block->file_fd, block->frame, PAGE_SIZE, block->offset);
fdatasync(block->file_fd);
// 3. 标记为干净页
block->is_dirty = false;
// 崩溃恢复时:
// 如果数据文件中的页面损坏(torn page)
// 从 Doublewrite Buffer 恢复完整页面
}

四、预读机制#

4.1 预读类型#

类型触发条件预读范围效果
随机预读同一 Extent 中连续 13+ 个页面被访问整个 Extent(1MB)适合索引扫描
线性预读顺序访问同一 Extent 的页面下一个 Extent适合全表扫描
-- InnoDB 预读配置
-- 线性预读阈值
SET GLOBAL innodb_read_ahead_threshold = 56;
-- 一个 Extent(64 页)中有 56 页被顺序访问时触发预读
-- 随机预读(通常关闭)
SET GLOBAL innodb_random_read_ahead = 0;
Note

在 OLTP 场景中,预读经常被关闭,因为随机访问模式不适合预读。在 OLAP 场景中,预读可以显著提升全表扫描性能。详见 Ch8 读路径

五、缓冲池并发控制#

5.1 Buffer Pool Instance 分片#

InnoDB 将 Buffer Pool 分为多个 Instance,减少锁竞争:

// Buffer Pool Instance 分片
// page_id % innodb_buffer_pool_instances 决定使用哪个 Instance
int get_buffer_pool_instance(page_id_t page_id) {
return page_id % innodb_buffer_pool_instances;
}
// 每个 Instance 有独立的:
// - LRU List
// - Flush List
// - Free List
// - Page Hash
// - 互斥锁
// 推荐配置:
// Instance 数量 = CPU 核心数 / 2(最多 64)

5.2 页面锁#

// Buffer Pool 页面锁
struct buf_block_t {
// 读写锁:保护页面内容
rw_lock_t lock; // S 锁(读)/ X 锁(写)
// 互斥锁:保护页面元数据
mutex_t mutex; // 保护 state, is_dirty 等
};
// 获取页面的流程
buf_block_t *buf_page_get(page_id_t page_id, rw_lock_mode_t mode) {
// 1. 在 Page Hash 中查找
buf_block_t *block = hash_search(page_id);
if (block == NULL) {
// 2. 未命中:从磁盘读取
block = buf_read_page(page_id);
}
// 3. 固定页面(防止被淘汰)
block->fix_count++;
// 4. 获取页面锁
if (mode == RW_S_LOCK) {
rw_lock_s_lock(&block->lock);
} else {
rw_lock_x_lock(&block->lock);
}
return block;
}

六、缓冲池调优#

6.1 关键参数#

参数推荐值说明
innodb_buffer_pool_size物理内存 50–80%最重要的参数
innodb_buffer_pool_instancesCPU 核心数 / 2减少锁竞争
innodb_old_blocks_pct37(默认)Old 子列表比例
innodb_old_blocks_time1000ms(默认)页面在 Old 列表的停留时间
innodb_max_dirty_pages_pct75(默认)触发加速刷盘的脏页比例
innodb_io_capacitySSD: 2000每秒 I/O 操作数
innodb_io_capacity_maxSSD: 4000最大 I/O 容量

6.2 命中率监控#

-- Buffer Pool 命中率
SELECT
(1 - Innodb_buffer_pool_reads / Innodb_buffer_pool_read_requests) * 100
AS hit_ratio
FROM (
SELECT variable_value + 0 AS Innodb_buffer_pool_reads
FROM performance_schema.global_status
WHERE variable_name = 'Innodb_buffer_pool_reads'
) r,
(
SELECT variable_value + 0 AS Innodb_buffer_pool_read_requests
FROM performance_schema.global_status
WHERE variable_name = 'Innodb_buffer_pool_read_requests'
) rr;
-- 目标:命中率 > 99.5%
-- 如果低于 99%,考虑增加 Buffer Pool 大小
-- Buffer Pool 使用情况
SELECT
page_type,
SUM(data_size) / 1024 / 1024 AS size_mb,
COUNT(*) AS page_count
FROM performance_schema.innodb_buffer_page
GROUP BY page_type
ORDER BY size_mb DESC;

6.3 PostgreSQL 缓冲区调优#

-- PostgreSQL shared_buffers
-- 推荐:物理内存的 25%
-- shared_buffers = '16GB'
-- 查看缓冲区统计
SELECT
sum(heap_blks_hit) AS hits,
sum(heap_blks_read) AS reads,
sum(heap_blks_hit) / NULLIF(sum(heap_blks_hit + heap_blks_read), 0)
AS hit_ratio
FROM pg_statio_user_tables;
-- 目标:命中率 > 99%

七、实战:缓冲池观察#

7.1 Buffer Pool 状态#

-- InnoDB Buffer Pool 状态
SHOW ENGINE INNODB STATUS\G
-- 关键信息:
-- Buffer pool size: 262144 pages (4GB)
-- Free buffers: 1024 pages
-- Database pages: 261120 pages
-- Old database pages: 96614 pages
-- Modified db pages: 15234 pages
-- Buffer pool hit rate: 1000 / 1000
-- Young-making: 1234567
-- Not young-making: 876543

7.2 热点页面分析#

-- 查看最热的索引页面
SELECT
table_name,
index_name,
COUNT(*) AS page_count,
SUM(data_size) / 1024 / 1024 AS size_mb
FROM performance_schema.innodb_buffer_page
WHERE table_name IS NOT NULL
GROUP BY table_name, index_name
ORDER BY page_count DESC
LIMIT 20;

7.3 脏页刷盘监控#

-- 脏页统计
SHOW STATUS LIKE 'Innodb_buffer_pool_pages_dirty';
SHOW STATUS LIKE 'Innodb_buffer_pool_pages_total';
-- 刷盘统计
SHOW STATUS LIKE 'Innodb_data_writes';
SHOW STATUS LIKE 'Innodb_data_written';
SHOW STATUS LIKE 'Innodb_fsyncs';
-- 计算脏页比例
SELECT
variable_value + 0 AS dirty_pages,
(SELECT variable_value + 0
FROM performance_schema.global_status
WHERE variable_name = 'Innodb_buffer_pool_pages_total') AS total_pages,
(variable_value + 0) / (SELECT variable_value + 0
FROM performance_schema.global_status
WHERE variable_name = 'Innodb_buffer_pool_pages_total') * 100 AS dirty_pct
FROM performance_schema.global_status
WHERE variable_name = 'Innodb_buffer_pool_pages_dirty';
Warning

缓冲池大小不是越大越好。过大的缓冲池会导致页面淘汰策略失效——LRU 链表过长时,热点页面可能在被访问前就被淘汰。一般建议缓冲池占物理内存的 60-80%,留出足够空间给操作系统页缓存和连接缓冲。

八、总结#

主题核心要点关键词
缓冲池架构Page Hash + LRU + Flush List + Free List,多 Instance 减少锁竞争多 Instance, 锁竞争
LRU 变体InnoDB 改良 LRU 用 Old/Young 分区 + 停留时间防止全表扫描污染改良 LRU, Old/Young
脏页刷盘自适应刷盘平衡 I/O 负载,紧急刷盘防止脏页积压自适应刷盘, 脏页
Doublewrite防止页面写撕裂,增加约 10–15% 写入开销写撕裂, 双写
预读线性预读适合顺序扫描,OLTP 场景通常关闭线性预读, 场景适配
调优Buffer Pool 大小是最重要参数,命中率 > 99% 是目标命中率, 容量调优

支持与分享

如果这篇文章对你有帮助,欢迎支持作者或分享给更多人

缓冲池
https://blog.souloss.com/posts/storage/storage-buffer-pool/
作者
Souloss
发布于
2025-08-07
许可协议
CC BY-NC-SA 4.0

部分信息可能已经过时