SQLite源码分析5 文件格式相关#
原文见 https://sqlite.org/fileformat2.html
描述了sqlite的各种数据结构、b树相关的存储等。
挑了重点做了翻译。需要先学会b树,最好了解分页、存储管理相关的概念。
数据库文件#
通常sqlite的一个数据库存在一个文件中,称为”main database file”。
在一个事务过程中,会在另一个文件中存备份信息,称为”rollback journal”回滚日志。
如果在一个事务执行中发生灾害,回滚日志可以用来把数据恢复到事务开始前的状态。这样保证数据始终处在一个稳定的状态,不会被破坏。
Pages(分页)#
数据库文件由一个或多个页组成。页大小范围是512-65536字节。
同个文件中的页的大小一致。页编号从1开始。最大为4294967294(2^32 - 2)。
最小的数据库文件只包含一个512字节的页。
最大的文件可包含2147483646个页,每页65536字节,一共281474976579584字节(256T)。
一般情况下磁盘会先满。不太需要考虑这个文件大小限制。实际应用中sqlite数据库一般大小为kb到几个G。虽然TB级别的应用也是存在的。
在某一时刻,一个页可作下列用途之一:
lock-byte
freelist
freelist trunk
freelist leaf
b-tree
表b树内部节点页
表b树叶子节点页
索引b树内部节点页
索引b树叶子节点页
payload overflow
pointer map
所有对于主数据库文件的读写都以页为界,所有写的字节长度都是页的整数倍。
读也一样,除了一个特例。当数据库被打开时,读前100字节(数据库文件头)。在存有有用信息的页被修改之前,页修改之前的数据会被写到回滚日志。
freelist的叶子页,由于不包含可用于回滚的信息,所以不需要写到回滚日志,以减少磁盘io。
数据库头信息#
数据库文件前100字节包含各种元信息。
关注一部分:
offset |
size |
描述 |
---|---|---|
0 |
16 |
写死的ascii字符串SQLite format 3 |
16 |
2 |
页的大小。我的文件是0x1000即4k字节。 |
20 |
1 |
每个页尾部的备用字节。可作一些扩展应用。比如存加密信息。 |
24 |
4 |
文件修改计数。文件每次被修改后释放锁时+1。如果有多个进程同时在读文件,可以用这个计数来检测文件是否更改。 |
32 |
4 |
第一个空闲页的编号 |
36 |
4 |
所有空闲页数量。 |
44 |
4 |
schema格式 |
48 |
4 |
默认页缓存大小。 |
56 |
4 |
字符串编码。1-utf8,2-utf16le,3-utf16be。 |
72 |
20 |
20字节备用空间。目前必须为0。 |
lock-byte页#
数据库文件中的某一页,给操作系统做特殊用途。为了兼容老旧的操作系统。
freelist#
数据库文件可能包含空闲的页。当删除某些信息时就可能出现空闲的页。
freelist存空闲的页的链表。
b树页#
b树提供基于页的有序key的kv存储。具体算法可见之前的blog。
sqlite使用两种b树实体:
表-b树。使用64bit有符号整数作为key,所有数据存在叶子节点。
索引-b树。使用任意类型的key,不存任何数据。一个b树页只可能是“内部”页和“叶子”页。
叶子页包含key,如果是表b树,每个key还包含数据。
内部页包含k个key和k+1个指针,指向下一层的子页。
k大于等于2,一般情况远大于2,可能多达上千。sqlite的表分为有rowid和无rowid。默认是有rowid表。我们只看有rowid的。
数据库每个表有一棵表b树。
每个索引都有一棵索引b树。
虚表没有b树。表b树的每个数据包含一个64位有符号整数和最大2147483647(2048M)字节的任意数据。
内部节点只包含key,不包含数据。数据都存在叶子节点。索引b树的每个数据包含最大2147483647字节的key,不包含数据。
每个key和它左边的指针一起称为一个cell。
一个b树页的数据布局:
100字节头数据(只对于第一页)
8或12字节页头。
cell队列
未分配的空间
cell内容
备用空间
页头格式:
offset |
size |
描述 |
---|---|---|
0 |
1 |
b树页的类型。0x2 索引b树内部节点页。0x5 表b树内部节点页。0xa 索引b树叶子页。0xd 表b树叶子页 |
1 |
2 |
页中的第一个空闲block号。0表示没有空闲的block。 |
3 |
2 |
cell的数量 |
5 |
2 |
cell内容的起始位置 |
7 |
1 |
cell内容中的空闲字节数 |
8 |
4 |
节点最右端的指针。因为每个cell包含的是左边的指针,最右边的指针一定会落单。 |
cell队列紧排在页头后面,按key升序排序。
页中会存在断续的可用空间,sqlite会时不时做类似“除碎片”的操作,把可用空间做成连续。来节省空间和提高效率。
变长整数(variable-length integer)(varint)#
https://sqlite.org/src4/doc/trunk/www/varint.wiki
用1到9个字节来表示一个64bit有符号整数。
较小的值占用较少的字节数。
一个varint的长度存在第一个字节中。
字典顺序和数字顺序一致。如果按数字顺序排序了,那么在字符串层面也是有序的。b树中数据类型的总结:
数据类型 |
位置:表叶子节点 |
表内部节点 |
索引叶子节点 |
索引内部节点 |
描述 |
---|---|---|---|---|---|
4字节int |
✔ |
✔ |
左子节点的页号 |
||
varint |
✔ |
✔ |
✔ |
payload的字节数 |
|
varint |
✔ |
✔ |
rowid |
||
字节array |
✔ |
✔ |
✔ |
payload |
|
4字节int |
✔ |
✔ |
✔ |
第一个overflow页的页号 |
cell payload overflow#
一个b树节点只分配1页空间,如果节点包含的数据超出限额,多出的数据就得存到overflow页。
overflow页以链表的形式存,每页前4字节表示下一页的页号,其他是实际数据。
schema层#
上述内容描述了b树底层的结构。下面讲sqlite如何用b树实现sql层面的操作,即增删查改等。
数据库记录的格式#
表b树的叶子数据和索引b树的数据可以是任意格式数据。
所有数据都符合“记录格式”。记录格式描述列的数量,每个列的数据类型,列数据的格式。
一条记录包含头和体。头以一个varint开始,表示头的总字节数。
接着是一个或多个varint,称为serial type。每列对应一个,表示列的类型。
serial type |
实际数据大小 |
描述 |
---|---|---|
0 |
0 |
null |
1 |
1 |
8位int |
2 |
2 |
16位int |
3 |
3 |
24位int |
4 |
4 |
32位int |
5 |
6 |
48位int |
6 |
8 |
64位int |
7 |
8 |
64位浮点数 |
8 |
0 |
整数0 |
9 |
0 |
整数1 |
10,11 |
不定长 |
内部备用 |
大于等于12的偶数N |
(N-12)/2 |
blob类型 |
大于等于13的奇数N |
(N-13)/2 |
string类型。结尾的null不存。 |
接着是每列的值。对于类型0,8,9,12,13,不需存值。
排序#
排序优先级
null值优先。serial type 0
数字(serial type 1-9)。在null之后,按数字排序。
字符串。按列的collating function排序。
blob。按memcmp()排序。
collating function定义如何比较两个字符。
sqlite定义3种内置的函数:
BINARY
所有字节依次用memcmp()比较
NOCASE
与BINARY基本一致。如果有ascii字符,会把大写英文字母先转为小写,再用memcmp()比较。
RTRIM
右侧trim。与BINARY基本一致。字符串右边的空格不影响排序。两个字符串右侧空格数不一样,但是之前的字符都一样的话,算是相等。可以添加自定义的collating function。
sql表的表示#
表一般用表b树来表示。
每个节点数据对应一行数据库记录。rowid作为64位的整数key存在每个节点数据里。
每条记录把所有列的数据按上述的记录格式拼接再存到b树节点。
sql索引的表示#
每个索引都对应一棵索引b树。
里面的每个节点对应一条实际数据表里的记录。
索引b树的每个数据的组成是:索引包含的所有列加上对应的数据表记录的rowid。
对于一般表,key是rowid,对于无rowid的表,key是主键。
sql数据库schema的存储#
sqlite_schema是sqlite内置的表。存各种结构定义信息。比如表、索引、trigger等等。
CREATE TABLE sqlite_schema(
type text,
name text,
tbl_name text,
rootpage integer,
sql textxt
);
回滚日志#
回滚日志和数据库文件存在同个目录。
一个数据库只能有一个回滚日志。所以一个数据库同时只能存在一个写事务。
当事务中途发生灾害时,数据库处于一个异常状态。 下次sqlite尝试打开时,回滚日志将会被检测到,并进行回滚操作,恢复到事务开始前的状态。
只有当一个回滚日志的header有效时才能进行回滚。 那么有三个情况可以使日志失效,也就是标志事务已经成功,即commit成功。
删除回滚日志
truncate回滚日志到0。
把header写坏,比如写成全0。
header的格式
offset |
size |
描述 |
---|---|---|
0 |
8 |
写死0xd9, 0xd5, 0x05, 0xf9, 0x20, 0xa1, 0x63, 0xd7 |
8 |
4 |
下一块日志数据的页数。-1表示已经到了末尾。 |
12 |
4 |
校验和的随机数 |
16 |
4 |
数据库初始的页数 |
20 |
4 |
对应的磁盘sector扇区的字节数? |
24 |
4 |
本日志的页数 |
header需要按sector大小填充0。
header存在于一个扇区内,这样如果发生断电等,信息不会损坏。接着是页数据。每个将要改变的数据页都会存下来。同个页最多存一次。
回滚的操作其实很简单,就是依次读日志里的页数据,写回数据库文件即可。日志数据页的格式
offset |
size |
描述 |
---|---|---|
0 |
4 |
页编号 |
4 |
N |
事务开始前的数据 |
N+4 |
4 |
校验和 |