手写 SQLite 02:B-Tree 页结构解析与 Rust 实现

上一篇我们解析了 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 字节(多了一个"最右子页"指针):

偏移大小字段说明
01page_type页类型:0x0D=叶表, 0x05=内部表, 0x0A=叶索引, 0x02=内部索引
12first_freeblock页内第一个空闲块的偏移(0 表示没有空闲块)
32cell_count本页包含的 Cell 数量
52cell_content_startCell 内容区起始偏移(0 代表 65536)
71fragmented_bytes碎片化的空闲字节数
84right_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 地址全部收集起来——这是实现全表扫描的前提。