sqlite3.36版本 btree实现(二)- 并发控制框架

2021-12-18
8分钟阅读时长

《sqlite3.36版本 btree实现》系列文章:

概述

按照之前起步阶段对sqlite btree整体架构的分析,“页面管理模块”分为以下几个子模块:

  • 页面缓存管理。
  • 页面备份,又分为以下两种实现:
    • journal文件。
    • WAL文件。
  • 页面管理模块。

前面一节讲完了“页面缓存管理”的实现,按照自下往上的顺序,就应该到“页面备份”了。“页面备份”核心的工作是:在真正修改页面内容之前,将还未修改的页面内容备份,这样一旦系统在事务过程中宕机崩溃,就可以用这部分内容回滚还未落盘的事务修改,让系统回到一个正确的状态。

“页面备份”有两种实现方式,在早期使用的journal文件,这种方式性能不高;在3.7版本之后,sqlite引入了WAL文件来保存页面内容,这样做的效率更高。

本节就讲解这部分内容,在对这部分内容有一个总体的了解之后,继续讲解页面备份的总体流程。后面的章节再具体分析journal以及WAL的实现。

写事务的流程

(以下流程分析,按照sqlite官网中的文档Atomic Commit In SQLite进行讲解,图例也全部引用自官网。)

sqlite的写事务,分为以下几个流程:

1、初始化阶段(Initial State)

初始化

如上图中,从右到左即是系统的磁盘、操作系统缓冲区、用户空间三部分,其中磁盘和操作系统缓冲区有划分为多块的空间,每一块在sqlite里被称为一个sector,蓝色部分表示是修改之前的数据。

这是系统初始时的样子。

2、拿到读锁(Acquiring A Read Lock)

拿到读锁

在开始进行写操作之前,sqlite必须先把待修改的页面加载内存中(这就是上一节“页面缓存管理器”做的事情),后续的修改其实也是首先修改这部分加载到内存中的页面内容,因为可能一次提交会修改同一个页面中的多处内容,最后才把页面内容落盘。

所以,这一步所要做的,是首先拿到数据库文件的读锁(shared lock),需要说明的是,这个读锁是数据库级别的锁。同一时间,系统中可以存在多个读锁,但是只要系统中还存在读锁,就不再允许分配出新的写锁(write lock)。

3、读出页面的内容到页面缓存

读出页面内容

拿到读锁之后,就可以把需要进行修改的页面读出来到用户空间的页面缓存了。从上图来看,读了三个页面的内容出来,也就是例子中的写操作要修改三个页面的内容。

4、拿到保留锁(Obtaining A Reserved Lock)

拿到保留锁

在进行修改之前,还需要首先将前面拿到的读锁(shared lock)升级为保留锁(reserved lock)。同一时间,系统中保留锁可以和多个读锁并存,但是只能存在最多一个保留锁。这个机制,保证了同一时间只能有一个进程对数据库进行写操作。

需要说明的是,拿到保留锁的进程,还并没有真正进行数据的修改操作,只是用这个锁,挡住了其它打算进行写操作的进程。

5、创建回滚用的备份文件(Creating A Rollback Journal File)

创建回滚用的备份文件

到了这一步,首先将待修改的页面内容备份。官网原文写的是备份到回滚用的journal文件中,我们上面提到备份机制除了journal文件还有wal文件,所以这里的“journal文件”应该更泛化的理解为“保存到备份文件中”,这种备份文件可能是journal文件,也可能是wal文件,视机制而定。

上图中,用户空间的页面写入到了备份文件中,注意到备份文件上面有一小块绿色的部分,理解为备份文件的meta信息即可。

另外还需要特别说明的是,从上图中可以看到,备份工作也仅仅到了操作系统缓冲区,即图中的中间部分,而磁盘部分还是空的。即到了这一步,即便是备份页面的内容,也还并没有sync到磁盘中,即只进行了备份的写操作,并没有强制落盘。

6、修改用户空间的页面内容(Changing Database Pages In User Space)

修改用户空间的页面内容

到了这一步,修改进程用户空间的页面内容,即上图中的橙色部分,就是修改后的用户空间数据。由于每个进程都有自己的用户空间(即便是同一个进程下的不同线程,对sqlite而言,只要使用的是不同的连接(connection),那么连接背后的页面缓冲区就不一样),所以这些修改并不被其它进程所见。这样,写进程做自己的修改,其它读进程读到的还是修改之前的页面数据。

7、将备份文件的内容落盘(Flushing The Rollback Journal File To Mass Storage)

将备份文件的内容落盘

上面的第5步提到,当时还只是写页面内容到备份文件中,这一步接在修改页面内容之后,将修改之前的页面内容sync到磁盘中。

8、拿到排他锁(Obtaining An Exclusive Lock)

拿到排他锁

前面的步骤做完,达到了这样的效果:

  • 对于待修改页面,修改之前的内容已经保存到了备份文件中。
  • 需要修改的内容,已经体现在了进程的用户空间的页面缓存里。

此时,需要将页面修改的内容写到数据库文件中。在修改数据库文件之前,还需要首先拿到排他锁(exclusive lock)。拿到排他锁,又分为两步:

  • 首先拿到悬锁(pending lock)。
  • 将悬锁升级为排他锁。

为什么要首先拿到悬锁?同一时间内,悬锁和前面的保留锁一样,只能存在最多一个;但是不同的是,悬锁不允许再分配新的读锁(shared lock),而保留锁没有这样的机制。换言之,在悬锁之前的所有读锁,可以继续读操作,悬锁会等待它们完成,再升级为排他锁;同时,只要系统中有悬锁,就不再允许有新的读操作,必须等待修改数据库完成才可以有新的读操作。

这样的机制,避免了读操作时,读到了未提交的事务写到一半的数据。

9、保存修改到数据库文件中(Writing Changes To The Database File)

拿到排他锁

拿到了排他锁之后,意味着此时系统中没有读操作、没有其他写操作,这时候可以放心将页面缓存中的内容落盘到数据库文件中了。

同样需要注意的是,这一步的修改,还还只是到了操作系统的缓冲区,并不保证落盘到数据库文件中。

10、落盘数据库文件修改(Flushing Changes To Mass Storage)

落盘数据库文件修改

这一步,将对数据库文件的修改落盘。

11、删除备份文件(Deleting The Rollback Journal)

删除备份文件

至此,这一次写操作已经落盘到了数据库文件中,前面保存到备份文件中的数据可以清除了。清除备份文件内容,是一个比较费时的操作,具体实现由不同的机制去优化,后面讲到journal文件以及wal的实现时再展开描述。

12、释放锁(Releasing The Lock)

释放锁

写操作全部完成,备份文件也清除了,到了这一步就可以释放锁,以便后面其他的读写操作进来。

写事务中涉及到的锁

上面写事务流程中,依次会拿到以下类型的锁,下图中做一个简单的总结:

写操作中涉及到的锁

崩溃恢复流程

上面的流程中,随时都可能因为系统崩溃而导致数据错乱的,因此一个写事务如果还未完成,重启时存储引擎需要识别出来,将还没有完成的事务进行回滚操作(rollback)。

分为以下几种情况来处理:

写备份数据之前失败

如果系统在落盘备份数据之前失败,即前面的流程7之前失败,按照上面的流程来看,情况是这样的:

  • 写事务的数据还停留在用户空间的页面缓存中,未落盘到数据库文件上(流程6)。
  • 在流程5,只是将数据写到备份文件,还没有强制刷盘,所以这时候崩溃,可能备份文件中的数据是损坏的。

所以在这种情况下重启,面对的是这样的情况:

  • 数据库文件:还在写事务之前的状态,因为写事务还未落盘。
  • 备份文件:可能损坏。

于是,启动之后将校验备份文件是否完整,如果完整将重放一遍备份文件中的页面到数据库文件中;否则,只是简单的删除备份文件中的数据即可。

写数据库文件时失败

如果已经过了流程7,而在将页面缓存中的修改落盘到数据库文件的过程中,系统崩溃了,那么面临的是这样的情况:

  • 数据库文件肯定损坏了。
  • 写事务中被修改页面之前的内容,已经落盘到备份文件中了。

于是,启动恢复的时候,只需要将备份文件中的页面重放一遍到数据库文件即可将数据库文件恢复到写事务修改前的状态了。

总结

以上,就是sqlite中写事务的总体流程,以及重启恢复的流程,这里还并没有涉及到具体的代码细节,有了对总体流程的理解,后面再来分析具体的两种备份机制:journal以及wal的实现。

另外,需要看到的是:sqlite中锁的粒度,都还是数据库级别的,现在我还不知道其它更高效的数据库所谓行锁的实现,留待以后吧。

参考文档