这是「从零用 Rust 实现 SQLite」系列的第一篇。整个系列会一步步实现一个能读取真实 .db 文件、执行 SQL 查询的 SQLite 读取器,每篇聚焦一个具体的实现步骤。
系列目录:
- 第 01 篇(本篇):SQLite 文件格式全景 + 解析文件头
- 第 02 篇:B-Tree 页结构解析
- 第 03 篇:遍历 B-Tree(叶节点 + 内部节点)
- 第 04 篇:Record 格式解析(varint + 列值)
- 第 05 篇:Schema 表解析,实现
.tables - 第 06 篇:实现
SELECT * FROM table - 第 07 篇:实现
SELECT count(*) - 第 08 篇:实现 WHERE 过滤
- 第 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 官方规范,每个字段的偏移量和含义如下:
| 偏移 | 大小 | 字段名 | 说明 |
|---|---|---|---|
| 0 | 16 | magic string | "SQLite format 3\000",固定魔数 |
| 16 | 2 | page_size | 页大小(字节),特殊值 1 代表 65536 |
| 18 | 1 | write_version | 文件格式写入版本(1=legacy, 2=WAL) |
| 19 | 1 | read_version | 文件格式读取版本 |
| 20 | 1 | reserved_bytes | 每页末尾保留的字节数(通常为 0) |
| 24 | 4 | file_change_counter | 文件变更计数器 |
| 39 | 4 | page_count | 数据库总页数 |
| 39 | 4 | first_freelist_trunk | 第一个空闲页链表页的页号 |
| 39 | 4 | freelist_count | 空闲页总数 |
| 40 | 4 | schema_cookie | Schema 变更计数(每次 DDL 操作递增) |
| 44 | 4 | schema_format | Schema 格式版本(1~4) |
| 48 | 4 | default_cache_size | 默认页缓存大小 |
| 52 | 4 | largest_root_page | auto-vacuum 模式下最大根页页号 |
| 56 | 4 | text_encoding | 文本编码:1=UTF-8, 2=UTF-16LE, 3=UTF-16BE |
| 60 | 4 | user_version | 用户自定义版本号(PRAGMA user_version) |
| 64 | 4 | incremental_vacuum | 增量 vacuum 模式标志 |
| 68 | 4 | application_id | 应用程序 ID(PRAGMA application_id) |
| 72 | 20 | reserved | 保留字段,全部为 0 |
| 92 | 4 | version_valid_for | version_number 对应的 change_counter |
| 96 | 4 | sqlite_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 数组——这是我们能真正读到数据行的关键一步。