手写 SQLite 01:SQLite 文件格式全景 + 用 Rust 解析文件头

这是「从零用 Rust 实现 SQLite」系列的第一篇。整个系列会一步步实现一个能读取真实 .db 文件、执行 SQL 查询的 SQLite 读取器,每篇聚焦一个具体的实现步骤。

系列目录:

  1. 第 01 篇(本篇):SQLite 文件格式全景 + 解析文件头
  2. 第 02 篇:B-Tree 页结构解析
  3. 第 03 篇:遍历 B-Tree(叶节点 + 内部节点)
  4. 第 04 篇:Record 格式解析(varint + 列值)
  5. 第 05 篇:Schema 表解析,实现 .tables
  6. 第 06 篇:实现 SELECT * FROM table
  7. 第 07 篇:实现 SELECT count(*)
  8. 第 08 篇:实现 WHERE 过滤
  9. 第 09 篇:索引 B-Tree 与加速查询

本篇先从宏观视角理解 SQLite 的文件格式,然后动手用 Rust 解析文件头。

一、SQLite 文件格式全景

SQLite 把整个数据库存在单个文件里。理解它的关键是把握三层结构:文件 → 页 → B-Tree

1. 文件 = 页的数组

SQLite 文件被划分为等大小的页(Page),每页大小默认 4096 字节(可配置为 512 ~ 65536,必须是 2 的幂次)。整个文件就是一个页的线性数组:

┌─────────────────────────────────────────────────────┐
│  SQLite 文件                                         │
├──────────┬──────────┬──────────┬──────────┬─────────┤
│  Page 1  │  Page 2  │  Page 3  │  Page 4  │  ...    │
│ (4096 B) │ (4096 B) │ (4096 B) │ (4096 B) │         │
└──────────┴──────────┴──────────┴──────────┴─────────┘
     ↑
  文件头(前 100 字节在 Page 1 里)

页从 1 开始编号(注意:不是从 0)。读取第 N 页的偏移量:

offset = (page_number - 1) * page_size

2. 页的类型

每一页都有类型,由页头的第一个字节标识:

类型说明
0x0D (13)叶节点表页存储实际的行数据(最常见)
0x05 (5)内部节点表页B-Tree 的分支,存储子页指针
0x0A (10)叶节点索引页存储索引数据
0x02 (2)内部节点索引页索引 B-Tree 的分支

3. B-Tree:数据的组织方式

SQLite 用 B-Tree 组织所有数据。每张表对应一棵 B-Tree,每个索引也对应一棵 B-Tree。

                    ┌─────────────────┐
                    │  内部节点页      │   ← Page 2(根节点)
                    │  key: 50        │
                    └────────┬────────┘
                   ┌─────────┴──────────┐
                   ↓                    ↓
          ┌─────────────┐      ┌─────────────┐
          │  叶节点页    │      │  叶节点页    │
          │ row 1..50   │      │ row 51..100 │   ← 实际数据在叶节点
          └─────────────┘      └─────────────┘
             Page 3                Page 4

叶节点页存储实际的行数据(称为 Cell),内部节点页只存储分界键和子页的页号。查询一行数据就是从根节点出发,逐层比较键值,走到叶节点找到目标行。

4. 特殊的 Page 1

Page 1 是整个 SQLite 文件最重要的页,它承担两个角色:

  • 前 100 字节:文件头(File Header),存储数据库的全局元数据
  • 第 100 字节之后sqlite_schema 表的根页(B-Tree 根节点),存储所有表和索引的 Schema 信息

5. Record:行数据的存储格式

叶节点页里的每一行数据(Cell)包含一个 Record。Record 由两部分组成:

  • Header:每列的类型描述(使用 varint 编码的 Serial Type 列表)
  • Body:实际的列值(按 Header 指定的类型和长度依次排列)

这些细节将在第 04 篇详细展开。现在先专注于第一步——解析文件头。

二、文件头格式(100 字节)

SQLite 文件的前 100 字节是文件头,包含数据库的核心元数据。按 SQLite 官方规范,每个字段的偏移量和含义如下:

偏移大小字段名说明
016magic string"SQLite format 3\000",固定魔数
162page_size页大小(字节),特殊值 1 代表 65536
181write_version文件格式写入版本(1=legacy, 2=WAL)
191read_version文件格式读取版本
201reserved_bytes每页末尾保留的字节数(通常为 0)
244file_change_counter文件变更计数器
394page_count数据库总页数
394first_freelist_trunk第一个空闲页链表页的页号
394freelist_count空闲页总数
404schema_cookieSchema 变更计数(每次 DDL 操作递增)
444schema_formatSchema 格式版本(1~4)
484default_cache_size默认页缓存大小
524largest_root_pageauto-vacuum 模式下最大根页页号
564text_encoding文本编码:1=UTF-8, 2=UTF-16LE, 3=UTF-16BE
604user_version用户自定义版本号(PRAGMA user_version)
644incremental_vacuum增量 vacuum 模式标志
684application_id应用程序 ID(PRAGMA application_id)
7220reserved保留字段,全部为 0
924version_valid_forversion_number 对应的 change_counter
964sqlite_version写入此文件的 SQLite 版本号

注意所有多字节整数都是大端序(Big-Endian)存储,这和 x86 机器的本机字节序相反,读取时需要做字节序转换。

三、动手实现

项目初始化

cargo new sqlite-rs
cd sqlite-rs

先准备一个测试用的 SQLite 文件:

sqlite3 test.db "CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, age INTEGER);
INSERT INTO users VALUES (1, 'Alice', 30);
INSERT INTO users VALUES (2, 'Bob', 25);
INSERT INTO users VALUES (3, 'Charlie', 35);"

定义文件头结构体

// src/header.rs

/// SQLite 文件头(前 100 字节)
#[derive(Debug)]
pub struct DbHeader {
    /// 页大小(字节)
    pub page_size: u32,
    /// 文件格式写入版本
    pub write_version: u8,
    /// 文件格式读取版本
    pub read_version: u8,
    /// 每页末尾保留字节数
    pub reserved_bytes: u8,
    /// 数据库总页数
    pub page_count: u32,
    /// Schema 变更计数
    pub schema_cookie: u32,
    /// Schema 格式版本
    pub schema_format: u32,
    /// 文本编码
    pub text_encoding: TextEncoding,
    /// 写入此文件的 SQLite 版本号
    pub sqlite_version: u32,
}

#[derive(Debug, PartialEq)]
pub enum TextEncoding {
    Utf8,
    Utf16Le,
    Utf16Be,
}

解析文件头

// src/header.rs (续)

use std::fs::File;
use std::io::{self, Read};

/// SQLite 文件的魔数字符串
const MAGIC: &[u8; 16] = b"SQLite format 3\0";

impl DbHeader {
    /// 从文件读取并解析文件头
    pub fn read_from_file(path: &str) -> io::Result<Self> {
        let mut file = File::open(path)?;
        let mut buf = [0u8; 100];
        file.read_exact(&mut buf)?;
        Self::parse(&buf)
    }

    /// 从字节切片解析文件头
    pub fn parse(buf: &[u8; 100]) -> io::Result<Self> {
        // 1. 验证魔数
        if &buf[0..16] != MAGIC {
            return Err(io::Error::new(
                io::ErrorKind::InvalidData,
                "not a valid SQLite database file",
            ));
        }

        // 2. 读取页大小(偏移 16,2 字节大端序)
        //    特殊规则:值为 1 时代表 65536
        let raw_page_size = u16::from_be_bytes([buf[16], buf[17]]);
        let page_size: u32 = if raw_page_size == 1 {
            65536
        } else {
            raw_page_size as u32
        };

        // 3. 读取版本信息(偏移 18、19,单字节)
        let write_version = buf[18];
        let read_version  = buf[19];
        let reserved_bytes = buf[20];

        // 4. 读取总页数(偏移 28,4 字节大端序)
        let page_count = u32::from_be_bytes([buf[28], buf[29], buf[30], buf[31]]);

        // 5. 读取 Schema 信息(偏移 40、44)
        let schema_cookie  = u32::from_be_bytes([buf[40], buf[41], buf[42], buf[43]]);
        let schema_format  = u32::from_be_bytes([buf[44], buf[45], buf[46], buf[47]]);

        // 6. 读取文本编码(偏移 56)
        let enc_raw = u32::from_be_bytes([buf[56], buf[57], buf[58], buf[59]]);
        let text_encoding = match enc_raw {
            1 => TextEncoding::Utf8,
            2 => TextEncoding::Utf16Le,
            3 => TextEncoding::Utf16Be,
            _ => return Err(io::Error::new(
                io::ErrorKind::InvalidData,
                format!("unknown text encoding: {}", enc_raw),
            )),
        };

        // 7. 读取 SQLite 版本号(偏移 96)
        let sqlite_version = u32::from_be_bytes([buf[96], buf[97], buf[98], buf[99]]);

        Ok(DbHeader {
            page_size,
            write_version,
            read_version,
            reserved_bytes,
            page_count,
            schema_cookie,
            schema_format,
            text_encoding,
            sqlite_version,
        })
    }
}

/// 将 SQLite 版本号(如 3046000)格式化为可读字符串(如 "3.46.0")
pub fn format_version(v: u32) -> String {
    let major = v / 1_000_000;
    let minor = (v % 1_000_000) / 1_000;
    let patch = v % 1_000;
    format!("{}.{}.{}", major, minor, patch)
}

主程序

// src/main.rs

mod header;
use header::{DbHeader, format_version};

fn main() {
    let path = std::env::args().nth(1).unwrap_or_else(|| {
        eprintln!("Usage: sqlite-rs <database.db>");
        std::process::exit(1);
    });

    let header = DbHeader::read_from_file(&path).unwrap_or_else(|e| {
        eprintln!("Error: {}", e);
        std::process::exit(1);
    });

    println!("=== SQLite File Header ===");
    println!("Page size:       {} bytes", header.page_size);
    println!("Page count:      {}", header.page_count);
    println!("Database size:   {} bytes",
             header.page_size as u64 * header.page_count as u64);
    println!("Write version:   {} ({})",
             header.write_version,
             if header.write_version == 2 { "WAL" } else { "legacy" });
    println!("Read version:    {}", header.read_version);
    println!("Reserved bytes:  {}", header.reserved_bytes);
    println!("Schema cookie:   {}", header.schema_cookie);
    println!("Schema format:   {}", header.schema_format);
    println!("Text encoding:   {:?}", header.text_encoding);
    println!("SQLite version:  {}", format_version(header.sqlite_version));
}

运行

cargo run -- test.db

输出:

=== SQLite File Header ===
Page size:       4096 bytes
Page count:      2
Database size:   8192 bytes
Write version:   1 (legacy)
Read version:    1
Reserved bytes:  0
Schema cookie:   1
Schema format:   4
Text encoding:   Utf8
SQLite version:  3.46.0

用 hexdump 验证

可以直接用 hexdump 查看原始字节,验证我们的解析是否正确:

hexdump -C test.db | head -8
00000000  53 51 4c 69 74 65 20 66  6f 72 6d 61 74 20 33 00  |SQLite format 3.|
00000010  10 00 01 01 00 40 20 20  00 00 00 01 00 00 00 02  |.....@  ........|
00000020  00 00 00 00 00 00 00 00  00 00 00 01 00 00 00 04  |................|
00000030  00 00 00 00 00 00 00 00  00 00 00 01 00 00 00 00  |................|
00000040  00 00 00 00 00 00 00 00  00 00 00 00 00 00 00 00  |................|

逐字段验证:

  • 偏移 0x00 ~ 0x0F:53 51 4c 69 74 65 20 66 6f 72 6d 61 74 20 33 00 = "SQLite format 3\0"
  • 偏移 0x10 ~ 0x11:10 00 = 0x1000 = 4096(页大小)✓
  • 偏移 0x12:01(write_version = 1, legacy)✓
  • 偏移 0x13:01(read_version = 1)✓
  • 偏移 0x1C ~ 0x1F:00 00 00 02 = 2(总页数)✓

四、完整代码结构

sqlite-rs/
├── Cargo.toml
├── test.db
└── src/
    ├── main.rs        ← 入口,命令行解析
    └── header.rs      ← 文件头解析

目前的 Cargo.toml 不需要任何外部依赖,完全用标准库实现:

[package]
name = "sqlite-rs"
version = "0.1.0"
edition = "2021"

[dependencies]
# 暂时不需要外部依赖

五、关键点总结

  • SQLite 文件是等大小页的线性数组,页号从 1 开始
  • Page 1 的前 100 字节是文件头,存储全局元数据
  • 所有多字节整数都是大端序,用 u32::from_be_bytes() 读取
  • 页大小字段为 1 时是特殊值,代表 65536
  • Page 1 除了文件头,剩余部分是 sqlite_schema 表的 B-Tree 根节点

下一篇将在文件头的基础上,解析 Page 1 的 B-Tree 页头,理解页头结构,并读取页内的 Cell 数组——这是我们能真正读到数据行的关键一步。