当前位置:首页 > 问答 > 正文

SQLite数据库文件格式那些事儿,开发中你可能没注意但超重要

主要参考自SQLite官方文档《SQLite Database File Format》以及多位开发者在社区如Stack Overflow上的实践经验分享)

SQLite数据库文件格式那些事儿,开发中你可能没注意但超重要

说到SQLite,咱们开发的人太熟悉了,就是个轻量级的数据库,一个文件就搞定,用起来贼方便,但正因为太方便了,很多人就直接SQLiteOpenHelper一把梭,或者执行个CREATE TABLE就完事了,很少去关心那个.db或者.sqlite文件里面到底是个什么结构,其实了解一些文件格式的“内幕”,关键时刻能帮你省下不少头发,避免一些莫名其妙的坑。

你得知道,SQLite数据库文件不是一个随便写的文本文件或者一堆杂乱数据的堆砌,它是一个精心设计的、有固定结构的“微型文件系统”,根据SQLite官方文档的说法,每个SQLite数据库文件都被划分成固定大小的“页”,这个页大小不是固定的,可以是512字节到65536字节之间的2的幂次方,默认是4096字节,这个大小在你创建数据库的时候就确定了,之后基本不能改(除非你用非常规手段做整个数据库的真空整理VACUUM),这个页是I/O操作的基本单位,也就是说,SQLite读写磁盘,最少也是读写一页。

SQLite数据库文件格式那些事儿,开发中你可能没注意但超重要

文件头是数据库的“身份证”和“总目录”,它就固定在前100个字节里,你可别小看这100个字节,它包含了魔数(用来标识这是个SQLite数据库文件,就是那个“SQLite format 3”的字符串)、页大小、文件格式的版本号、数据库最后一次修改的页数、数据库的文本编码(是UTF-8还是UTF-16)、还有那个至关重要的“应用ID”等等,有时候你拿到一个数据库文件,想快速看看它的一些基本信息,用十六进制编辑器打开,瞄一眼文件头就能知道个大概。

数据库里所有的表、索引、触发器等等,在SQLite里都被抽象成一种叫做“B-tree”的数据结构来存储,B-tree是一种能高效查询和排序的树状结构,存储表实际数据的B-tree叫“表B-tree”,每个表的行数据(rowid除外)都作为一个负载(Payload)存放在叶子页上,而索引则存放在另一种“索引B-tree”里,这个B-tree结构保证了数据的有序性和查询效率。

但这里有个超级重要的细节,很多开发者会忽略,那就是“rowid”或者说“rowid”,根据SQLite官方文档的说明,如果你在创建表的时候,没有用INTEGER PRIMARY KEY来声明主键,那么SQLite会偷偷地给你加一个叫rowid的隐藏列作为内部主键,这个rowid是一个64位的有符号整数,它决定了你的数据行在表B-tree中的物理存储顺序,通常情况下,rowid是单调递增的,但关键在于,如果你显式地用INTEGER PRIMARY KEY来定义主键,那么这个主键列就直接成为了rowid的别名,这样做的好处是,用这个主键去查询会非常快,因为它直接对应了数据的物理位置,反之,如果你用其他类型(比如TEXT)或者多个列作为主键,SQLite会在背后创建一个隐藏的索引B-tree来维护这个主键,然后再通过rowid去查找实际数据,多了一次查找过程,在性能要求极高的场景下,这个差异就会显现出来,如果可能,尽量用INTEGER PRIMARY KEY作为主键。

SQLite数据库文件格式那些事儿,开发中你可能没注意但超重要

另一个容易出问题的地方是“空闲页列表”,当你删除大量数据后,这些数据占用的页并不会立刻把空间还给操作系统,而是被SQLite标记为空闲,加入一个“空闲页列表”,等着下次插入数据时复用,这样做是为了提高插入性能,避免频繁分配空间,结果就是,数据库文件可能看起来非常大,但实际有效数据没多少,这就是为什么你删了数据,文件大小却不变的原因,如果你需要释放磁盘空间,就需要手动执行VACUUM命令,这个命令会重建整个数据库文件,抛弃所有空闲页,从而缩小文件体积,但要注意,VACUUM在执行过程中需要大约两倍原文件大小的额外磁盘空间,并且会占用数据库锁,在移动设备等存储紧张或高并发场景下要谨慎使用。

还有事务的原子性,也跟文件格式紧密相关,SQLite保证事务是原子的,即要么全部完成,要么全部不生效,这个魔法是怎么实现的呢?它靠的是一个叫“回滚日志”或者“预写日志(WAL)”的机制,在默认的回滚日志模式下,当你开始一个事务修改数据时,SQLite并不会直接去改数据库文件本身,而是先把要修改的页的原始内容复制到回滚日志文件里,如果事务成功提交,这个日志文件会被删除;如果事务失败回滚,就用日志文件里的内容把数据库恢复原样,这意味着,如果一个事务在执行过程中(比如正在写入大量数据)程序崩溃或者断电了,你下次打开数据库时,SQLite会发现这个未完成的日志文件,然后自动进行回滚操作,确保数据库不会处于一个半成品状态,了解这个机制,你就明白为什么突然断电后你的数据库通常还是完好的,也就能理解为什么要把事务拆小,避免长时间运行大事务(因为回滚日志会一直增长)。

最后提一下数据类型,SQLite以“动态类型系统”闻名,你可以把任何类型的数据存入任何列(除INTEGER PRIMARY KEY外),但这绝不意味着你可以随意存,类型亲和性(Type Affinity)这个概念很重要,你声明列时为它指定的类型(如INTEGER, TEXT, REAL等)会给出一个“建议”,SQLite会优先尝试把存入的数据转换成该类型,如果你不注意,把字符串存到了声明为INTEGER的列里,虽然当下可能不会报错,但排序、比较时可能会产生意想不到的结果,严重影响查询正确性和性能,养成良好的习惯,严格按照声明的类型来存数据,能避免很多潜在的坑。

SQLite的文件格式设计得非常精巧和健壮,我们不需要像数据库内核开发者那样精通每一个字节的含义,但了解这些基本概念和关键点,就像开车要知道油箱和刹车在哪一样,能让你在开发中更得心应手,更能应对那些诡异的问题,写出更高效、更稳定的代码。