数据库为什么不直接使用操作系统文件?
在数据库出现之前,人们习惯使用普通的文本或二进制文件来存储数据。例如一个简易的 users.txt:
1,张三,20
2,李四,25
3,王五,30
这种存储方式在数据量极小时简单有效,但当数据规模膨胀到百万、千万甚至几十亿条时,致命的问题便接踵而至:
- 随机定位困难:为了查找特定 ID 的记录,必须遍历整个文件(O(N) 复杂度)。
- 变长数据修改:如果要将“张三”修改为“张三丰”,后续所有数据在磁盘上都要向后平移,引发极高的 I/O 开销。
- 空洞与碎片化:删除某条记录后,留下的物理空间难以被后续新增的记录高效复用。
- 高并发与恢复:操作系统原生的文件读写无法直接提供事务所需的物理页校验、日志重放(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 InnoDB | 16 KB | 针对高并发 OLTP 事务优化,平衡 B+ 树扇出与 I/O 吞吐 |
| PostgreSQL | 8 KB | 适配传统 Unix 文件系统 Block 尺寸,减少写放大 |
| SQLite | 4 KB | 嵌入式轻量设计,对齐 OS 页面(Page Size)以降低内存占用 |
| SQL Server | 8 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 通常划分为以下四个区域,第五个特殊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 中,插入数据非常简单:
- 寻找任意一个有空闲空间的 Page(通过 Free Space Map / Page Directory)。
- 将数据插入该 Page。
- 写入时间复杂度接近 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 │
└──────────────────────┘
| 数据库引擎 | 底层存储架构模型 | 特点与优点 | 潜在劣势 |
|---|---|---|---|
| PostgreSQL | Heap File + Secondary Indexes | 架构清晰,表和索引高度解耦;多索引场景下二级索引尺寸小。 | 更新主键不需要重构完整数据,但二级索引查找需要物理回表(TID)。 |
| MySQL InnoDB | Clustered Index (聚簇索引) | 主键 B+ 树的叶子节点直接存放完整行数据。主键点查极快,节省一次回表 I/O。 | 插入变长主键易造成页分裂;非主键索引(二级索引)必须存放主键值。 |
| SQLite | B-Tree Direct Storage | 整个表直接组织为一个 B-Tree 文件,精简且零配置。 | 适合嵌入式与单机应用,高并发高吞吐写入受限。 |
六、总结
总结数据库存储引擎的最底层抽象层次:
- 操作系统视文件为 Byte Stream,但数据库视文件为 Page Array。
- Page 是数据库与磁盘/内存缓存层交互的物理最小单元(常见 8KB / 16KB)。
- Slotted Page 结构完美解决了变长字段存储与页内空闲空间回收的难题。
- Heap File 负责高效处理无序落盘与快速写入(O(1) ),Index 负责解决精准定位与范围检索(O(log N) )。
- PostgreSQL(堆表模式) 与 InnoDB(聚簇索引模式) 代表了现代数据库在存储物理布局上的两大流派。