3、存储管理基础:磁盘与内存层次结构、页(Page)与块(Block)概念、缓冲区管理(Buffer Pool)原理与实现
数据库内核的存储管理模块是连接上层查询引擎与底层物理介质的桥梁。无论你是要设计一个面向在线交易(OLTP)的MySQL InnoDB,还是面向分析型负载(OLAP)的ClickHouse,存储管理都决定了数据能否高效、可靠、并发地读写。本章将围绕三个核心问题展开:数据在磁盘与内存之间如何分层组织?页(Page)与块(Block)如何定义读写的最小单元?缓冲区管理(Buffer Pool)如何用有限的内存去缓存海量的磁盘数据,并保证事务的隔离性与持久性?理解这些基础,是后续学习索引组织、事务日志(WAL)、锁与MVCC的必备前提。
上图展示了数据库存储管理的基本链路。磁盘层以块(Block)为物理单位,内存层以页(Page)为逻辑单位,上层索引与事务引擎通过缓冲区管理器访问数据。下面我们逐一深入。
3.1 磁盘与内存层次结构:为什么数据库不能直接读文件?
数据库系统必须同时面对两种存储介质:持久化的磁盘(HDD/SSD)和易失的内存(DRAM)。磁盘容量大、成本低,但访问延迟以毫秒甚至十毫秒计;内存容量小、成本高,但访问延迟在纳秒级。两者相差约5个数量级。如果每次查询都直接发起磁盘I/O,数据库性能将不可接受。因此,存储管理的第一条原则是:尽量让数据在内存中完成读写,磁盘只作为持久化与容灾的最终载体。
从层次结构上看,数据库存储通常分为四层:
- 文件系统层:操作系统提供的文件抽象,数据库将表空间映射为一个或多个文件。
- 存储引擎层:将文件划分为固定大小的块(Block),并管理块与逻辑页的映射。
- 缓冲区管理层:在内存中维护页的缓存副本,负责读入、淘汰、刷盘。
- 访问方法层:B+树、Hash索引等结构,通过页号(Page ID)向缓冲区请求数据。
这种分层设计使得上层无需关心磁盘的具体布局,只需通过页号访问数据。同时,缓冲区管理可以统一调度I/O,合并多次小请求为一次大请求,提升吞吐量。
3.2 页(Page)与块(Block)概念:逻辑与物理的映射
页是数据库内部管理数据的最小逻辑单元,块是磁盘I/O的最小物理单元。两者大小通常一致(如InnoDB的页大小为16KB,块大小也对应16KB),但概念上必须区分:页是面向存储引擎的抽象,块是面向文件系统的抽象。一个页可能对应磁盘上的一个或多个连续块,也可能在文件系统中不连续(通过段(Segment)和区(Extent)管理)。
一个典型的页结构包含以下部分:
| 组成部分 | 作用 | 典型大小 |
|---|---|---|
| 页头(Page Header) | 记录页的元信息:页号、LSN(日志序列号)、校验和、页类型 | 约100~200字节 |
| 记录区(Record Area) | 存储实际的行数据,按Slot目录管理 | 页主体空间 |
| Slot目录(Slot Directory) | 记录每条记录的偏移量,支持快速定位 | 每条记录2字节 |
| 空闲空间(Free Space) | 页内未使用的区域,用于插入新记录 | 动态变化 |
| 页尾(Page Trailer) | 存储校验和,用于检测页损坏 | 8字节 |
页的设计需要权衡:页越大,单次I/O读取的数据越多,顺序扫描效率高,但缓冲区能缓存的页数减少,随机点查时浪费的I/O也更多;页越小,缓冲区能容纳更多页,但页头开销占比增大,且需要更多次I/O。InnoDB默认16KB,PostgreSQL默认8KB,SQL Server默认8KB,这是经过实践检验的折中值。
3.3 缓冲区管理(Buffer Pool)原理与实现
缓冲区管理是存储管理的心脏。它维护一个内存中的页缓存池(Buffer Pool),所有页的读写都必须经过它。其核心职责包括:页的查找、加载、淘汰、刷盘、并发控制。
3.3.1 核心数据结构
Buffer Pool通常由两部分组成:
- 页帧数组(Frame Array):一块连续的内存区域,划分为固定大小的帧(Frame),每个帧可以容纳一个页。
- 页表(Page Table):一个哈希表,键为页号(Page ID),值为帧的地址。通过页表可以快速判断某个页是否在内存中。
当上层请求页P时,流程如下:
- 在页表中查找P,若命中,直接返回帧地址,增加引用计数。
- 若未命中,从空闲帧列表或淘汰队列中获取一个帧。
- 发起磁盘I/O,将P读入该帧。
- 更新页表,返回帧地址。
3.3.2 淘汰策略:LRU与Clock算法
当Buffer Pool满时,需要淘汰一个页。最经典的算法是LRU(最近最少使用),但数据库场景有特殊问题:顺序扫描污染。例如,全表扫描会一次性读取大量页,这些页可能只被访问一次,却把频繁访问的热点页挤出缓冲区。因此,现代数据库通常使用改进的LRU变种:
- InnoDB的LRU链表分为新生代(Young)和老年代(Old),新读入的页放入老年代头部,只有被再次访问时才晋升到新生代。这样顺序扫描的页很快被淘汰,不会影响热点页。
- Clock算法(二次机会):每个页有一个引用位,被访问时置1;淘汰时若引用位为1则清0并跳过,为0则淘汰。实现简单,性能接近LRU。
3.3.3 脏页刷盘与Checkpoint
被修改的页称为脏页(Dirty Page)。脏页不能立即丢弃,必须写回磁盘以保证持久性。但频繁刷盘会带来大量随机I/O,因此数据库采用延迟刷盘策略:脏页在内存中保留一段时间,由后台线程批量刷盘。同时,通过Checkpoint机制定期将脏页刷盘,并记录日志序列号(LSN),以便崩溃恢复时知道从哪个点开始重放日志。
3.3.4 并发控制:Latch与Lock
Buffer Pool是共享资源,多个线程可能同时访问同一个页。数据库使用两种锁:
- Latch(闩锁):保护页在内存中的物理结构,如页头、Slot目录。持锁时间极短(微秒级),使用原子操作实现。
- Lock(锁):保护事务逻辑,如行级锁。持锁时间可能很长(毫秒到秒级),由锁管理器维护。
Latch的设计是缓冲区管理的关键难点。例如,在页分裂或合并时,需要同时持有多个页的Latch,必须避免死锁。InnoDB采用自旋锁加等待队列的组合,PostgreSQL使用轻量级锁(LWLock)。
3.4 案例拆解:一次点查背后的存储管理全流程
假设一个电商系统,用户表(user)有1000万行,每行约200字节,页大小16KB,每页可存约80行。现在执行一条SQL:SELECT * FROM user WHERE id = 5000000;。我们追踪存储管理的行为:
- 索引定位:B+树从根节点开始查找,需要访问3层索引页(假设高度为3)。
- 页请求:每层索引页都通过页号向Buffer Pool发起请求。假设Buffer Pool大小为8GB,当前命中率99%。
- 命中分析:根节点页常驻内存,命中;第二层页可能命中;第三层叶子页可能未命中,需要从磁盘读取。
- 磁盘I/O:未命中的叶子页触发一次随机读,耗时约5ms(SSD)。
- 页加载:读取16KB数据到帧中,更新页表,返回记录。
- 记录定位:在页内通过Slot目录找到id=5000000的记录,返回给上层。
整个过程,如果Buffer Pool命中,耗时约0.1ms;如果未命中,耗时约5ms。这就是为什么Buffer Pool命中率如此重要。
innodb_old_blocks_time参数(默认1000ms),让新读入的页在1秒内不被晋升到新生代。调整后命中率恢复至99.3%。
3.5 常见误区与总结复盘
初学者在理解存储管理时,常犯以下错误:
- 误区一:页和块是同一个概念。实际上,页是逻辑概念,块是物理概念。虽然大小通常一致,但页可以跨多个不连续的块(通过段管理)。
- 误区二:Buffer Pool越大越好。过大会导致刷盘延迟、恢复时间变长,且内存换页(Swap)反而降低性能。
- 误区三:LRU是万能的。数据库场景必须考虑顺序扫描污染,需要采用分代LRU或Clock变种。
- 误区四:脏页可以无限积累。脏页比例过高会触发强制刷盘,导致I/O风暴。
总结本章要点:存储管理的本质是用内存缓存换取磁盘I/O的减少。页是数据组织的基本单位,块是I/O的基本单位,Buffer Pool是两者的桥梁。理解页的结构、淘汰策略、刷盘机制和并发控制,是后续学习索引、事务和恢复的基础。在实际调优中,优先关注命中率、脏页比例和I/O延迟三个指标,再结合具体负载特征调整参数。
SHOW ENGINE INNODB STATUS(MySQL)或pg_buffercache(PostgreSQL)观察Buffer Pool的命中率、脏页数量和淘汰情况。遇到性能问题时,先排除Buffer Pool配置是否合理,再考虑SQL层面的优化。