sqlite的后端系统设计原理
Posted 58商业技术团队
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了sqlite的后端系统设计原理相关的知识,希望对你有一定的参考价值。
引言
数据库是现代系统设计中必不可少的软件服务。为数据的高性能持久化存储、备份以及快速检索等提供支持。那么数据库是如何实现数据的持久化存储又是以何种方式把数据组织成表、视图、索引等形式呢?怎样实现数据的内存缓存以及快速检索支持呢?传统数据库的通用设计架构应该是怎样的呢?本文将以一个轻量级的数据库sqlite为例来解答上述问题。
sqlite是一个被广泛使用的数据库管理系统(DBMS),在android、ios等操作系统以及chrome、firefox、qq客户端等软件中都有重要应用。与mysql、sqlserver 等传统的大型数据库相比,sqlite主要有以下特点[1]:
∙ 零配置
sqlite的一个数据库本身就是一个文件,不需要任何额外的配置信息
∙ 嵌入式
sqlite支持当前各种主流的服务器、pc、手机以及嵌入式设备。可以很方便嵌入各种操作系统
∙ 多语言绑定
sqlite提供了主流语言(如C、C++、Java、C#)的sdk,有些语言(如python、php)也自带了sqlite扩展。
∙ 事务支持和线程安全
sqlite提供了基本的事务支持机制,支持flat事务(非嵌套),并且sqlite本身的api是线程安全的,多线程并发访问不会导致不可预测的结果。
本文将介绍sqlite的主要功能(如表、索引、缓存、事务)的后端实现原理,并基于实现原理来与mysql、sqlserver等大型数据库对比,对sqlite适合的业务场景进行简单介绍。
Figure 1: sqlite架构
sqlite整体架构
sqlite是一个功能相对完整的数据库系统。一个传统的数据库系统大体都可以划分成以下几部分:connector pool(网络层,提供CS模式的数据库访问支持);sql 解析&优化器;数据管理&缓存;存储引擎&虚拟机;文件系统抽象层(OS层,屏蔽各种不同操作系统的API差异)。sqlite除了没有网络层的支持外,整体架构也基于以上的架构。整体上sqlite的功能系统被划分成两部分,即frontend(前端)和backend(后端)。前端实现sql的语法解析,生成sqlite虚拟机支持的中间代码;后端实现sqlite的文件结构、数据组织、存储缓存管理、VDBE虚拟机等功能。整个sqlite的架构图如图1所示。
sqlite前端包括词法分析器、语法分析器与中间代码生成器三部分。前端结构跟现代程序设计语言的前端很类似,只不过前者的语法相对来说简单很多。sqlite 的后端由B–Tree(表与索引数据组织结构)、Pager(页管理)、在OS Interface上抽象出的虚拟文件系统以及VDBE虚拟机几部分组成。前端主要功能是解析sql语句生成对应的中间码;而后端则把数据库相关的表、索引等数据结构用B–Tree、B+Tree等实现,并通过pager实现事务管理、存储缓存、文件组织等功能。一条简单的sql查询语句,如”select * from t1”,在sqlite返回结果前会经历以下几个过程:
∙ 词法分析
把原始的语句分隔成一个个可识别的词法单词,如select最终被识别成¡operator select
∙ 语法分析
将识别出的词法匹配一个具体的语法模式,这里匹配select from语法,生成抽象语法树(AST)
∙ 中间代码生成
根据抽象语法树生成虚拟机中间代码,上述语句生成的中间代码参见图10
∙ 执行WDBE虚拟机
虚拟机加载生成的中间代码,将指令解释成B–树、B+树的相关操作
∙ B–树、B+树读写
将树的节点对应到具体的page,然后树的增删改查操作会转成通
过pager模块实现的对page的读写操作
∙ pager管理页数据
pager将磁盘中的页数据加载到内存,缓存已经加载到内存的页,并负责把已经写脏的页写会到磁盘
∙ os interface提供一个虚拟的文件系统
pager在虚拟的文件系统上实现对文件的读写、枷锁操作,来实现基本的事务,日志等功能。os interface屏蔽了操作系统层面不同的api,对上层提供一个统一虚拟文件系统。
本文将根据上述顺序依次介绍sqlite后端系统模块的实现原理。
sqlite的页面管理
sqlite的一个数据库内所有数据都存储在单一的文件中[2]。”页”(Page)是sqlite文件系统划分的最小读写单元。一个sqlite数据库是由若干个页组合在一起形成的一个物理文件。页的大小在sqlite编译的时候是可指定的,范围是512B–32KB(默认1KB)。sqlite文件的页都是顺序存储的,页码编
Figure 2: pager架构
号从1开始的,0页表示null,sqlite内部均以页码编号来访问页。第N页在sqlite文件中的offset可以用如下的公式计算:
O(N) = (N-1) * S (1)
其中N表示页码,O(N)表示第N页在sqlite数据库文件的起始Offset,S表示页大小(PageSize)。
sqlie内部用pager模块实现整个页管理功能。pager模块介于B+树和OS层之间,提供基于页的数据存储&缓存、锁管理以及日志管理功能。其中锁管理与日志管理结合实现了sqlite的事务支持,而页的存储缓存则提供了统一的读写服务,屏蔽上层调用对磁盘与内存的感知。pager在sqlite后端中所在位置与主要功能如图2所示[3]。
3.1 page类型
sqlite的页在实际应用中被划分为leaf、internal、overflow及free四种类型的逻辑页。leaf页及internal页存储的是B+树的叶子节点和内部节点。当某些表的元素过大时,一个页节点无法存储所有的数据导致overflow(存储数据溢出),溢出的数据便会写入到溢出节点中。此时原始的页中只存储部分数据信息并用4字节表示overflow节点编号,溢出部分数据写入编号
Figure 3: sqlite文件头
Figure 4: freelist结构
表示的溢出页中。free页即当前尚未被占用的页,在原始的数据被删除后,sqlite会把页设置成free页,而不会直接释放该页节点占用的存储空间。
3.2 sqlite文件头部
sqlite用第1页来作为整个sqlite数据库文件的头部,第1页保存了整个数据库文件的meta信息。如页大小、读写文件方式、文件更新计数、free page的管理等等。完整的sqlite文件头说明如图3所示。 sqlite的文件头部还有若干保留空间,有特定需求的用户可以利用这些尚未被占用的空间实现某些sqlite的定制化需求,如加密、压缩、数据校验等等。
sqlite使用链表来管理free page。free page包括trunk page和leaf page两种。trunk page中存储的是下一个trunk page的指针及此trunk page包含的多个空闲的leaf page页编号,不同的trunk page之间通过单项链表关联起来。sqlite的文件头用4字节指向第一个trunk page。freelist的结构如图4所示。
Figure 5: sqlite缓存
3.3 sqlite缓存
缓存是影响数据库性能的重要一部分。一个好的缓存设计可以提高内存的命中率、减少磁盘的IO次数。sqlite的缓存管理功能是在pager模块中实现的。内存中的一个pager模块对应了磁盘中的一个sqlite数据库文件。如图5所示,pager模块的数据结构主要包括以下几部分:一个数组实现的哈希表保存所有加载到存储中的数据库页节点。每个数组的元素指向的是一个PgHdr(Page Handler)类型的双向链表。每个存在内存中的页通过简单的哈希算法即可算出页所在数组中的位置。可以看出sqlite在内存中的页的组织方式很像java中hashmap的实现方式。PgHdr对象管理了数据库的每一页数据在内存中的映射。PgHdr通过四字节的pgno来表示当前对象管理的页编号。inJournal 表示此页内容是否已写入日志。needSync 表示数据写磁盘之前是否需要写日志,dirty 表示此页的数据是否已”脏”(即内存的数据已经被修改,与影片中的数据不一致),另外通过引用计数来(rc)表示此内存中的页是否在使用中,只有不在使用中的页在内存不足的时候才可以用来加在磁盘中的页数据。
对于内存中的free页sqlite同样用PgHdr来管理。Pager对象中用一个指针指向第一个PgHdr节点。所有可用的PgHdr节点用一个双向链表串联起来。如果一个PgHdr已经没有引用计数了,sqlite认为这个PgHdr也是空闲可被占用的,并将此对象加入可占用的PgHdr 双向链表。但是并不会从原始的哈希表中移除。需要注意的是,PgHdr只在Pager内部是可见的,并不会对外暴露。外部读写的数据结构还是Page。
3.4 页读写过程
sqlite提供sqlite3pager get api实现加载页的整个过程。所有被访问的页必须首先加载到内存,然后才能被上层的数据结构访问。访问某个sqlite页的整个过程可以分成以下几个步骤:
∙ 搜索已经存在内存中的页
sqlite首先根据页码计算出该页在pager缓存数组中的位置,然后遍历PgHdr链表,如果找到某个PgHdr的缓存页编号与给定编号相同,则命中缓存,直接返回page的内容
∙ 如果缓存查找失败,则需要把存在磁盘中的页加载到内存中
首先要找到一个未被占用的PgHdr,可以通过查找Pager对象的free
PgHdr 链表实现。如果存在free的PgHdr,直接占用。否则需要在内存中心分配一块PgHdr结构来存储磁盘中待加载到内存的页数据(内存足够的前提下)
∙ 如果还未找到可占用的PgHdr,则尝试占用已缓存在内存中的哈希数组中的PgHdr
通过某种淘汰算法来选择可占用的PgHdr。只能占用引用数为0的PgHdr。而且需要检查PgHdr的dirty标志,如果dirty为true,需要先把数据写入磁盘中的对应页,然后才能被占用
∙ 从硬盘中的对应页读取数据到分配好的PgHdr
如果请求的页节点比磁盘文件中的最大页数小,则读取磁盘中的对于页内容到PgHdr 中。否则直接把PgHdr中的Page数据清零,并设置PgHdr的引用计数,最后返回页在内存中的指针。
内存的读写性能远高于磁盘的读写性能,因此如何提高内存中的页缓存命中率是提高sqlite读写性能的重要途径。sqlite使用一种类似LRU(least recently used)的内存淘汰算法来提高缓存数据命中率。
sqlite提供sqlite3pager write api实现写page的操作。如同上一节提到的,第一次写入一个page页时,sqlite会首先把页的原始内容写入日志文件并设置inJournal和needSync标志。needSync标志会在日志文件写入磁盘后清除(WAL–write ahead logging)。每次写Page的时候对应PgHdr的dirty标志都会设置,但是何时把该页写回磁盘是由sqlite控制的,sqlite采用延迟写
入(delayed write)的策略来把”脏”的内存页回写到磁盘–即当显示的通过flush方式将页内容写回磁盘或者某个PgHdr被回收加载新的数据页的时候才将PgHdr的页数据写回磁盘。通过这种策略可以合并对同一个页的多次写操作,减少磁盘IO。
Figure 6: B+树
sqlite的表与索引
sqlite的Pager提供了对页一级的读写、锁、日志等操作。但是并没有实现任何数据库相关的概念(如表、索引、事务等)。sqlite的表是由B+树实现的。在sqlite数据库中,每个sqlite表都对应了唯一一个B+树。sqlite的索引与表类似,但由于索引只起到加速检索的作用,本身并不存储记录数据,因此sqlite选用B–树来实现索引,这样索引占用的内存空间相对更少,容易加载到内存中。
B–树和B+树都是多路平衡搜索树,关于二者的相关定义读者可自行查阅其他数据结构或者数据库相关的资料。本文主要介绍sqlie是如何利用二者实现数据库中的表及索引。以B+树为例,每个节点都对应了一个单独的Page页。每个B+树可以通过根节点所在Page页的页码唯一标识出来。如图6 所示。sqlite的每个表中的所有数据都存在于B+树的叶子节点上。B+树的内部节点存储的则是索引信息。(如果一个sqlite定义了自增的整数主键,那这个主键就是树的索引,否则sqlite会自己根据每条记录生成一个类似rowid的key作为索引)
sqlite的B+树种存在internal和leaf两种节点,对应的是internal和leaf两种类型的sqlite页。sqlite的不同页类型有不同的结构,页类型在sqlite的页头部用一个byte 标识。每个sqlite的页内部又被划分成多个cell,cell为page页内部读写的基本单位。页的数据组织如图7所示。页的顶部存储的是页头,页头中存储了页的类型(internal、leaf、overflow)、页中cell的数量、节点的右子节点(B+树的Ptr(N),当节点是leaf节点时忽略)等数据。页头下
面保存的是此页中所有的cell指针,每个cell指针用2字节来表示。cell的内容是在页中自底向上存储的,cell指针和cell内容中间部分为页的可用空间。由于每个页节点的大小有限(页大小默认1KB),因此如果需要存储的数据量太大,页节点中的数据会发生溢出(overflow),这时需要把溢
Figure 7: 页的结构
Figure 8: B+树实现的sqlite表
出的数据存在其他的页上,保存溢出数据的页被称为overflow页。原始页与overflow页可以通过原始页的cell中索记录的overflow页指针串联起来。
一个典型的sqlite生成的B+树如图8所示。叶子节点保存的是sqlite表中的所有元素。内部节点则包含了标的所有主键索引信息。由此可知,当我们对某个主键条件进行读写时,时间复杂度为:O(log2n)。同样,对于limit条件的范围检索,可以首先利用索引信息查找到第一个符合条件的节点,因为leaf节点是相互串联起来的,所以可以通过串联好的leaf节点检索到limit的范围内的所有数据。
对于除主键外的其他索引,sqlite用B树来组织。B树的内部节点和叶子节点都可以存储索引与数据。与存储表用的B+树相比,b树只需要存储索引
与对应record的主键信息,而不需要存储record的内容。
sqlite用了一种很特别的方式管理所有的表与索引信息。sqlite把所有的表与索引信息存储在一张系统预定义的表中,叫做sqlite master表。每个表的创建语句、表名和表对应的B+树信息均记录在此表中。同样,sqlite把
Figure 9: sqlite master表
所有的索引信息也存储在sqlite master表中。我们在Ubuntu linux上用sqlite 3.16.0版本验证了上述信息,如图9所示。而sqlite master表的root节点则固定使用第1个page来存储。通过B树和B+树两种数据结构,sqlite巧妙的实现了一个数据库的核心功能。
由于sqlite本身的事务实现是基于pager模块提供的锁机制。而pager模块不存在数据库的表、行、索引等逻辑概念,因此pager模块支持的读写事务相关的锁都是加在整个数据库上的。因此sqlite的读操作可以并行处理但写操作是串行执行的,所以sqlite不适合写并发量很大的场景,但大多数场景下(如桌面产品、中小型的企业系统服务)数据库的读操作是远大于写操作的,而且没有很高的写并发。在这种场景下(据sqlite创始人建议,日访问在100万以内的需求sqlite完全可以满足)选用sqlite而不是一个大型的数据库(如mysql、oracle等)可以使资源得到更有效的利用,加快系统的开发上线速度。因为sqlite对事物的支持有限(只支持简单的事务,不支持事务嵌套),如果有复杂事务的业务需求,那sqlite也不是一个很好的选择。
sqlite虚拟机VDBE
sql的后端最上层就是sqlite的虚拟机VDBE(virtual database engine)[4]。VDBE是sqlite前端和后端的桥梁,sqlite的核心运行过程都是在VDBE中实现的。一个VDBE对象包括以下数据:
∙ 一个由sql解析器生成的字节码
∙ 查询column的名称和数据类型
∙ 输入参数值
∙ 程序计数器
Figure 10: sqlite字节码程序
∙ 操作数执行堆栈
∙ 其他运行时的上下文信息(B–Tree等等)
VDBE接收sqlite前端生成的字节码,然后依次执行字节码。VDBE利用B+树和B–树提供的接口执行字节码的相关流程,并提供结果给前端。
5.1 sqlite字节码
sqlite自己定义了一种内部语言来翻译前端的sql查询。sql查询翻译后会生
成对应的字节码程序。字节码程序由一系列的字节码指令构成,标准的
字节码指令由以下格式组成:< opcode,P1,P2,P3>。其中opcode表示一
种确定的字节码操作符,而后面的P1,P2,P3等表示自己么指令的操作
数。每一个字节码指令都完成虚拟机的某个特定操作。P1表示32位有符号
Figure 11: sqlite虚拟机代码框架
Figure 12: record数据
操作数栈中。当虚拟机执行到halt指令或者执行到某个指令出现内部错误或者程序计数器指向最后一条指令的时候,虚拟机关闭。虚拟机关闭的时候会释放所有的内存资源、同时关闭所有的游标。当虚拟机是因错退出的时候,虚拟机会把所有的事务和子事务关闭,并恢复数据库的原始数据。
5.2 表记录结构
sqlite的表数据由多个record(记录,表中的行数据)组成。sqlite使用一种变长的记录格式来存储数据,使用变长的数据首先可以节省存储空间,同时可以使记录从磁盘加载到内存的时候速度更快。每一个record被分成两部分,header和image。header记录大小以及每个column的类型信息,header后的image对应的实际记录的存储内容。sqlite虚拟机支持5中存储类型,有符号整数、有符号浮点数、字符串、二进制以及NULL。在record 中的每个记录必须是以上五中类型之一。一个典型的record的存储结构如图12所示。 sqlite使用变长整数来存储rowid、record中的整数类型、字段的字节数等信息[5]。变长整数用高一位表示标志位。在组成变长整数的各个字节中,最低位的字节第8 位用0,表示整数结束,而前面的各个字节第8位是1,表示整数没有结束。google protobuf[6]中也用了类似的方式来表示变长整数。在这种整数表示方式下,原始数据0x0000007f可以用0x7f表示,0x00000https://mp.weixin.qq.com/cgi-bin/appmsg?t=media/appmsg_edit&action=edit&type=10&appmsgid=307812174&token=1791566097&lang=zh_CN100可以用0x82 0x00表示。可以看到,对于出现频次较高的值较小的整数,变长整数可以大大减少占用的存储空间。因为编码规则简单,解码执行效率也很高,不会对数据库的性能产生任何影响。
============================总结==========================
本文详细介绍了sqlite的整个后端系统的设计实现逻辑与主要数据结构。通过这些设计我们可以初步认识一些通用的传统数据库设计原理,以及如何依据这些原理来优化一些sql查询,如对常用的查询条件建索引、复合索引的最左匹配原理等等;同时了解如何利用sqlite的一些预留设计来实现一些专用的需求,如数据库加密、编码、校验等等。我们也根据sqlite的实现原理提出了适用sqlite的一些业务场景,同时也为读者根据不同的数据库实现
原理来做数据库方案选型提供了具体的思路。
参考文献
[1] D. Richard Hipp. sqlite官网. http://www.sqlite.org.
[2] D. Richard Hipp. sqlite文件格式. hhttps://www.sqlite.org/fileformat.html.
[3] Sibsankar Haldar. Inside sqlite. ” O’Reilly Media, Inc.”, 2007.
[4] Michael Owens. Embedding an sql database with sqlite. Linux Jounal,2003(110):2, 2003.
[5] 万玛宁, 关永, and 韩相军. 嵌入式数据库典型技术sqlite 和berkeleydb 的研究. 微计算机信息, (01Z):91–93, 2006.
[6] google. protobuf官 网. https://developers.google.com/protocol-buffers/.
以上是关于sqlite的后端系统设计原理的主要内容,如果未能解决你的问题,请参考以下文章