在前几篇中我们已经知道数据库不会简单地把数据当成一个巨大的字节流来处理。
数据库会把数据组织成 Page,再在 Page 之上组织 Record、Index 和 Table。
但一个新的问题出现了:
Page 里面到底长什么样?
如果一页只有 8KB、16KB,而记录大小又各不相同,数据库究竟如何知道一条记录在哪里、哪里还有空间,以及删除和更新之后如何继续使用这些空间?
这次进入数据库存储引擎内部。
一、从一个空 Page 开始
假设数据库使用:
text
Page Size = 8KB
最开始,一个 Page 可能是空的:
text
+--------------------------------+
| |
| |
| 8 KB |
| |
| |
+--------------------------------+
现在插入第一条记录:
text
User
id = 100
name = 张三
age = 20
数据库不能只是:Record
它还必须知道:
- 这条 Record 在哪里?
- 有多长?
- Page 中还有多少空间?
- Page 中有多少条记录?
因此 Page 必须拥有自己的元数据。
二、Page Header:一个 Page 的"身份证"
最基本的 Page 可以抽象成:
text
+----------------------+
| Page Header |
+----------------------+
| Record |
| Record |
| Record |
| |
| Free Space |
+----------------------+
Page Header 可以保存类似:
text
Page ID
Page Type
Record Count
Free Space
LSN
Checksum
当然,不同数据库的实际字段完全不同。
例如:
cpp
struct PageHeader
{
uint32_t page_id;
uint16_t page_type;
uint16_t record_count;
};
这里需要特别注意:**这只是帮助理解的抽象结构,并不是某个真实数据库的 Page 格式。**真实数据库通常复杂得多。
三、为什么不能简单地把 Record 一个接一个放?
假设每条 Record 都固定大小:
text
Record = 100 Bytes
那么很简单:
text
Page
Record 0
Record 1
Record 2
Record 3
...
找到第 N 条:
text
offset = N × 100
但是现实数据库很少这么简单。
例如:
text
id name
1 张三
2 李四
3 王五
字符串长度不同:
text
张三 = 2
李四五六七 = 5
王五六 = 3
Record 大小可能不同。
于是:
text
Record 0 = 50 Bytes
Record 1 = 80 Bytes
Record 2 = 45 Bytes
这时候第 N 条记录的位置不能简单通过 N × RecordSize 计算。数据库必须保存,**Record 到底在哪里。**于是出现了非常重要的概念:Slot
四、Slot:数据库如何定位 Page 中的 Record?
可以把 Page 想象成一个仓库。
Record:仓库里的货物。
Slot:仓库的货物目录。
例如:
text
+----------------------+
| Page Header |
+----------------------+
| Slot 0 → Record A |
| Slot 1 → Record B |
| Slot 2 → Record C |
+----------------------+
| |
| Free Space |
| |
+----------------------+
| Record C |
| Record B |
| Record A |
+----------------------+
Slot 保存:
text
Offset
Length
例如:
text
Slot 0
offset = 7900
length = 80
Slot 1
offset = 7820
length = 80
于是数据库可以通过:
text
Page ID
+
Slot ID
定位一条 Record。
五、为什么 Slot 很重要?
假设 Page 中:
text
Record A
Record B
Record C
删除:
text
Record B
如果没有 Slot:
text
A
C
数据库可能需要重新移动大量数据。
但是有 Slot:
text
Slot 0 → A
Slot 1 → invalid
Slot 2 → C
Record C:可以继续留在原来的位置。只需要把slot1标记成无效状态。
这带来了一个非常重要的思想:逻辑位置和物理位置可以分离。
六、Heap Page 的经典布局
很多数据库的 Heap Page 都可以抽象成类似:
text
高地址
+-------------------------+
| Record C |
+-------------------------+
| Record B |
+-------------------------+
| Record A |
+-------------------------+
| |
| Free Space |
| |
+-------------------------+
| Slot 2 |
+-------------------------+
| Slot 1 |
+-------------------------+
| Slot 0 |
+-------------------------+
| Page Header |
+-------------------------+
低地址
也就是:
text
Header
↓
Slots
Free Space
Records
↑
从另一端向前增长
为什么 Slot 和 Record 从两边向中间增长?
因为这样可以:最大化利用 Page 中的空闲空间。
七、插入 Record 会发生什么?
假设:Page Size = 8192
当前:
text
+----------------+
| Header |
+----------------+
| Slot 0 |
+----------------+
| Free Space |
| |
| |
+----------------+
| Record A |
+----------------+
现在插入:
text
Record B
数据库可能:
第一步
从 Free Space 分配空间。
text
Free Space
↓
Record B
第二步
增加 Slot:
text
Slot 0 → A
Slot 1 → B
最终:
text
+----------------+
| Header |
+----------------+
| Slot 0 → A |
| Slot 1 → B |
+----------------+
| Free Space |
+----------------+
| Record B |
| Record A |
+----------------+
八、删除 Record 怎么办?
现在:
text
Slot 0 → A
Slot 1 → B
Slot 2 → C
删除 B。
一种简单方案:
text
Slot 1 = invalid
于是:
text
Slot 0 → A
Slot 1 → deleted
Slot 2 → C
这里出现了一个问题,B 原来的空间怎么办?
九、删除并不意味着立即整理 Page
数据库通常不会因为删除一条记录,就立即把整个 Page 重新整理。
否则:
text
Delete
↓
Move Records
↓
Rewrite Page
删除成本会非常高。所以数据库经常允许 Page 暂时出现,Fragmentation(碎片空间)
例如:
text
+----------------+
| Record A |
+----------------+
| Free |
+----------------+
| Record C |
+----------------+
| Free |
+----------------+
这些空间以后可以重新利用。
十、这就是 Free Space Management
数据库必须知道哪些 Page 还有空间?否则插入一条数据时难道扫描整个数据库?
text
Page 0
Page 1
Page 2
Page 3
...
Page 1000000
当然不能。因此数据库需要:Free Space Map
可以简单理解为:
text
Page 0 → 80% full
Page 1 → 20% full
Page 2 → 95% full
Page 3 → 40% full
当插入一个较大的 Record 需要 1KB,数据库可以快速寻找 Page 1,而不是扫描所有 Page。
十一、更新是更复杂的问题
现在:
text
Record A
name = 张三
假设原来的 Record:
text
50 Bytes
修改以后:
text
name = 李四伍六七
变成:
text
80 Bytes
原来的位置可能放不下。怎么办?这是数据库存储引擎非常重要的问题。
十二、第一种方法:原地扩展
如果 Record 后面还有连续空间:
text
Record A
↓
Free Space
可以直接扩大。
但是如果:
text
Record A
Record B
Record C
后面没有足够空间就很麻烦。
十三、第二种方法:移动 Record
数据库可以:
text
旧位置
↓
新位置
然后更新 Slot:
text
Slot 0
旧 Offset
↓
新 Offset
这样 Slot ID 不变,但 Record 的物理位置变了,这就是为什么 Slot 非常重要。
十四、为什么不能让其他对象直接保存 Record 的物理地址?
这是一个非常重要的设计思想。
假设:
text
Record A
address = 0x1000
其他结构直接保存:
cpp
char* ptr = 0x1000;
如果 Record 移动:
text
0x1000
↓
0x2000
那么:
text
ptr
就失效了。
但如果保存的是:
text
Page ID = 10
Slot ID = 3
那么:
text
Page 10
Slot 3
↓
当前 Record
Record 即使移动 Slot 可以跟着更新。因此**Record ID 和 Record Physical Address 是两个概念。**这个思想在很多数据库内部都非常重要。
十五、PostgreSQL 就大量体现了这种思想
PostgreSQL 中:TID
可以理解为:
text
Block Number
+
Offset
也就是:
text
Heap Page
+
Tuple Location
它并不是简单保存 "char*" 这样的内存地址。
因为内存地址只在当前进程生命周期内有效。而数据库需要的是可以持久化、可以重新定位的数据位置。
十六、为什么 Page Size 不能无限大?
这是另一个非常重要的问题。
假设:Page = 4KB
读取一个 Page:4KB
如果:Page = 1GB
读取一条很小的 Record也可能需要处理:1GB
显然浪费。
所以 Page Size 是一种权衡:
text
Page 太小
↓
Tree Height 增加
I/O 次数增加
Page 太大
↓
单次 I/O 数据过多
Cache 利用率降低
因此数据库需要选择合适的 Page 大小。
常见:
text
4KB
8KB
16KB
32KB
但具体大小取决于数据库设计。
十七、Page 不只是"数据容器"
到了这里,我们应该改变一个认识。
Page 并不是简单的 char[8192]。
它实际上承担了很多职责:
text
Page
|
+-- Identification(标识信息)
|
+-- Record Organization(记录组织方式)
|
+-- Free Space Management(空闲空间管理)
|
+-- Integrity Information(完整性信息)
|
+-- Recovery Metadata(恢复原数据)
|
+-- Concurrency Information(并发信息)
所以 Page 是数据库存储引擎最重要的抽象之一。
十八、不同数据库的 Page 并不一样
现在回到我们之前学习的三个数据库。
PostgreSQL
典型结构可以抽象成:
text
Page Header
|
↓
Item Pointers
|
↓
Free Space
|
↓
Tuples
其中 Item Pointer 类似我们今天讲的 Slot。
InnoDB
InnoDB 的 Page 结构更加复杂。
一个数据页会包含:
text
Page Header
+
Infimum / Supremum
+
User Records
+
Free Space
+
Page Directory
+
File Header
+
File Trailer
它不是简单的 Heap Page。因为 InnoDB 的 Page 本身就是 B+Tree 数据结构的重要组成部分。
SQLite
SQLite 的 Page 同样服务于 B-Tree。
例如:
text
Page Header
+
Cell Pointer Array
+
Unallocated Space
+
Cells
其中 Cell Pointer Array 承担了与 Slot 类似的定位作用。
十九、三个数据库放在一起
我们现在可以看到一个非常有意思的现象:
| 数据库 | 数据组织 | Page 中的定位结构 |
|---|---|---|
| PostgreSQL | Heap | Item Pointer |
| InnoDB | B+Tree | Page Directory 等 |
| SQLite | B-Tree | Cell Pointer Array |
它们虽然名字不同:
text
Item Pointer
Page Directory
Cell Pointer
但背后的问题其实相同,如何在有限大小的 Page 中高效管理大小不一、不断变化的数据。
二十、Page 与 Buffer Pool 的关系
现在把前面几篇联系起来。
磁盘:
text
Database File
+--------+
| Page 0 |
+--------+
| Page 1 |
+--------+
| Page 2 |
+--------+
内存:
text
Buffer Pool
+--------+
| Page 0 |
+--------+
| Page 5 |
+--------+
| Page 8 |
+--------+
数据库不会把整个数据库加载进内存。
而是:
text
Disk Page
↓
Buffer Pool
↓
CPU
因此Page 同时是磁盘组织单位,也是数据库内存管理的重要单位。
这也是为什么 Page 会成为数据库架构中的核心抽象。
二十一、Page、Record、Slot 三者是什么关系?
一定要把这三个概念分开。
text
Page
|
+----------------------+
| Header |
| |
| Slot 0 ─────────┐ |
| Slot 1 ──────┐ | |
| Slot 2 ───┐ | | |
| | | | |
| Free Space| | | |
| ↓ ↓ ↓ |
| Record |
+----------------------+
**Page:**一个固定大小的存储空间。
**Record:**真正的数据。
**Slot:**帮助数据库定位 Record 的元数据。
三者不要混淆。
二十二、最重要的设计思想:间接寻址
如果让我从今天挑一个最值得记住的概念:
就是:Indirection ------ 间接寻址
不要:
text
ID → Physical Address
而是:
text
ID
↓
Slot
↓
Physical Location
↓
Record
这样 Record 可以移动,Slot 可以更新,上层逻辑引用不一定需要改变。这是一种非常经典的系统设计思想。
二十三、为什么这和数据库的 MVCC 有关系?
这里先埋一个伏笔。
如果数据库中的一条记录发生更新:
text
Old Version
↓
New Version
数据库可能并不只是简单地覆盖旧数据,因为其他事务可能仍然需要看到旧版本。
于是:
text
Record
↓
Version
↓
Transaction
开始产生联系,这会进入 MVCC,而 MVCC 会成为 PostgreSQL、InnoDB 等现代数据库最重要的机制之一。
二十四、知识地图
我们已经从:
text
Database
逐渐走到:
text
Database
↓
Storage Engine
↓
Data File
↓
Page
↓
Record
↓
Slot
同时:
text
Page
├── Header
├── Slot
├── Free Space
└── Record
而 Page 又连接着:
text
Page
|
+── Disk
|
+── Buffer Pool
|
+── Index
|
+── Transaction
所以 Page 并不是一个孤立的数据结构。它实际上是数据库存储、缓存、索引和事务系统之间的连接点。
二十五、问题梳理
真正应该掌握的是下面这几个问题。
① 为什么需要 Page?
因为数据库需要一个固定大小、适合磁盘和内存管理的存储单位。
② 为什么需要 Slot?
因为 Record 大小不固定,而且 Record 的物理位置可能发生变化。
③ 为什么删除 Record 后不立即整理?
因为移动大量 Record 的成本很高。
数据库可以暂时保留碎片空间,之后再利用或整理。
④ 为什么更新可能需要移动 Record?
因为新 Record 可能比原来的 Record 更大。
⑤ 为什么不能让上层直接依赖物理地址?
因为数据可能移动。
所以数据库通常需要某种间接寻址机制。
最重要的一句话:数据库 Page 的本质,不只是"存数据的一块空间",而是一个能够独立管理数据、空间和定位关系的存储单元。
理解了这一点,后面很多数据库设计都会自然串起来。