数据库存储的基石:Page、Heap File 与数据在磁盘上的组织方式

26 阅读7分钟

数据库为什么不直接使用操作系统文件?

在数据库出现之前,人们习惯使用普通的文本或二进制文件来存储数据。例如一个简易的 users.txt:

1,张三,20
2,李四,25
3,王五,30

这种存储方式在数据量极小时简单有效,但当数据规模膨胀到百万、千万甚至几十亿条时,致命的问题便接踵而至:

  1. 随机定位困难:为了查找特定 ID 的记录,必须遍历整个文件(O(N) 复杂度)。
  2. 变长数据修改:如果要将“张三”修改为“张三丰”,后续所有数据在磁盘上都要向后平移,引发极高的 I/O 开销。
  3. 空洞与碎片化:删除某条记录后,留下的物理空间难以被后续新增的记录高效复用。
  4. 高并发与恢复:操作系统原生的文件读写无法直接提供事务所需的物理页校验、日志重放(Redo/Undo)与并发锁粒度控制。

因此,现代数据库存储层的核心设计哲学是:打破“文件字节流”概念,建立以“数据页(Page)”为核心的存储抽象。

 逻辑视图 [ SQL Query ]
             │
             ▼
 执行引擎 [ Query Executor ]
             │
             ▼
 索引结构 [ B+ Tree / LSM-Tree ]
             │
             ▼
 数据容器 [ Heap File / Table Space ]
             │
             ▼
 最小单元 [ Page / Block (8KB / 16KB) ]
             │
             ▼
 物理存储 [ Disk Block / NVMe SSD ]

一、核心抽象:为什么 Page(页)是数据库的共同选择?

在标准 C/C++ 程序眼中,文件是一串连续的字节流(Byte Stream)。但在数据库眼中,文件被严格切割为固定大小的内存/磁盘交互单元——Page(数据页) 。

各大主流数据库在 Page 大小的选择上各有侧重:

数据库引擎默认 Page 大小设计考量与典型应用场景
MySQL InnoDB16 KB针对高并发 OLTP 事务优化,平衡 B+ 树扇出与 I/O 吞吐
PostgreSQL8 KB适配传统 Unix 文件系统 Block 尺寸,减少写放大
SQLite4 KB嵌入式轻量设计,对齐 OS 页面(Page Size)以降低内存占用
SQL Server8 KB以 8 Pages(64KB)组成 Extent(区)进行批量分配

为什么不能按“字节”或“单条记录”读写?

底层物理硬件(HDD 的 Sector 或 SSD 的 Flash Page)的读写并不是以 Byte 为单位的,而是以 4KB 块 粒度进行物理对齐读写。

如果数据库每次检索一条 100 字节的记录都发起一次物理 I/O,会造成严重的 I/O 浪费。数据库通过在内存中缓存整页(Buffer Pool),将物理磁盘整页读入内存,然后在内存的高速 Buffer 中检索具体记录。Page 是磁盘与内存之间数据交换的最小单位。

二、拆解 Page:一个页面里究竟包含了什么?

以经典的 Slotted-Page(槽位页) 架构为例,一个标准的 Page 绝不仅仅是数据的堆砌,而是高度结构化的内存/磁盘映射。

Page架构图.png 一个完整的 Page 通常划分为以下四个区域,第五个特殊Page会有:

1. Page Header(页头)

存储当前页面的元数据,帮助存储引擎认识这个 Page:

  • page_id:当前页面的全局唯一标识。
  • checksum:防断电/磁盘损坏的校验和。
  • lsn(Log Sequence Number):用于 WAL 日志恢复与崩溃一致性检查。
  • free_offset:指示页内空闲区域起始/终止指针。

用 C++ 代码简单抽象其结构如下:

struct PageHeader {
    uint32_t page_id;      // 页面 ID
    uint64_t lsn;          // 日志序列号 (崩溃恢复)
    uint16_t checksum;     // 页数据校验码
    uint16_t slot_count;   // 记录槽位数量
    uint16_t free_start;   // 空闲区域起始偏移
    uint16_t free_end;     // 空闲区域结束偏移
};

2. Slot Array(槽位数组 / 记录指针)

从页头顺向增长,保存每个 Record 在该 Page 内部的相对偏移量(Offset)。

3. Free Space(空闲空间)

Slot Array 与 Tuple 之间的未分配空间,向中间双向挤压。

4. Records / Tuples(数据记录)

从页尾逆向增长,存储真正的物理行数据(包含 Null Bitmap、变长列 Offset 以及用户数据)。

5.Special Space(特殊空间)

主要用来存储特定页面类型专用的附加控制信息(特殊元数据),之所以被称为“Optional”,是因为普通的存储数据的页面(如 Heap Page)通常完全不需要它,只有在特殊页面(如B+树索引页)才会启用。

Slotted Page 的优势:

当删除或更新某条 Tuple 时,无需重排物理数据,只需调整 Slot Array 中的偏移量或作失效标记;变长字段更新时,也仅需在页内做碎片整理(Defragmentation),外部索引引用的 (Page_ID, Slot_Index) 保持不变!

三、Heap File:无序数据页的物理管理者

当一个页面存满后,数据库需要一种机制来组织成千上万个 Page,这就是 Heap File(堆文件) 。

注意:这里的“Heap(堆)”是指 无序(Unordered)的数据集合,与 C++ / Java 内存管理中的堆内存(Heap Memory)没有关系。

               Table: Users
                    │
            ┌───────┴───────┐
            ▼               ▼
      Primary Index     Heap File (无序物理数据文件)
     (B+ Tree / Hash)   ┌──────────────────────────┐
                        │ Page 0  [ID:5, ID:20]    │
                        ├──────────────────────────┤
                        │ Page 1  [ID:3, ID:100]   │
                        ├──────────────────────────┤
                        │ Page 2  [ID:1, ID:88]    │
                        └──────────────────────────┘

为什么数据库默认采用 Heap File?

答案在于极致的写入性能。

在 Heap File 中,插入数据非常简单:

  1. 寻找任意一个有空闲空间的 Page(通过 Free Space Map / Page Directory)。
  2. 将数据插入该 Page。
  3. 写入时间复杂度接近 O(1) 。

如果数据库在存储物理行时就强制要求按照 ID 顺序排列(类似数组插入):

插入一条 ID = 2.5 的数据,可能引发后续所有 Page 中几百万条数据的连锁迁移,写入开销将极其恐怖。

因此,大部分现代数据库采用 数据存储在 Heap File(负责物理落盘),物理定位由 Index(负责高效检索)维护 的分工模式。

四、协同机制:Index 与 Heap File 如何联动?

既然 Heap File 里的数据是无序的,那么 SELECT * FROM users WHERE id = 100 如何避免全表扫描(Full Table Scan)?

这正是索引(Index)发挥作用的时刻。索引存储的是 Key --> Row Identifier (RID) 的映射关系。

[用户发起 SQL 查询: WHERE id = 100]
                  │
                  ▼
         [ 检索 B+ Tree 索引 ]
                  │
                  ▼
      得到物理地址 Tuple ID (TID)
      (Page_ID: 5, Slot_ID: 3)
                  │
                  ▼
      [ 直接读取 Heap File Page 5 ]
                  │
                  ▼
         通过 Slot 3 定位数据并返回
  • 顺序扫描(Seq Scan) :如果没有索引,需要遍历 Heap File 里的每个 Page,时间复杂度为 O(N) 。
  • 索引扫描(Index Scan) :通过 B+ Tree 快速查找到 RID = (Page 5, Slot 3),直接按页定位,时间复杂度降至 O(log N) 。在十亿级数据规模下,检索次数从 10 亿次缩减至不到 30 次磁盘/内存寻址。

五、横向对比:三者架构在不同数据库中的实践

没有放之四海而皆准的架构,只有针对特定场景的取舍:

  PostgreSQL Paradigm                 InnoDB Clustered B+Tree Paradigm
 ┌──────────────────────┐            ┌────────────────────────────────┐
 │ Index (B+Tree)       │            │ Primary Key B+Tree             │
 │  └─> Tuple ID (TID)  │            │ ┌────────────────────────────┐ │
 └──────────┬───────────┘            │ │ Leaf Node: [Key | Row Data]│ │
            │                        │ └────────────────────────────┘ │
            ▼                        └────────────────────────────────┘
 ┌──────────────────────┐
 │ Heap File            │
 │  └─> Raw Tuples      │
 └──────────────────────┘
数据库引擎底层存储架构模型特点与优点潜在劣势
PostgreSQLHeap File + Secondary Indexes架构清晰,表和索引高度解耦;多索引场景下二级索引尺寸小。更新主键不需要重构完整数据,但二级索引查找需要物理回表(TID)。
MySQL InnoDBClustered Index (聚簇索引)主键 B+ 树的叶子节点直接存放完整行数据。主键点查极快,节省一次回表 I/O。插入变长主键易造成页分裂;非主键索引(二级索引)必须存放主键值。
SQLiteB-Tree Direct Storage整个表直接组织为一个 B-Tree 文件,精简且零配置。适合嵌入式与单机应用,高并发高吞吐写入受限。

六、总结

总结数据库存储引擎的最底层抽象层次:

  1. 操作系统视文件为 Byte Stream,但数据库视文件为 Page Array。
  2. Page 是数据库与磁盘/内存缓存层交互的物理最小单元(常见 8KB / 16KB)。
  3. Slotted Page 结构完美解决了变长字段存储与页内空闲空间回收的难题。
  4. Heap File 负责高效处理无序落盘与快速写入(O(1) ),Index 负责解决精准定位与范围检索(O(log N) )。
  5. PostgreSQL(堆表模式) 与 InnoDB(聚簇索引模式) 代表了现代数据库在存储物理布局上的两大流派。