上一篇我们解析了 SQLite 文件的前 100 字节(文件头),知道了页大小和总页数。这一篇进入 B-Tree 的核心:页的内部结构。每个页里有什么?数据行(Cell)在哪里?页头告诉了我们什么?
本篇结束时,我们能读取任意一页的 B-Tree 页头,并拿到该页包含的所有 Cell 的偏移量列表——这是后续读取实际数据行的基础。
一、页的内存布局
每一页从头到尾的布局如下:
┌───────────────────────────────────────────────────────┐
│ 页头(Page Header) │
│ 叶节点:8 字节 内部节点:12 字节 │
├───────────────────────────────────────────────────────┤
│ Cell 指针数组(Cell Pointer Array) │
│ 每个指针 2 字节,共 cell_count 个 │
├───────────────────────────────────────────────────────┤
│ 未分配空间(Unallocated Space) │
│ 用于分配新的 Cell │
├───────────────────────────────────────────────────────┤
│ Cell 内容区(Cell Content Area) │
│ Cell 从页尾向页头方向增长 │
└───────────────────────────────────────────────────────┘
关键点:Cell 从页的末尾向前增长,而 Cell 指针数组从页头向后增长。两者之间的空隙是未分配空间。Cell 指针数组记录每个 Cell 在页内的偏移量,通过它可以随机访问任意 Cell,不需要顺序扫描。
注意 Page 1 比较特殊:它的前 100 字节是文件头,所以 Page 1 的 B-Tree 页头从偏移 100 开始,而不是从 0 开始。其他页的页头都从偏移 0 开始。
二、页头格式
叶节点页头 8 字节,内部节点页头 12 字节(多了一个"最右子页"指针):
| 偏移 | 大小 | 字段 | 说明 |
|---|---|---|---|
| 0 | 1 | page_type | 页类型:0x0D=叶表, 0x05=内部表, 0x0A=叶索引, 0x02=内部索引 |
| 1 | 2 | first_freeblock | 页内第一个空闲块的偏移(0 表示没有空闲块) |
| 3 | 2 | cell_count | 本页包含的 Cell 数量 |
| 5 | 2 | cell_content_start | Cell 内容区起始偏移(0 代表 65536) |
| 7 | 1 | fragmented_bytes | 碎片化的空闲字节数 |
| 8 | 4 | right_most_pointer | 仅内部节点有:最右子页的页号 |
页头之后紧跟 Cell 指针数组,每个指针是 2 字节大端序整数,代表对应 Cell 在页内的字节偏移量。
三、Rust 实现
页类型枚举
// src/page.rs
#[derive(Debug, PartialEq, Clone, Copy)]
pub enum PageType {
LeafTable, // 0x0D — 叶节点表页(存行数据)
InteriorTable, // 0x05 — 内部节点表页(存子页指针)
LeafIndex, // 0x0A — 叶节点索引页
InteriorIndex, // 0x02 — 内部节点索引页
}
impl PageType {
pub fn from_byte(b: u8) -> Option<Self> {
match b {
0x0D => Some(PageType::LeafTable),
0x05 => Some(PageType::InteriorTable),
0x0A => Some(PageType::LeafIndex),
0x02 => Some(PageType::InteriorIndex),
_ => None,
}
}
pub fn is_leaf(&self) -> bool {
matches!(self, PageType::LeafTable | PageType::LeafIndex)
}
pub fn is_interior(&self) -> bool {
!self.is_leaf()
}
/// 页头大小:叶节点 8 字节,内部节点 12 字节
pub fn header_size(&self) -> usize {
if self.is_interior() { 12 } else { 8 }
}
}
页头结构体
// src/page.rs (续)
#[derive(Debug)]
pub struct PageHeader {
pub page_type: PageType,
pub first_freeblock: u16,
pub cell_count: u16,
pub cell_content_start: u32, // u32 因为 0 需要解释为 65536
pub fragmented_bytes: u8,
pub right_most_pointer: Option<u32>, // 仅内部节点有
}
impl PageHeader {
/// 从页字节切片解析页头
/// `data` 是整个页的字节数组
/// `page_offset` 是页头在 data 中的起始偏移(Page 1 为 100,其他为 0)
pub fn parse(data: &[u8], page_offset: usize) -> Result<Self, &'static str> {
if data.len() < page_offset + 8 {
return Err("page data too short for header");
}
let b = &data[page_offset..];
// 字节 0:页类型
let page_type = PageType::from_byte(b[0])
.ok_or("unknown page type")?;
// 字节 1-2:第一个空闲块偏移
let first_freeblock = u16::from_be_bytes([b[1], b[2]]);
// 字节 3-4:Cell 数量
let cell_count = u16::from_be_bytes([b[3], b[4]]);
// 字节 5-6:Cell 内容区起始偏移(0 代表 65536)
let raw_start = u16::from_be_bytes([b[5], b[6]]);
let cell_content_start = if raw_start == 0 { 65536 } else { raw_start as u32 };
// 字节 7:碎片化空闲字节数
let fragmented_bytes = b[7];
// 字节 8-11(仅内部节点):最右子页页号
let right_most_pointer = if page_type.is_interior() {
if data.len() < page_offset + 12 {
return Err("page data too short for interior header");
}
Some(u32::from_be_bytes([b[8], b[9], b[10], b[11]]))
} else {
None
};
Ok(PageHeader {
page_type,
first_freeblock,
cell_count,
cell_content_start,
fragmented_bytes,
right_most_pointer,
})
}
}
页读取器
// src/pager.rs
use std::fs::File;
use std::io::{self, Read, Seek, SeekFrom};
pub struct Pager {
file: File,
page_size: u32,
}
impl Pager {
pub fn open(path: &str, page_size: u32) -> io::Result<Self> {
let file = File::open(path)?;
Ok(Pager { file, page_size })
}
/// 读取第 page_num 页(从 1 开始编号),返回页的完整字节数组
pub fn read_page(&mut self, page_num: u32) -> io::Result<Vec<u8>> {
assert!(page_num >= 1, "page numbers start from 1");
let offset = (page_num - 1) as u64 * self.page_size as u64;
self.file.seek(SeekFrom::Start(offset))?;
let mut buf = vec![0u8; self.page_size as usize];
self.file.read_exact(&mut buf)?;
Ok(buf)
}
}
读取 Cell 指针数组
Cell 指针数组紧跟在页头之后,每个指针 2 字节,共 cell_count 个:
// src/page.rs (续)
/// 从页数据中读取所有 Cell 的页内偏移量
/// 返回 Vec<u16>,每个元素是对应 Cell 在页内的字节偏移
pub fn read_cell_pointers(
data: &[u8],
header: &PageHeader,
page_header_offset: usize, // Page 1 为 100,其他为 0
) -> Vec<u16> {
// Cell 指针数组紧跟在页头之后
let array_start = page_header_offset + header.page_type.header_size();
let count = header.cell_count as usize;
(0..count)
.map(|i| {
let pos = array_start + i * 2;
u16::from_be_bytes([data[pos], data[pos + 1]])
})
.collect()
}
四、组装起来
更新 main.rs,在读取文件头之后,再读取 Page 1 的页头和 Cell 指针:
// src/main.rs
mod header;
mod page;
mod pager;
use header::{DbHeader, format_version};
use page::{PageHeader, read_cell_pointers};
use pager::Pager;
fn main() {
let path = std::env::args().nth(1).unwrap_or_else(|| {
eprintln!("Usage: sqlite-rs <database.db>");
std::process::exit(1);
});
// 1. 解析文件头
let db_header = DbHeader::read_from_file(&path).unwrap_or_else(|e| {
eprintln!("Error reading header: {}", e);
std::process::exit(1);
});
println!("=== Database Info ===");
println!("Page size: {} bytes", db_header.page_size);
println!("Page count: {}", db_header.page_count);
println!("SQLite ver: {}", format_version(db_header.sqlite_version));
println!();
// 2. 读取 Page 1
let mut pager = Pager::open(&path, db_header.page_size).unwrap();
let page1_data = pager.read_page(1).unwrap();
// Page 1 的页头从偏移 100 开始(前 100 字节是文件头)
let page_header_offset = 100;
let page_header = PageHeader::parse(&page1_data, page_header_offset).unwrap();
println!("=== Page 1 (sqlite_schema root) ===");
println!("Page type: {:?}", page_header.page_type);
println!("Cell count: {}", page_header.cell_count);
println!("Content area: {} (offset from page start)", page_header.cell_content_start);
println!("Freeblock: {}", page_header.first_freeblock);
if let Some(rmp) = page_header.right_most_pointer {
println!("Rightmost ptr: page {}", rmp);
}
println!();
// 3. 读取 Cell 指针数组
let cell_pointers = read_cell_pointers(&page1_data, &page_header, page_header_offset);
println!("=== Cell Pointers (page-relative offsets) ===");
for (i, offset) in cell_pointers.iter().enumerate() {
println!(" Cell {:2}: offset = {}", i, offset);
}
}
运行结果
cargo run -- test.db
=== Database Info ===
Page size: 4096 bytes
Page count: 2
SQLite ver: 3.46.0
=== Page 1 (sqlite_schema root) ===
Page type: LeafTable
Cell count: 1
Content area: 3964 (offset from page start)
Freeblock: 0
=== Cell Pointers (page-relative offsets) ===
Cell 0: offset = 3964
Page 1 是叶节点,包含 1 个 Cell(即 sqlite_schema 表中的一行——我们只建了一张 users 表,所以 schema 里只有 1 条记录)。Cell 存储在页内偏移 3964 处(靠近页末尾,符合"从尾部向前增长"的规则)。
用 hexdump 验证
# 查看 Page 1 偏移 100 处的页头(8 字节)
hexdump -C test.db | grep -A 3 "^00000060"
00000060 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 |................|
00000070 00 2e 44 06 00 00 00 00 00 00 00 00 00 00 00 00 |..D.............|
# 专门看偏移 0x64 (100) 处的页头
python3 -c "
data = open('test.db', 'rb').read()
h = data[100:110]
print(f'page_type: 0x{h[0]:02x} ({h[0]})')
print(f'first_freeblock: {int.from_bytes(h[1:3], \"big\")}')
print(f'cell_count: {int.from_bytes(h[3:5], \"big\")}')
print(f'cell_content_start:{int.from_bytes(h[5:7], \"big\")}')
print(f'fragmented_bytes: {h[7]}')
print(f'cell_ptr[0]: {int.from_bytes(data[108:110], \"big\")}')
"
page_type: 0x0d (13) ← LeafTable ✓
first_freeblock: 0 ✓
cell_count: 1 ✓
cell_content_start: 3964 ✓
fragmented_bytes: 0 ✓
cell_ptr[0]: 3964 ✓
每个字段都和我们的 Rust 解析结果完全一致。
五、当前代码结构
sqlite-rs/
├── Cargo.toml
├── test.db
└── src/
├── main.rs ← 入口
├── header.rs ← 文件头解析(第 01 篇)
├── page.rs ← 页头解析 + Cell 指针读取(本篇)
└── pager.rs ← 页读取器(本篇)
六、关键点总结
- 每页 = 页头 + Cell 指针数组 + 未分配空间 + Cell 内容区
- Cell 从页末尾向前增长,Cell 指针数组从页头向后增长
- 叶节点页头 8 字节,内部节点多一个 4 字节的"最右子页"指针(12 字节)
- Cell 指针数组每项 2 字节,记录 Cell 在页内的字节偏移
- Page 1 特殊:页头从偏移 100 开始,其他页从偏移 0 开始
cell_content_start为 0 时代表 65536
下一篇会实现 B-Tree 的遍历:从根节点出发,处理内部节点(读取子页指针),一路走到叶节点,把所有叶节点页的 Cell 地址全部收集起来——这是实现全表扫描的前提。