SQLite3 文件格式
本文来源:Zero’s Donkey Den
原文地址:7-0.cc/sqlite3_file_format
转载许可:未经授权,禁止转载
SQLite是目前世界上应用最多、最广泛的数据库,以其开源、无版权、轻量级等特性,使它涵盖了各种各样的应用场景。比如嵌入式电子设备、手机、电脑、汽车等各种领域。可以这么说:它没有出现在你的生活中,但却影响了你生活中的方方面面。
具体来说,你的手机可能会用它来存储联系人、短信、通话记录、浏览器历史、书签、QQ、微信等APP的聊天内容等信息。可想而知它是如此的重要,而因此也衍生出了诸如:数据恢复、司法取证、存储及查询优化等业务场景,而正确的理解SQLite的文件格式是其中必不可少的一个环节。
SQLite3主文件格式
我们知道SQLite3是以单文件来存储的数据,当数据库打开时,文件系统上可能还会存在两个额外文件:*-wal
日志文件、*-shm
共享内存文件。
在这里我们讨论主数据库文件,暂不涉及*-wal
、*-shm
。
而另外需要注意的是:在撰写本文时,当前SQLite3最高版本为:3.42.0 (2023-05-16),笔者测试的最低版本为:3.24.0 (2018-06-04)。如果你的版本在高于或低于这个区间,实际文件格式可能存在差异!
SQLite3文件由固定大小的页(Page)组成
- 页大小为:
512~32768
, 必须是2整倍数, 默认1024字节。 - 页编号为:
1~2147483648(2^31)
, 以4字节表示(记作Page n
)。页编号为0时, 表示不存在。 - 页类型为:
- B-tree页
- 表内部页(Table Interior)
- 表叶子页(Table Leaf)
- 索引内部页(Index Interior)
- 索引叶子页(Index Leaf)
- 空闲页(Freelist)
- 主干页(Freelist Trunk)
- 叶子页(freelist Leaf)
- 溢出页(Overflow)
- lock-byte
- pointer-map
- B-tree页
文件头
Page 1
的页类型总是B-tree
, 且只有该页的前100字节是数据库的文件头, 记录了数据库的相关信息, 定义在: btreeint.h:45(未合并的版本)
All of the integer values are big-endian (most significant byte first).
Offset | Size | Description |
---|---|---|
0 | 16 | The header string: “SQLite format 3\000” |
16 | 2 | The database page size in bytes. Must be a power of two between 512 and 32768 inclusive, or the value 1 representing a page size of 65536. |
18 | 1 | File format write version. 1 for legacy; 2 for WAL. |
19 | 1 | File format read version. 1 for legacy; 2 for WAL. |
20 | 1 | Bytes of unused “reserved” space at the end of each page. Usually 0. |
21 | 1 | Maximum embedded payload fraction. Must be 64. |
22 | 1 | Minimum embedded payload fraction. Must be 32. |
23 | 1 | Leaf payload fraction. Must be 32. |
24 | 4 | File change counter. |
28 | 4 | Size of the database file in pages. The “in-header database size”. |
32 | 4 | Page number of the first freelist trunk page. |
36 | 4 | Total number of freelist pages. |
40 | 4 | The schema cookie. |
44 | 4 | The schema format number. Supported schema formats are 1, 2, 3, and 4. |
48 | 4 | Default page cache size. |
52 | 4 | The page number of the largest root b-tree page when in auto-vacuum or incremental-vacuum modes, or zero otherwise. |
56 | 4 | The database text encoding. A value of 1 means UTF-8. A value of 2 means UTF-16le. A value of 3 means UTF-16be. |
60 | 4 | The “user version” as read and set by the user_version pragma. |
64 | 4 | True (non-zero) for incremental-vacuum mode. False (zero) otherwise. |
68 | 4 | The “Application ID” set by PRAGMA application_id. |
72 | 20 | Reserved for expansion. Must be zero. |
92 | 4 | The version-valid-for number. |
96 | 4 | SQLITE_VERSION_NUMBER |
SQLite Documentation: PRAGMA page_size;
页头 Page Header
OFFSET | SIZE | NAME | DESCRIPTION |
---|---|---|---|
0 | 1 | Flag | The one-byte flag indicating the b-tree page type. |
1 | 2 | FreeblockOffest | byte offset to the first freeblock |
3 | 2 | CellTotal | number of cells on this page |
5 | 2 | CellOffest | first byte of the cell content area |
7 | 1 | FragmentedBytes | number of fragmented free bytes |
8 | 4 | Right child (the Ptr(N) value). Omitted on leaves. |
Flag
定义了页的类型:0x2
表示索引内部页(the page is an interior index b-tree page)0x5
表示表内部页(the page is an interior table b-tree page)0xA
表示索引叶子页(the page is a leaf index b-tree page)0xD
表示表叶子页(the page is a leaf table b-tree page)
FreeblockOffest
表示第一个空闲块的偏移量CellOffest
表示单元内容的偏移量FragmentedBytes
表示此页中碎片空间的总字节数
注意: Flags字段的官方描述如下(btreeInt.h:136), 但与实际并不相符,以官方文档描述为准.
The flags define the format of this btree page. The leaf flag means that
this page has no children. The zerodata flag means that this page carries
only keys and no data. The intkey flag means that the key is an integer
which is stored in the key size entry of the cell header rather than in
the payload area.
B-tree页
-
B-tree
用于存储表的数据与索引. -
B-tree
内部实际承载数据的媒介称为单元(Cell), 由页头中定义的Flag, 决定单元的格式. -
B-tree
必然存在, 而Freelist
与Overflow
不一定存在. -
B-tree
至少占用一个完整的页, 每个页是B-tree
的一个节点, 每个表或索引的第一页称为根页.- 所有表或索引的根页编号都存储在系统表
sqlite_master
中, 该表的根页编号为Page 1
.
- 所有表或索引的根页编号都存储在系统表
-
B-tree
页结构|-----------------| | file header | 100 字节, 仅存在于page 1. |-----------------| | page header | 叶子页(leaf pages)占8字节, 内部页(interior pages)占12字节 |-----------------| | cell pointer | | 存储单元指针的数组 | array | | 每个元素2字节, 按记录顺序存储 | | v 向下增长 |-----------------| | unallocated | 未分配的空间 | space | |-----------------| ^ 向上增长 | cell content | | 无序且夹杂着空闲块(freeblocks)以及自由空间碎片(Free space). | area | | 存储单元内容的区域 |-----------------| | reserved region | 保留空间 | | 用于扩展存储每个页的信息, 其大小由文件头的第20字节处定义, 通常为0. |-----------------|
可变长整数
可变长整数, 为1~9字节, 仅使用低7位, 而第8位在第一个字节(整数的低地址方向)是0, 其余是1.
0x00 becomes 0x00000000
0x7f becomes 0x0000007f
0x81 0x00 becomes 0x00000080
0x82 0x00 becomes 0x00000100
0x80 0x7f becomes 0x0000007f
0x8a 0x91 0xd1 0xac 0x78 becomes 0xa2345678 // 源码这里写错成了0x12345678, 根据算法结果应该是: 0xa2345678
0x81 0x81 0x81 0x81 0x01 becomes 0x10204081
// 参考:
// https://github.com/sqlite/sqlite/blob/58b5921ca460032c6d19bfe6f0f736763f33c3bc/src/btreeInt.h#L183
// https://sqlite.org/forum/forumpost/11fc7308d5e607f2
0x81 0x00
解码过程,如下图所示:
参考代码 Python
:
def fromBytes(ibytes:bytes):
'''
解码可变长整数
'''
value = 0
count = 0
total = len(ibytes)
if total > 9:
total = 9
while count < total:
value = value << 7 | ibytes[count] & 0x7f
if not ibytes[count] & 0x80:
return SQLiteNumber(value, count + 1)
elif count + 1 >= total:
return SQLiteNumber() # 解析失败
count = count + 1
return SQLiteNumber(value, count)
单元(Cell)
-
单元长度是可变的, 并可能会跨越多个页(溢出).
-
单元存储在单元内容区域(Cell Content Area), 位于页尾并向页头的方向逆向生长.
-
单元存储是无序且不连续的.
-
单元的索引或称指针存储在单元指针数组(Cell Pointer Array), 该数组的存储是有序的.
-
单元的内部使用可变长度的整数.
-
单元结构依赖于b-tree页面类型, 可以分为4种不同格式的单元:
- 0x5 表内部页(Table Interior)
存储的是用于组织和导航数据的信息,不存储实际数据(简单的说就是:维护了一些信息,用来快速定位数据所在的表叶子页)。 - 0xD 表叶子页(Table Leaf)
存储实际的数据库记录,包括所有的表结构与表数据等等。 - 0x2 索引内部页(Index Interior)、0xA 索引叶子页(Index Leaf)
存储的是索引信息,会存储部分表字段(如:主键、索引列、唯一约束等字段)的内容副本。
- 0x5 表内部页(Table Interior)
-
不同格式的单元结构对比如下:
Datatype Appears in... Description Table Leaf (0x0d) Table Interior (0x05) Index Leaf (0x0a) Index Interior (0x02) 4-byte integer ✔ ✔ 左子(left child)页号 varint ✔ ✔ ✔ 负载数据字节数 varint ✔ ✔ Rowid byte array ✔ ✔ ✔ 负载数据 4-byte integer ? ? ? 第一个溢出页的页号, 如果没有溢出则不存在
Page中的单元及负载:
单元负载(Cell.Payload)
-
单元负载的结构称为"Record Format", 由两部分组成:
- 标头(Header)
- 主体(Body)
-
Header与Body存在多个字段, 且两个区域的字段是一一对应的, 前者包含字段类型与数据长度, 后者是字段的数据.
-
结构如下:
| ------------------- HEADER ----------------- | ------------ BODY ------------ | | header size | type 1 | type 2 | ... | type N | data 1 | data 2 | ... | data N |
- Header中的所有字段都是可变长整数.
- Header size 表示标头区域的字节数.
- type 1, type 2 分别对应 data 1, data 2 …以此类推, 直到 type N对应data N.
- type 1, type 2 等类型称为"Serial Type", 其存储的值, 表示字段的类型与长度(字节数):
Serial Type Content Size Meaning 0 0 Value is a NULL. 1~4 N Value is a big-endian (N * 8)-bit twos-complement integer. 5 6 Value is a big-endian 48-bit twos-complement integer. 6 8 Value is a big-endian 64-bit twos-complement integer. 7 8 Value is a big-endian IEEE 754-2008 64-bit floating point number. 8 0 Value is the integer 0. (Only available for schema format 4 and higher.)
也用于Float
类型的0值.9 0 Value is the integer 1. (Only available for schema format 4 and higher.) 10,11 可变 仅内部使用 N≥12 & 偶数 (N-12)/2 Value is a BLOB that is (N-12)/2 bytes in length. N≥13 & 奇数 (N-13)/2 Value is a string in the text encoding and (N-13)/2 bytes in length. The nul terminator is not stored.
上图中的负载数据解析:
H 06 00 01 31 [82 63]
E | | | |
A rowid | | |
D | | |
| | |
B 16 | | = '22'
O E8 |B5 B5 E5 8D 81 E4 B9 9D E4 B8 83 E5 8D 81 E4 B8 80 = UTF-8 STR '赵十九七十一'
D 4D 61 65 63 65 6E 61 73 20 74 72 ... 8C E3 80 82 = UTF-8 STR 'Maecenas tristique orci ac sem。汉字内容在这里。 Duis ultricies pharetra magna。汉字内容在这里。 Donec accumsan malesuada orci。汉字内容在这里。'
Y
B-tree内部页(Table Interior 0x05)的导航信息
表内部页仅存储导航信息, 所有数据都存储在叶子页中, 且所有叶子页都在同一层上并以key
排序.
导航信息看起来如下(与实际存储有一定差异):
----------------------------------------------------------------
| Ptr(0) | Key(0) | Ptr(1) | Key(1) | ... | Key(N-1) | Ptr(N) |
----------------------------------------------------------------
Ptr(i)
存储的是页号Key(i)
存储的是唯一key
(默认是rowid
)Ptr(N)
是最右边的页号, 存储在页头种的最后一个字段- 从左到右的排序逻辑为(以
Key
升序排列):Ptr(i)
页内部所有记录的key <= key(i)
Ptr(i)
页内部所有记录的key > Key(i-1)
空闲块
- 单元内容区域内未使用的空间被收集为空闲块链表(Freeblock List), 按照地址升序排列(即下一块地址总是大于前一块的地址)
- 每个空闲块最小4字节, 因为需要4字节来存储空闲块信息, 小于4字节的空间称为碎片(Fragment).
- 空闲块信息结构如下:
SIZE NAME DESCRIPTION 2 NextOffest Byte offset of the next freeblock 2 BlockSzie Bytes in this freeblock
溢出页(overflow page)
-
多个溢出页组成一个链表, 除了最后一页外, 每个页面都完全填充了数据(页面大小- 4字节), 最后一页可以只有1字节的数据。
-
溢出页结构如下:
SIZE DESCRIPTION 4 Page number of next overflow page * Data -
溢出到溢出页的负载大小也取决于页类型,算法如下:
-
U
为数据库页面的可用大小, 即: 总页大小减去每页末尾的保留空间(也就是文件头第20字节表示的大小)。 -
P
为负载大小 -
X
最大值
: 表示可以直接存储在当前页(B-tree)上而不会溢出到溢出页上的的最大负载数 -
M
最小值
: 表示在允许溢出之前必须存储在当前页上的最小有效负载数 -
K
理想值
: 表示在当前页上存储多少数据后, 剩下的溢出页刚好容纳所有数据而不造成溢出页上出现剩余空间 -
X
取值算法(根据页面类型决定):X = U-35
0xd(Table Leaf Page)X = ((U-12)*64/255)-23
0xa, 0x2(Index Page)
-
M = ((U-12)*32/255)-23
-
K = M+((P-M)%(U-4))
-
如果
P <= X
:
则整个负载存储在此页面上, 不会产生溢出。 -
如果
P > X && K <= X
:
则存储在此页上的字节数为K
。 -
如果
P > X && K > X
:
则存储在此页上的字节数为M
, 页上存储的字节数永远不少于M
。 -
常数说明:
(U-4)
溢出页除去页头之后的可用空间(U-12)
B-tree页除去页头之后的可用空间(32/255)
SQLite开发者预设的一个比值,具体考量不做探究。(64/255)
SQLite开发者预设的一个比值,具体考量不做探究。(23)
SQLite开发者预设的一个常数, 大概是为了预留一个缓冲空间。(35)
B-tree页页头 +(23)
。
-
参考代码
C语言
// https://sqlite.org/src/file/ext/misc/dbdata.c // dbdataNext() /* Figure out how much data to read from the local page */ U = pCsr->nPage; if( bHasRowid ){ X = U-35; }else{ X = ((U-12)*64/255)-23; } if( nPayload<=X ){ nLocal = nPayload; }else{ int M, K; M = ((U-12)*32/255)-23; K = M+((nPayload-M)%(U-4)); if( K<=X ){ nLocal = K; }else{ nLocal = M; } }
-
空闲页(freelist page)
- 空闲页有两种子类型:
- 主干页(Trunk Pages)
- 叶子页(Leaf Pages)
- 文件头指向了第一个
trunk
页链表, 每个trunk
页指向了多个leaf
页. - 叶子页的结构是未指定的
- 主干页结构如下:
SIZE DESCRIPTION 4 Page number of next trunk page 4 Number of leaf pointers on this page * zero or more pages numbers of leaves
Ptrmap页(Pointer Map or Ptrmap Pages)
- SQLite FileHead 第52字节为零, 则没有Ptrmap页, 否则必须存在.
- 数据库如果包含Ptrmap页, 则第一个Ptrmap页是Page 2.
- Ptrmap页由元素长度为5字节的数组构成, 设 数组长度为
J
, 页可用空间为U
, 则J=U/5
. - 第一个Ptrmap页将包含从第3页到第
J+2
页的反向指针信息. - 第二个Ptrmap页将在页
J+3
上,该页将提供从页J+4
到页2*J+3
的反向指针信息。 - 数据库如果包含Ptrmap页, 由上面计算确定的页码上的所有页都必须是Ptrmap页(换句话说这个坑只能蹲Ptrmap页)。但是,如果byte-lock页恰好在此页码上,对于这种情况,Ptrmap将移动到下一个页面(byte-lock页优先级更高)。
- 5字节的数组元素格式如下:
SIZE DESCRIPTION 1 page type 4 page number(big-endian) - 页类型如下:
- 0x1 A b-tree root page. The page number should be zero.
- 0x2 A freelist page. The page number should be zero.
- 0x3 The first page of a cell payload overflow chain. The page number is the b-tree page that contains the cell whose content has overflowed.
- 0x4 A page in an overflow chain other than the first page. The page number is the prior page of the overflow chain.
- 0x5 A non-root b-tree page. The page number is the parent b-tree page.
SQLite FileHead 第52字节偏移量
The page number of the largest root b-tree page when in auto-vacuum or incremental-vacuum modes, or zero otherwise.
Lock-Byte页
- 所有现代操作系统都支持查询文件锁定,因此实际上不再需要锁字节页,只是为了向后兼容而保留。
- lock-byte是一个单独的页,其位于数据库文件的
1073741824 ~ 1073742335
,即1G的位置,占512个字节;小于1G的数据库文件则没有此页。 - lock-byte被预留出来,供特定于操作系统的VFS实现在实现数据库文件锁定原语时使用。SQLite不使用锁字节页。
- SQLite中内置的unix和win32 VFS实现不会写入锁字节页,但用于其他操作系统的第三方VFS实现可能会。
制作样本
$sqlite3.exe person.db
执行下面的SQL语句:
CREATE TABLE person(
id INTEGER PRIMARY KEY,
age INTEGER,
name TEXT,
note TEXT);
INSERT INTO person VALUES(1, 28, '赵四', Null);
INSERT INTO person VALUES(2, 96, '余老爷', Null);
INSERT INTO person VALUES(7, 27, 'This is deleted record 删除的记录 end', Null);
DELETE FROM person WHERE id = 7;
制作好的样本:
https://github.com/ZeroKwok/SQLite3DB-Samples.git注意:
sqlite3.exe 最好是使用自己编译的版本, 否则其他的发行版可能出现删除数据后写0的情况.
编译SQLite3.exe
- 下载tcltk并设置环境变量
- 下载: https://github.com/teclab-at/tcltk/releases/download/version-8.7a6.15/tcltk87-8.7a6.15.tcl87.Win10.x86_64.tgz
- 设置:
set PATH=%PATH%;G:\local\tcltk\bin
(在vs 命令提示符中)
- 克隆仓库
mkdir sqlite-rep && cd sqlite-rep
git clone https://github.com/sqlite/sqlite.git
cd sqlite
- 切换到发行版本(否则可能出现错误)
git switch major-release --detach
- 编译
cd ../sqlite-rep && makdir bld && cd bld
nmake /f ..\sqlite\Makefile.msc TOP=..\sqlite