Oh!Coder

Coding Life

SQLite中的锁

| Comments

在SQLite中,锁是一个重要而又基本的概念。

与锁密切相关的一个基本概念是事务。首先,为了更好的理解锁的概念,我们简单回顾一下什么是事务?

事务定义了一组SQL命令的边界,这组命令或者作为一个整体被全部执行,或者都不执行,这被称为数据库完整性的原子性原则,关于事务的典型例子就是银行转帐。

事务由3个命令控制:begin、commit和rollback。begin之后和commit之前的所有操作都可以被取消。commit命令提交所执行的所有操作。与之类似,rollback还原begin之后的所有操作。

那么在数据库中,事务和锁在查询处理中密切相关。查询总是在事务内执行,事务又涉及到锁,所以如果锁控制不当,会产生很多问题。接下来,让我们谈谈SQLite中的锁。

SQLite采用粗粒度的锁。当一个连接要写数据库时,所有其他的连接被锁住,直到写连接结束它的事务。SQLite有一个加锁表,用来帮助不同的写数据库都能够在最后一刻再加锁,以保证最大的并发性。

SQLite有5种不同的锁状态:未锁定(unlocked)、共享(shared)、保留(reserved)、待定(pending)和排它(exclusive)。每个数据库连接在同一时刻只能处于其中的一个状态(见下图),每种状态(未锁定(unlocked)状态除外)都有一种锁与之对应。

pic

图中所示,所有的事务都是从未锁定(unlocked)、保留(reserved)或排他锁开始的。默认情况下,一切都从未锁定(unlocked)开始。白色的锁状态—-未锁定(unlocked)、待定(pending)、共享(shared)和保留(reserved),全部都可以在同一时间同一数据库的不同连接种存在。不过,从灰色的待定锁开始,限制就多了。灰色的待定状态代表锁正在被某个连接拥有,即某个想要获取独占锁的写操作。与此对应,白色的待定状态表示连接获取和释放共享锁的途径。尽管有这些不同的锁状态,但是所有的SQLite事务都可以归结为两种类型之一:读事务和写事务。这也是图种描绘的最终内容:读操作、写操作以及它们如何在一起工作。

下面,我们就从读事务和写事务两个方面讨论。

  • 读事务

从select语句的锁进程开始,它的路径比较简单。执行select语句的连接启动事务,从未锁定转到共享锁,提交之后回到未锁定状态,操作结束。

这里我们提出两个疑问:

  1. 执行两个语句会发生什么?
  2. 它们的锁路径是什么?

这两个疑问取决于是否运行在自动提交模式下。

考虑如下实例:

1
2
3
4
5
6
db = open('worlds.db')
db.exec('begin')
db.exec('select * from sometables')
db.exec('select * from sometables')
db.exec('commit')
db.close()

这里有明确的begin命令开始,两个select命令在一个事务种执行,因此它们在同一个共享状态下执行。第一个exec()运行,让连接进入共享状态;然后第二个exec()运行;最后,手动提交命令,让连接从共享状态回到未锁定状态。代码的锁路径如下所示:

UNLOCKED->PENDING->SHARED->UNLOCKED

现在考虑没有begin和commit命令的情况。两个select命令运行在自动提交模式下。因此,它们各自经历完整的路径。现在,代码的锁路径如下所示:

UNLOCKED->PENDING->SHARED->UNLOCKED->PENDING->SHARED->UNLOCKED

由于该代码只是读取数据,因此,可能不会产生多大差异,但在自动提交模式下的确会两次锁定文件,而不是其他方式中那样只锁定一次。这样的做法,你可能会发现,可以在两个select exec()调用之间插入修改数据库的操作,因此,这样做无法确保两个命令返回相同的结果。相反,如果有begin…commit命令,可以保证它们的结果完全相同。

  • 写事务

下面说说数据库写操作,例如update语句。首先,连接必须遵从与select相同的路径,先到共享状态。所有的操作—-写操作或读操作—-都必须经历;未知锁->待定锁->共享锁。

保留状态(reserved)

连接尝试向数据库写入内容时,必须从共享锁转换到保留锁。如果它获得保留锁,则准备好开始进行数据修改。即使连接真的不能在此时修改数据库,它也可以将修改内容存储在本地pager内的内存缓存中,也就是上一篇中提到的页面缓存。

当连接进入保留状态时,pager初始化回滚日志。回滚日志是一个文件,用于回滚和故障恢复。具体地说,它拥有将数据库还原到事务开始之前的原来状体啊的数据库页。当B-tree修改页时,pager将这些数据库页都存放到日志文件。比如说,对于update修改的每条记录,页面获取与原始记录相关的数据库页,并将它们复制到日志中。日志就拥有事务开始之前的一些数据库内容。因此,要撤销事务时,pager只是简单地将日志文件中的内容复制回数据库中。这样,数据库就还原到事务开始前的状态。

保留状态下,pager实际上管理三种页:已修改页、未修改页和日志页。已修改页是包含B-tree已改变记录的页,这些页存储在页缓存中。未修改页是B-tree读取但并未改变的页,它们是诸如select命令之类的结果。最后是日志页,它就是已修改页的原始版本。日志页不会存储在页面缓存中,但B-tree修改前会将其写入日志。

因为页面缓存,写操作连接的确可以在保留状态完成实际的工作,而不用干扰其他(读操作)连接。因此,SQLite可以有效地让多个读操作和一个写操作同一时间在同一数据库中工作。唯一需要注意的是,写操作连接要将所做的修改存储在页面缓存中,而不是数据库文件中。此外要注意,给定数据库同一时间只能有一个保留或独占连接—-但是多个读操作可以和一个写操作并存。

待定状态(pending)

当连接完成update操作,并提交事务时,pager开始进入独占状态的过程。一旦获得待定锁,并继续持有该锁,阻止其他连接获取待定锁。这里的待定锁也被称为网关锁,因为写操作继续持有待定锁,其他连接无法从未锁定转换到共享状态,结果是没有可以进入数据库的新连接:没有新的读操作,没有新的写操作。挂起状态实际上是损耗阶段。写操作保证它可以排队等待数据库—-只要每个人都守规矩且行为得当,最终都可以获得独占锁。只有其他已有共享锁的连接可以继续正常工作。待定状态下,写操作等待这些连接完成并释放其共享锁。

独占状态(exclusive)

独占状态中,主要工作是将修改的页从页面缓存刷新到数据库文件。这时要慎重,因为pager开始实际修改数据库了。

在pager开始写入修改的页前,首先要处理日志。它会检查日志的完整内容是否已写入磁盘。这种情况下,依然很可能,即使pager已将页写入日志文件,但是操作系统具有很多缓存,因此,可能还有一部分内容在操作系统的缓存中(也许不是全部)。

将日志提交到磁盘非常重要,因为如果程序或系统在pager写入数据库文件时崩溃,日志是日后还原数据库文件的唯一方式。如果在系统崩溃前,日志页没有完整地写入磁盘,那么数据库就无法还原到其原来的状态,因为内存中的日志页在系统崩溃时丢失了。这种情况下,如果运气好的话,还有一个处于不一致状态的数据库,如果不走运的话,也许数据库也损坏了。

一旦处理完日志,pager就可以将所有已修改的页复制到数据库文件。下一步做什么取决于事务模式。比如此处说的情况,事务自动提交,然后pager清理日志,清除页缓存,从独占锁回到未锁定状态。如果该事务未提交,pager继续持有独占锁,日志继续发挥作用,直到发出COMMIT或者ROLLBACK命令。

至此,以读事务和写事务为两大方面,对锁的5种状态之间的转换以及所起到的作用,分别做了举例和总结。对于SQLite中5种锁的状态的转换,仅仅是需要了解的基本概念,相关的知识还有很多,比如自动提交模式下对于效率有什么影响?页面缓存的大小对于保留锁状态过渡到独占锁状态的影响?等等还有很多其他方面需要了解,这里只是做了基本介绍。

Comments