扒开Database的底裤! 居然是16KB 的小方块!

1 阅读9分钟

最近在学习 CMU 15-445《数据库系统导论》,这门课是卡内基梅隆大学的经典数据库课程,Andy Pavlo 讲得相当硬核。

学习过程中发现一个问题——网上几乎找不到对应这门课的中文讨论。

于是我决定:边学边写,把自己的理解记录下来。 不保证完全正确,但保证是我真正消化过的东西。如果你也在学这门课,或者只是对数据库底层感兴趣,希望这些文章能帮到你。

如果有错误,欢迎在评论区锤我 🔨

数据库的基本单位:一切从 Page 开始


一、为什么需要数据库?

1.1 时代背景:数据爆炸的前夜

在关系型数据库诞生之前(1970 年代以前),企业存储数据的方式极为原始。数据被写进一个个文本文件、CSV 表格,甚至是纸质账本。随着业务规模扩大,问题接踵而至。

1.2 没有数据库:查数据是一场噩梦

想象一个场景:你是一家连锁超市的 IT 员工,老板问你:

"上个月华东地区,所有门店里,牛奶的销量是多少?"

如果没有数据库,你会怎么做?

步骤你要做的事痛点
1找到华东所有门店的销售文件文件散落在各个服务器目录里
2打开每一个文件,手动筛选"牛奶"关键字文件格式不统一,有的是 .txt,有的是 .csv
3把每家门店的数量加在一起字段名不一样,有叫 count,有叫 数量,有叫 qty
4汇总结果你可能已经花了半天时间

这就是没有数据库时,查数据的真实痛苦

  • 数据孤岛:数据散落各处,没有统一入口
  • 格式不一致:每个文件/系统有自己的规则
  • 并发灾难:两个人同时修改同一个文件,数据直接损坏
  • 查询效率极低:没有索引,每次都是全量扫描
  • 没有事务保障:程序崩了,数据写了一半,怎么办?

storage_evolution_timeline.svg

1.3 数据库的诞生:给数据一个家

1970 年,Edgar F. Codd 在 IBM 发表论文 "A Relational Model of Data for Large Shared Data Banks" ,提出关系模型,现代数据库的基础由此奠定。

数据库解决的核心问题可以总结为四个字:统一管理

  • 统一的数据格式和接口(SQL)
  • 统一的并发控制(锁、MVCC)
  • 统一的持久化保证(WAL、Checkpoint)
  • 统一的查询优化(索引、执行计划)

二、既然要存数据,我们该怎么设计?

2.1 从需求出发:数据最终要落到磁盘

不管上层的 SQL 有多优雅,数据最终都需要持久化到磁盘上。这里有一个关键的物理约束:

磁盘 I/O 是数据库性能的最大瓶颈。

磁盘读取数据的方式和内存完全不同:

  • 内存:随机访问,寻址时间接近 0,可以精确到字节
  • 磁盘(HDD) :机械寻道,读取一个字节和读取 4KB 的时间几乎相同
  • 磁盘(SSD) :虽然没有机械臂,但底层 NAND Flash 也是以"页(Flash Page)"为最小读写单位

hdd_ssd_ram_comparison.svg

2.2 核心设计推导:为什么需要 Page?

既然每次 I/O 都有固定开销,那最合理的设计就是:

每次 I/O 尽量多搬一点数据,把开销摊薄。

这个"每次搬运的固定大小的数据块",就是 Page(页)

磁盘 I/O 的本质:
  不是读 1 行数据 → 而是读 1 个 Page(通常 4KB 或 16KB)
  不是写 1 行数据 → 而是写 1 个 Page

Page 的大小通常是操作系统页大小的整数倍:

数据库默认 Page 大小
MySQL InnoDB16 KB
PostgreSQL8 KB
SQLite4 KB(可配置)
Oracle8 KB(可配置至 32 KB)
SQL Server8 KB

page_transfer_unit.svg


三、深入 Page:结构与作用

3.1 Page 的整体结构

一个 Page 在物理上是一块连续的字节序列,逻辑上分为几个固定区域。以 MySQL InnoDB 的 16KB Page 为例:

image.png

3.2 各部分详解

File Header(文件头)

这是 Page 的"身份证",记录了 Page 在整个文件中的位置信息和类型:

字段大小含义
FIL_PAGE_SPACE_OR_CHKSUM4 字节校验和(新版)或 Space ID
FIL_PAGE_OFFSET4 字节当前 Page 的页号(从 0 开始)
FIL_PAGE_PREV4 字节上一个 Page 的页号(双向链表)
FIL_PAGE_NEXT4 字节下一个 Page 的页号(双向链表)
FIL_PAGE_LSN8 字节最后修改该 Page 的 LSN(日志序列号)
FIL_PAGE_TYPE2 字节Page 的类型(数据页、索引页、Undo 页等)
Page Header(页头)

记录当前 Page 的状态信息,核心字段:

  • PAGE_N_DIR_SLOTS:Page Directory 中槽的数量
  • PAGE_HEAP_TOP:Free Space 的起始偏移量(即下一条记录应该插入的位置)
  • PAGE_N_RECS:当前 Page 中的记录数量
  • PAGE_FREE:已删除记录组成的链表头(用于空间复用)
User Records(用户记录区)

这是真正存放数据行的地方。每条记录并非随意排列,而是通过单向链表按主键顺序串联:

Infimum → Record_1 → Record_2 → Record_3 → Supremum
  (最小)                                      (最大)

每条记录头部包含:

  • delete_mask:是否被标记删除(逻辑删除)
  • heap_no:在堆中的位置编号
  • next_record:下一条记录的相对偏移量
Page Directory(页目录)

Page Directory 是 Page 内部的二级索引,用于加速页内查找。

如果没有 Page Directory,在一个 16KB 的页里查找一条记录,需要从 Infimum 顺序遍历链表,最坏情况是 O(n)。

Page Directory 把记录分组,每组的最大记录放入一个"槽(Slot)",槽存储的是该记录在 Page 内的偏移量。查找时先二分槽,再在槽对应的小组内顺序查找,复杂度降为 O(log n + 小常数)

Page Directory(从页尾往上):
  Slot[0] → Supremum 的偏移
  Slot[1] → 第 8 条记录的偏移
  Slot[2] → 第 4 条记录的偏移
  Slot[3] → Infimum 的偏移

3.3 Page 的类型

Page 不只用来存数据行,不同类型的 Page 承担不同职责:

Page 类型常量用途
FIL_PAGE_INDEX0x45BFB+ Tree 的节点(数据页或索引页)
FIL_PAGE_UNDO_LOG0x0002Undo Log,用于事务回滚和 MVCC
FIL_PAGE_INODE0x0003段(Segment)信息
FIL_PAGE_IBUF_FREE_LIST0x0004Insert Buffer 的空闲列表
FIL_PAGE_TYPE_SYS0x000B系统页
FIL_PAGE_TYPE_FSP_HDR0x0008表空间头页
FIL_PAGE_TYPE_XDES0x0009区(Extent)描述页

3.4 Page 的作用:它在整个数据库中的位置

Page 不是孤立存在的,它处于数据库存储体系的中间层,连接着上层的逻辑结构和下层的物理存储。

逻辑层(用户视角)
     Table(表)
        ↓
     Index(索引 / B+ Tree)
        ↓
     Segment(段:叶子段 + 非叶子段)
        ↓
     Extent(区:64 个连续 Page = 1MB)
        ↓
  ►  Page(页:16KB)  ◄  ← 我们今天的主角
        ↓
     Row(行:Page 内的记录)
        ↓
物理层(磁盘视角)
     .ibd 文件(表空间文件)

1:Buffer Pool 如何使用 Page

Buffer Pool 是数据库的内存缓存层,所有对 Page 的读写都先经过它。

工作流程:

① SQL 执行 → 需要读取某行数据
② 计算该行所在的 Page 编号
③ 查询 Buffer Pool 的哈希表:Page 是否已在内存?
    ├── 命中(Cache Hit):直接返回内存中的 Page,无 I/O
    └── 未命中(Cache Miss):从磁盘读取该 Page 载入 Buffer Pool,再返回
④ 修改 Page → Page 变为 Dirty Page(脏页)
⑤ Checkpoint 或 LRU 淘汰时 → 将 Dirty Page 刷回磁盘

关键数据结构:

  • Free List:空闲 Page 链表,可以直接分配的内存块
  • LRU List:按访问时间排序,淘汰最久未使用的 Page
  • Flush List:所有脏页的链表,Checkpoint 时按 LSN 顺序刷盘

2:B+ Tree 如何组织 Page

InnoDB 的每张表(聚簇索引)和每个二级索引,都是一棵 B+ Tree。B+ Tree 的每个节点就是一个 Page

                  [根节点 Page]
                 /             \
       [内节点 Page]         [内节点 Page]
       /         \             /         \
[叶子 Page] [叶子 Page] [叶子 Page] [叶子 Page]
    ↔           ↔           ↔           ↔
(叶子层的 Page 通过 File Header 中的 PREV/NEXT 形成双向链表)
  • 非叶子节点(内节点) :存储索引键 + 子节点的 Page 编号
  • 叶子节点:存储完整的行数据(聚簇索引)或主键值(二级索引)

3:WAL(Write-Ahead Log)与 Page 的关系

为了保证崩溃后数据不丢失(Durability),InnoDB 使用 WAL(Write-Ahead Logging) 机制:

核心原则:修改 Page 之前,必须先把修改记录写入 Redo Log。

① 事务修改一行数据
② 先写 Redo Log(顺序写,极快)
③ 再修改 Buffer Pool 中的 Page(内存操作)
④ 返回事务提交成功
⑤ 异步将 Dirty Page 刷回磁盘

崩溃恢复时:
  读取 Redo Log → 重放所有已提交但未刷盘的 Page 修改 → 恢复完毕

Page Header 中的 LSN(Log Sequence Number) 字段,记录了该 Page 最后一次被修改时对应的 Redo Log 位置,这是崩溃恢复的核心依据。


四、总结

从一个简单的"查数据很痛苦"的问题出发,我们一路推导到了数据库最核心的存储单位——Page

痛点 → 数据库 → 磁盘 I/O 瓶颈 → Page 设计 → Page 内部结构 → 与上下游的协作
组件与 Page 的关系
Buffer PoolPage 的内存缓存,所有读写必经之路
B+ Tree用 Page 作为节点,构建索引结构
Redo Log / WAL通过 LSN 追踪 Page 的修改,保障崩溃恢复
Extent / SegmentPage 的上级管理单位,负责磁盘空间分配
Undo Log特殊类型的 Page,支持事务回滚和 MVCC

理解 Page,就是理解数据库存储引擎的基础。后续无论是深入研究索引、事务、锁,还是调优 innodb_buffer_pool_size,都会不断回到这个最小单位。


📖 参考资料

CMU(卡梅隆大学)-15445《数据库系统导论》