[SQLite]浅析其二——SQLite数据库的日志

SQLite数据库的日志

SQLite数据存储是孤本数据,在写入时异常中断(断电,坏块等),会导致数据库文件结构损坏,造成数据丢失。

因此日志的存在就非常必要,在官网的介绍中指出,SQLite支持通过PRAGMA journal_mode=?的形式设置日志模式,而当前支持的日志模式有如下几种:

  • DELETE
  • TRUNCATE
  • PERSIST
  • MEMORY
  • WAL
  • OFF

除去MEMORY与OFF两种不常用的模式,其余四种实际上可以分为两个大类:WAL(预写日志)与Rollback(回滚日志)模式。

日志的分类

日志模式按照其行为的不同,可以分为WAL日志与Rollback日志模式,后者在Android中也被成为Legacy Mode(即“传统日志模式”,由于本文讨论的更多是Android下的SQLite相关细节,因此后续尽量与Android的叫法保持一致)

而分类的标准,则是日志记录的行为差异:

可能已经有人注意到了,这两种日志有两个相对来说含义相反的词:Rollback和Ahead,前者是表明日志记录的内容是用于回滚的,后者则表明日志记录的内容是领先于实际写入到数据库文件中的内容的。

再扩展一点,我们可以得到如下的概括:

  • WAL日志模式是将写入的信息先写入到一个日志文件中,待时机成熟后再同步到db文件中;
  • 回滚日志模式是在每次写入新数据之前,将原始数据备份到日志文件中,用于后续如果发生异常中断时,可以进行数据恢复;

传统日志模式的作用范围仅限于写操作,而WAL日志模式由于存在一个索引(WAL-index)文件,因此也会作用于读操作;

传统日志模式

读取数据流程
  1. 初始状态,Connection初建立时,系统没有关于该数据库的任何缓存;

    蓝色方块一块代表一个扇区(Sector),取值一般时512B,可通过/sys/block/mmcblk0/queue/hw_sector_size或等效节点查看;
    读取

  2. 申请读锁(Read Lock)

    为了保证原子性,读取数据必须在持有读锁(Read Lock)的情况下进行;

    读锁本质上是一种共享锁(Shared Lock),其特征为:可以允许两个甚至更多连接对数据库进行读取操作,但是会阻止其他连接在读取过程中对其进行写操作;
    读锁
    在没有排它锁的情况下,读锁可以快速获得;排它锁会在后面提到;

  3. 在获得读锁以后,SQLite会将磁盘上的db文件中部分数据读入到缓存,后续如果重复读取这部分数据,将不再从磁盘获取。
    缓存

  4. 读取完毕后,将数据拷贝到用户空间;
    读取

写入数据流程

由于写入数据需要对变动的数据进行备份(回滚日志),因此写入数据之前,同样需要上方读取数据所需的1-4步,然后从第5步开始为写入数据的步骤:

  1. 申请保留锁

    在进行写入操作之前,数据库需要获取一个保留锁(Reserved Lock),保留锁与共享锁类似,同样允许其他进程对这个数据库进行读取操作。但由于每个数据库文件同一时间只能允许有一个保留锁,且保留锁可以与多个进程的共享锁共存,因此同一时间只能有一个进程可以对数据库进行写操作;
    保留锁

  2. 创建回滚日志文件(Rollback Journal File)

    在传统日志模式下进行任何改动之前,SQLite会先创建一个单独的回滚日志文件,将即将改变的数据库内容,以页的单位备份到回滚日志文件中。回滚日志文件拥有一个很小的文件头(下图绿色部分),用以记录数据库文件变更前的大小,这样即使此次事物操作会导致数据库增大,我们也可以获悉之前的文件大小,这有利于我们进行事务回滚。与此同时,页号也被写入到回滚日志文件中了;

    在各大主流操作系统中,创建一个新的文件并不会立即将其写入磁盘,因此此时,回滚日志文件还存在于缓存中;
    写日志

  3. 修改用户空间数据

    修改用户空间的数据库内容,由于用户空间的内容是以拷贝形式存在的,因此修改这部分内容不会导致其他连接、其他进程读取的内容发生改变;
    在这里插入图片描述

  4. 同步回滚日志文件到磁盘;

    这里根据PRAGMA synchronous的配置不同,实现也不同,但具体暂不展开,后续单独写一篇讨论,这里假设同步文件到磁盘是原子性的,不会有任何问题即可。
    在这里插入图片描述

  5. 申请等待锁(Pending Lock)

    实际上也是两个步骤:先申请一个等待锁(Pending Lock),也称未决锁,然后将其升级为排它锁; 在这里插入图片描述

  6. 升级为排它锁(Exclusive Lock)

    等待锁允许已经获取到共享锁的进程继续读取数据库文件,禁止新的共享锁申请。设计等待锁主要时为了防止写饿死,因为如果一直有新的共享锁申请,那么接下来的排它锁永远无法获取到;

    等到所有共享锁都释放以后,等待锁会升级为排它锁;此时除了锁持有者以外,不存在其他可以读写该数据库的进程。
    在这里插入图片描述

  7. 写入修改

    然后将修改后的数据写入,通常这里的写入也只是写入到缓存中,不会真正进行磁盘写入;
    在这里插入图片描述

  8. 将数据从缓存刷入磁盘;
    刷盘

  9. 删除日志

    如果上述流程运行正常完成,没有出现系统崩溃、断电等问题,那么此时SQLite会删除回滚日志;

    这里就是同为传统日志模式,但还存在DELETE | TRUNCATE | PERSIST三种不同选项的主要差异:怎么“删除”无用的回滚日志

    根据PRAGMA journal_mode的配置不同,实现也不同:

     9.1 当journal_mode=DELETE时,这一操作是真正的删除该文件;
    
     9.2 当journal_mode=TRUNCATE时,这一操作仅将回滚日志文件截取为长度为0的文件;这会比DELETE快一点,因为虽然文件长度为0,但是文件存在,因此不会更新父目录的inode信息,如果层级目录越复杂,TRUNCATE的效率较DELETE越高;
         
     9.3 当journal_mode=PERSIST时,这一操作仅将回滚日志文件头修改为0;以此标记该日志文件无效,改动最小,效率最高;
    

    这里就是PRAGMA journal_mode=DELETE|TRUNCATE|PERSIST三者的差异;
    删除日志
    无论如何“删除”,此步骤我们得到的结果都是一样的:没有有效的回滚日志文件了

  10. 释放排它锁

    使该数据库可以重新被其他进程读写;

    注意,较早版本会在此步清除用户空间内的数据,但是考虑到这部分数据可能会被重复使用,因此在新版本上均不作清理;

    在下一次读取时,SQLite会重新获取共享锁,并通过确认数据库文件头中的修改计数,来确定缓存的数据是否最新,如果是,则直接复用;只有当计数不一致时,才会重新从缓存中读取;在大部分情况下,复用的概率更大,因此这一步修改会显著提升性能;
    在这里插入图片描述

日志回滚流程

异常中止

  1. 当在进行上述步骤8(刷盘)时,若是发生了断电情况. 那么当重新开机后,磁盘中的情况可能就是如下图所示:
    在这里插入图片描述

  2. 热日志判断

    当任意一个新的SQLite进程去访问该数据库文件时,首先会获得读锁(共享锁)。
    读锁
    当存在有日志文件时,该SQLite进程会先去检查该日志文件是否为“热日志”,判断条件如下:

    2.1 存在一个回滚日志文件
    2.2 该日志文件不为空
    2.3 主数据库文件上不存在保留锁
    2.4 日志头格式正确且不为0
    2.5 回滚日志不包含父日志(Super-Journal File)文件名称,或者日志包含父日志文件名,且主日志文件存在;(父日志文件涉及多文件提交,这里方便理解就暂不展开,默认此条为真)
    当且仅当上述5条全部满足时,该日志才会被判定为“热日志”

日志回滚

  1. 将共享锁升级为排他锁

    处理热日志的第一步是得到数据库的文件排他锁。这可以防止其他进程同时去回滚同一个日志。在这里插入图片描述

  2. 开始数据回滚

    这里进行了三个操作:

    2.1 从回滚日志中读取页面的原始内容到系统缓存;
    2.2 将数据库文件truncate回其原始大小(日志头记录了文件原始大小);
    2.3 将内容写回到数据库文件对应的位置(日志头记录了页号);
    回滚

  3. 删除回滚日志

    同上,对于不同的日志模式,其删除日志的方式有所不同,这里不再赘述。
    在这里插入图片描述

  4. 继续执行接下来的数据库操作(从读取数据流程的步骤3开始)

    将排他锁还原为读锁。继续执行接下的操作,中止的事务就像是从没有发生过一样。还原读锁

WAL日志模式

WAL日志模式相较于传统日志模式差异很大。受限于篇幅,这里就尽可能简单介绍一下,后续会单独开一篇把细节补齐。
首先交代一个背景:WAL日志模式下通常会存在三个文件:

  • xxx.db 数据库文件,与传统日志模式下相同
  • xxx.db-wal WAL日志文件,与传统日志模式不同的是,该日志文件会长时间存在,并不会在每次写入后清除;
  • xxx.db-shm WAL-index文件,用于对WAL日志文件中的内容进行索引,并由于其会被加载入共享内存,因此跨进程的高速查询也是可以的(但不建议这么做)
读取数据流程

与传统日志模式不同,WAL日志模式下,每次读取数据请求都会向WAL-index文件进行查询,以确认WAL日志文件中是否有更新数据:如果有,则从WAL日志文件中读取数;反之则与传统日志模式一样,从DB文件中读取数据;

写入数据流程

与传统日志模式不同,WAL日志模式可以实现读写并发,其写入数据的流程图如下:

WAL
这张图比较复杂,解读一下:

  • WAL的锁机制与传统日志模式完全不同。首先锁是作用在WAL-index上的,而不是WAL日志文件本身;其次WAL中的锁均具备两种属性:共享锁与独占锁,因此这里write(exclusive)表示WAL-index文件上锁WAL_WRITE_LOCK,且属性为独占锁;
  • Reader指代读请求,每次读请求实际上都会去寻找WAL-index的比对情况,确认是从DB文件中读取,还是从WAL日志文件中读取;图中绿色数据即表示WAL日志文件中更新后的数据;
  • Writer指代写请求,写入前会将WAL-index文件上锁(写锁,独占),然后将改动后的数据谁家到WAL日志文件有效数据的末尾(mxFrame帧之后),然后将新增的帧索引以及页号更新到WAL-index文件中,随后更新WAL-index文件的文件头,提示mxFrame发生改变,最后释放写锁;
  • WAL-index是一个记录WAL日志记录情况的索引文件,以共享内存的方式提供快速索引,减少磁盘IO,同时为跨进程通信提供高速支持,(虽然Android处于安全、权限管控等考虑,没有使用跨进程读取);
  • 当WAL日志文件积累到一定体积后,会触发SQLite的Checkpoint机制,将WAL日志文件中的新数据写入到DB文件中;
异常恢复流程

不难看出,其实当WAL日志文件与WAL-index更新完毕后,在假定文件本身不会自然损坏的前提下(EMMC/UFS坏快),数据库就不存在损坏的可能;

当然,如果在写入或Checkpoint过程中出现断电等问题,会导致WAL-index文件或WAL日志文件的数据不完整,针对这种情况,SQLite提供了Recovery机制:

  • 如果WAL-index文件更新时错误,那么在下次读操作触发时,会判断文件头的A/B副本是否一致,如果不一致,会尝试获取一下写锁,以排除正在写入的可能;如果写锁可以正常获取,表示WAL-index文件更新时被中断了,需要根据WAL日志文件的信息(包括nBackfill与mxFrame等)对WAL-index文件进行回复;

  • 如果WAL日志文件在Checkpoint到DB文件时中断,实际上并不影响数据的读写,因为WAL-index仍会指向WAL日志文件中的数据,那么整个流程照旧,下次Checkpoint时将从失败的帧处开始同步;

  • 如果是写入WAL日志文件时断电,那么此时WAL-index文件中的mxFrame并未更新,因此会被直接丢弃;

  • 8
    点赞
  • 18
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值