最近一段时间查阅了一些SQLite3数据库的文件结构,在此集中记录一下。
首先,先介绍一个SQLite3数据库,我能够直观感受到的是单一磁盘文件,就是说SQLite数据库被存在文件系统的单一磁盘文件内(如果有日志文件的话,数据库映像就将存在两个文件中),只要有权限就可以随意的访问和拷贝,而其他的数据库引擎,基本都会将数据库存放在一个磁盘目录下,然后由该目录下的一组文件构成该数据库的数据文件。(以上参考《SQLite 学习手册》)。
下面进入正题,来讲讲SQLite3数据库文件结构。
SQLite3数据库总体结构
SQLite3数据库表的B+树结构
SQLite数据库页面结构
注:以上三张图来自朱清华的论文《基于Android手机SQLite的取证系统设计实现》
SQLite3数据库头结构
用winhex打开一个数据库文件(数据库名.db),前100个字节就是数据库的头结构,这是固定的。如下图:
这100个字节所代表的含义是:
起始地址 | 终止地址 | 含义 | 备注 |
0 | 15 | 头字符串 | 一般都是SQLite format 3 |
16 | 17 | 页大小 | 表示数据库的页大小,上图为0x1000,也就是4096个字节 |
18 | 18 | 文件格式版本写 | 一般是0x01 |
19 | 19 | 文件格式版本读 | 也是0x01 |
20 | 20 | 每页尾部保留空间大小 | 默认是0 |
21 | 21 | btree内部页单元最多能用的空间 | 0x40,也就是25% |
22 | 22 | btree内部页单元最少使用空间 | 0x20,也就是12.5% |
23 | 23 | btree叶子页单元最少使用空间 | 0x20,12.5% |
24 | 27 | 文件修改次数 | 该值由事务增加 |
28 | 31 | 数据库占据的总页数 | |
32 | 35 | 空闲页链表头指针 |
我觉得对于在auto-vacuum数据库, 由于空闲页只要出现一个空闲页就将这个空间归还给操作系统,因此,他的空闲页链表头指针一直为0,我查看了一个开启auto-vacuum选项的数据库发现这个值都为0 |
36 | 39 | 空闲页数量 | 这个也同样都为0 |
40 | 44 | schema版本号 | |
44 | 47 | 值为1-4之间 | |
48 | 51 | 默认页缓存大小 | |
52 | 55 | b-tree最大根页号 | 当创建数据库的时候启动auto-vacuum功能时,此处的值表示b-tree最大的根页号,没有启用该功能时,此处值为0 |
56 | 59 | 编码方式 |
1对应utf-8,2对应utf-16le,3对应utf-16be |
60 | 63 | 用户版本号 | 此处的值由用户使用pragma读取或设置 |
64 | 67 | 是否启用incremental-vacuum | 对于auto-vacuum数据库,当为incremental-vacuum时为1 |
68 | 71 | 用户应用程序ID | 由pragma application_id设置的应用ID |
72 | 91 | 保留空间 | 为扩展空间预留 |
92 | 95 | 有效版本 | |
96 | 99 | SQLite数据库版本号 |
SQLite3数据库页头结构
数据库头信息存储在根页中,紧接着数据库头的是页头信息,页头可以是中间页页可以是叶子页,当数据库很小的时候,数据库头结束后就是页头,也就是说根页就是叶子页。
上图中蓝色部分就是一个页头信息,页头的结构如下:
起始地址 | 结束地址 | 含义 | 备注 |
0 | 0 | 该页类型 |
0x0d表示b+tree叶子页,0x05是b+tree内部页,0x0a是b-tree叶子页,0x02是b-tree内部页 |
1 | 2 | 第一个*块的偏移量 | 指的是第一个*块的偏移地址,这个地址是相对于该页的页首而言的,因此在数据库中查找的时候需要换算成绝对偏移量,也就是加上该页页头的偏移量 |
3 | 4 | 本页单元数 | |
5 | 6 | 单元内容起始地址 | 这个起始地址是数据库存储的最新的那条记录的地址 |
7 | 7 | 空闲块数 | 指的是空闲块大小小于3个字节的数目 |
8 | 11 | 最右孩子页号 | 只有内部页有这一属性,叶子页是没有的 |
12 | 12+本页单元数*2 | 该页中每个单元的起始地址 | 这个地址也是一个相对量,实际使用的时候需要换算 |
将上面的表格和页头信息相对应可以发现,数据库头结束后紧跟的是一个b+tree内部页页头,该页没有*块,共有5个单元,单元内容的起始地址是0x0FE7,空闲块数为0。最右孩子页号为0x00001B,接下来10个字节分别代表该页五个单元的起始偏移。在0x0FE7后面可以看到还有其他的信息,这些信息就是被部分覆盖前的原始信息。
接下来看一个b+tree叶子页的详细情况:
可以发现上面该页的页头标志是0x0D,也就是叶子页,并且该页有被删除的数据,他的第一个*块的起始地址是0x0729,该页有14个单元,单元内容起始地址是0x0122,空闲块数是0,该页每一最右孩子页号这一个区域。
SQLite3数据库Sql_master表结构
同样的Sqli_master表页存储在根页中。Sql_master表是系统表,它里面存储着各个表的建表SQL语句,下面是Sql_master表的详细结构:
type | name | tal_name | rootpage | sql |
text字段,系统表的创建类型,有table,index,trigger,view四种类型 | text字段,表、索引、触发器、视图的名字 | 对表和视图来说,这个值和name字段一致,对索引和触发器来说是建立在那个表上的表名字 | 对表和索引来说是根页的页号,至今看到的根页号都是用一个字节表示的 | text字段,创建表、索引、触发器或视图所使用的sql语句 |
接下来我们看一个具体的单元内容以及一个单元删除前后的页头以及单元头记录头的变化。
SQLite3数据库单元结构
记录大小 | RowID | Payload | overflow |
该字段是一个1到9个字节的变长整数,也就是记录内容部分的大小 |
该单元的记录在表中的行ID数,同样用1-9个字节的变长整数表示 | 记录内容部分 | 溢出页链表的第一个指针,没有溢出页的时候就没有这个域,给字段为4个字节 |
SQLite3记录内容部分的结构是:
记录头 | type | Payload |
一个可变长整数,表示记录头加上type的个数 | 记录中每一个字段的类型描述,描述本身包含了字段的类型和长度(字节数) | 记录真正的内容部分 |
type的类型具体有一下几种,计算字段的长度方法也附在表中:
type的值 | 表示的字段类型 | 长度(占据字节数) |
0 | NULL | 0 |
X(X∈{1,2,3,4}) | 有符号整数 | X |
5 | 有符号整数 | 6 |
6 | 有符号整数 | 8 |
7 | IEEE float | 8 |
8 | 有符号整数 | 0 |
9 | 有符号整数 | 0 |
X(X∈{X>12,且X%2==0}) | BLOB | (X-12)/2 |
X(X∈{X>13,且X%2==1}) | TEXT | (X-13)/2 |
SQLite3数据库单眼数据删除前后变化情况
下面看一个具体的单元在删除前后的对比情况:
短信数据库的一条短信被删除前的单元数据信息为:
可以看到该单元的记录内容大小为0x36,也就是54个字节,该单元的记录在短信表的第5行,记录头的大小为0x1c,也就是28个字节,也就是说type的字节数为27个字节,第一个type值为0x00,也就是0;第二个type类型为0x02,是一个占据两个字节的有符号整数,与该符号相对应的值为0x06C2,下一个type值为0x17,是大于13的奇数,因此他表示一个长度为(23-13)/2=5的text文本,该字段对应的值为10086;一次类推,可以将整个字段所代表的含义解析出来,可以解析出来短信的内容为:text3。
下面来看一看删除该短信后,该单元以及单元所在页的页头改变:
与上面的数据进行对比我们可以发现,在该单元变成*块之后只有前四个字节发生了变化,这是变化最少的情况,因为该数据比较少,单元头、RowID、记录头的大小都只用了一个字节就可以表示。
改变的前四个字节所代表的含义是:
前两个字节表示下一个*块的起始地址;
后两个字节表示该*块的大小,可以发现*块的大小值比删除前单元的记录内容大小值多了2,也就是说*块的大小值记录的是删除前整个单元的大小。
下面看看页头的变化:
删除前该页页头的情况是:
删除后该页的情况是:
可以看到删除后的第一个*块起始地址不在是0,而是被删除的那天短信所在单元的起始地址0X09E7,该页的单元数从原来的7个变成了5个(变成5个是因为我在删除过程中手误删除了两条短信)。除此之外我们也能够发现,在记录该页各单元起始地址的区域结束后,后面的位置存储的数据是一致的,这也是一种部分覆盖的情况。
变长型整数
SQLite数据库中有很多的整数是设定为变长整数类型的,在这里也记录一下变长整数的规则。
变长整数是8个bit为一组(也就是一个字节),最高位是判断为,当最高位为0时表示当前字节为该整数的最后一个字节,当最高位为1时表示后面的一个字节也表示这个整数。用一个例子来说明:
eg:0x81 95 E3 21
上面的整数第一个字节的最高位为1,继续向后读一个字节;第二个字节最高位也为1,继续向后读一个字节,第三个字节最高位也为1,继续向后读一个字节;第四个字节最高位为0,停止。
接下来就是将变长整数转为定长整数,转化的过程就是去符号位的过程。
首先将16进制的变长整数转为2进制:
1000 0001 1001 0101 1110 0011 0010 0001
去掉符号位后变成:
000 0001 001 0101 110 0011 010 0001
从低位像高位四个一组重新组成16进制数:
0x25 71 A1
从变长转定长的过程就结束了。
结束语
这篇博客参考了很多资料,主要的参考资料有:
1.《SQLite数据库文件格式全面分析》——空转,这篇文章对SQLite3数据库文件结构分析的很详细,但是其中有一个有歧义的地方是,作者在2.3节介绍大文件内部页单元结构的时候忽略了最右孩子页号后面结根的是该页各个单元起始地址这一情况,将各单元起始地址也归为未分配空间部分了。
2.《基于Android手机SQLite的取证系统设计实现》——朱清华
3.http://www.runoob.com/sqlite/sqlite-java.html 该链接处讲了SQLite数据库的基本应用知识,包括与C/C++和Java的接口使用。