SQLite数据库以其零配置、单文件存储的特点被广泛嵌入到各类应用中,但很少人真正打开过这个文件,看看里面的字节是如何排列的。实际上,SQLite文件并不是一个黑盒,它有一套严格定义的物理存储格式,所有表、索引、触发器和视图最终都落在一个个固定大小的页中。理解这种结构不仅能满足技术好奇心,还能在数据库文件损坏时帮助定位问题,或者为性能调优提供底层依据。接下来我们就从文件头开始,逐层拆解SQLite文件的内部构造。

SQLite文件整体布局与页类型
SQLite数据库文件由一系列连续的页组成,每个页大小相同,默认是4096字节,也可以在创建数据库时通过PRAGMA page_size设置为512到65536之间的2的幂次。文件的第一页比较特殊,它的前100字节是文件头,用来描述整个数据库的元信息,后面跟着第一个B树页。从第二页开始,所有页都按用途划分为不同类型:B树页、空闲页、溢出页和锁页等。
文件头中最关键的信息包括魔数字符串“SQLite format 3\000”,它用来标识这是一个SQLite 3格式的数据库。紧随其后的两个字节表示页大小,但注意这个值存储的是以2为底的对数偏移,比如值为1表示实际页大小为512字节,值为2表示1024字节,通常看到的默认值4096字节对应存储值1。文件头还包含文件格式版本号、只读/读写标记、最大嵌入有效载荷分数、最小嵌入有效载荷分数和叶子有效载荷分数等,这些参数影响B树中溢出页的使用策略。
每种页都有一个一字节的页类型标记,位于页的第一个字节。常见的类型值包括:2表示内部索引B树页,5表示内部表B树页,10表示叶子索引B树页,13表示叶子表B树页。此外还有空闲页、溢出页和锁页。可以通过以下Python代码读取一个SQLite文件的前100字节,解析出魔数和页大小:
import struct
with open('test.db', 'rb') as f:
header = f.read(100)
magic = header[0:16]
print("魔数:", magic)
page_size_raw = header[16:18]
page_size_value = struct.unpack('>H', page_size_raw)[0]
page_size = 1 if page_size_value == 1 else page_size_value * 256
print("页大小:", page_size)
# 读取文件格式版本
format_version = header[18:20]
print("文件格式版本:", struct.unpack('>H', format_version)[0])
这段代码会输出魔数“SQLite format 3\000”以及根据原始值转换后的实际页大小。理解文件头是后续解析页的基础,因为只有知道了页大小,才能正确跳转到任意页的偏移位置。
B树页的内部结构与记录存储
SQLite中的每张表和每个索引都对应一个B树,表使用B+树变体,索引也类似。B树页分为叶子页和内部页两种。对于一个表B树,叶子页存储实际的行记录,内部页存储键值和子页指针,键值通常是主键或rowid。对于索引B树,叶子页存储索引键和对应记录的rowid列表。
每个B树页开头都有一个页头,紧接着是单元格指针数组,最后是实际单元格内容。叶子表B树页的页头包含页类型、第一个空闲块偏移、单元格数量、单元格内容区起始偏移、碎片字节数和右兄弟页指针等字段。单元格指针数组是一个从页尾向前扩展的数组,每个指针指向一个单元格,这些单元格从页头之后的内容区开始向后增长,两者在页中间相遇即表示页满。
单元格中的记录格式是SQLite设计的精髓。一条记录由记录头和记录体组成,记录头连续存放每个列的serial type,serial type是一个变长整数,编码了值的数据类型和字节长度。例如serial type为1表示8位有符号整数,2表示16位整数,3表示24位整数,4表示32位整数,5表示48位整数,6表示64位整数,7表示IEEE浮点数,8表示整数0,9表示整数1,10和11用于布尔值,大于等于12且为偶数表示BLOB,长度为(serial type-12)/2,为奇数表示文本,长度为(serial type-13)/2。记录头以长度前缀作为一个整体存储,用户只需要解析出所有serial type并累加长度,就能定位到各列的值。
以下是一个简化的Python代码片段,用来解析一条SQLite记录的第一个serial type:
def read_varint(data, pos):
result = 0
shift = 0
while True:
byte = data[pos]
pos += 1
result |= (byte & 0x7F) << shift
if (byte & 0x80) == 0:
break
shift += 7
return result, pos
# 假设record是一条记录的字节串,record_header_size是记录头长度
record_header_size, pos = read_varint(record, 0)
serial_types = []
while pos < record_header_size:
st, pos = read_varint(record, pos)
serial_types.append(st)
print("各列serial type:", serial_types)
这段代码展示了变长整数的解码过程:每个字节的最高位是延续标志,低7位是数据。通过不断移位累加,直到遇到最高位为0的字节。这种编码方式对短整数非常高效,也解释了为什么SQLite在存储小整数时几乎不浪费空间。
空闲页管理与空间回收策略
当删除表中的行或者更新行导致大小变化时,SQLite并不会立即把释放的空间返还给操作系统,而是将这些页标记为空闲页,并加入空闲页链表中。空闲页链表由一个32位整数组成的链表头数组维护,这些指针存储在数据库文件的第1页偏移32处开始的4字节整数中,每个整数指向一个空闲页链表的头。链表中的每个空闲页的前4个字节存储下一个空闲页的页号,后4个字节存储链表中剩余空闲页的数量。
SQLite会在适当的时候复用这些空闲页,以避免文件无限增长。但频繁的增删操作也可能导致文件碎片化,即逻辑上连续的数据在物理上分散在不同页中,影响顺序扫描的性能。可以通过VACUUM命令重建数据库文件,消除空闲页并紧凑存储,但VACUUM需要额外的磁盘空间且会阻塞写操作。
在WAL(Write-Ahead Logging)模式下,SQLite的存储方式略有不同,主数据库文件仍然是页结构,但写操作先记录到独立的WAL文件中,只有检查点发生时才会将WAL中的页写回主文件。这种机制避免了阻塞读操作,但主文件中可能会存在一些已提交但尚未合并的页。理解空闲页和WAL机制对于分析数据库文件大小变化和恢复未提交事务非常有用。
总结来说,SQLite文件是一种自描述的二进制格式,通过页、B树和变长记录编码,在保持小巧灵活的同时提供了完整的ACID事务支持。无论你是想手动解析SQLite文件,还是优化嵌入式应用的存储性能,这些底层知识都会让你对SQLite有一个全新的认识。
SQLite文件结构页结构B树修改时间:2026-08-27 19:58:57