SQLite剖析(7):锁和并发控制

    本文整理自http://sqlite.org/lockingv3.html。
    在SQLite中,锁和并发控制机制都是由pager.c模块负责处理的,用于实现ACID(Atomic, Consistent, Isolated和Durable)特性。在含有数据修改的事务中,该模块将确保或者所有的数据修改全部提交,或者全部回滚。与此同时,该模块还提供了一些磁盘文件的内存Cache功能。
    事实上,pager模块并不关心数据库存储的细节,如B-Tree、编码方式、索引等。它只是将其视为由统一大小(通常为1024字节)的数据块构成的单一文件,其中每个块被称为一个页(page)。页的起始编号为1,即数据库的首个1024字节称为"page 1",其后的页编号以此类推。pager通过OS接口模块(如os_unix.c, os_win.c)与操作系统通信。
     1、锁
    从单个进程的角度来看,一个数据库文件可以有五种不同的锁状态:
    (1)UNLOCKED: 文件没有持有任何锁,即当前数据库不存在任何读或写的操作。其它的进程可以在该数据库上执行任意的读写操作。此状态为缺省状态。
    (2)SHARED: 在此状态下,该数据库可以被读取但是不能被写入。在同一时刻可以有任意数量的进程在同一个数据库上持有共享锁,因此读操作是并发的。换句话说,只要有一个或多个共享锁处于活动状态,就不再允许有数据库文件写入的操作存在。
    (3)RESERVED: 假如某个进程在将来的某一时刻打算在当前的数据库中执行写操作,然而此时只是从数据库中读取数据,那么我们就可以简单的理解为数据库文件此时已经拥有了保留锁。当保留锁处于活动状态时,该数据库只能有一个或多个共享锁存在,即同一数据库的同一时刻只能存在一个保留锁和多个共享锁。在Oracle中此类锁被称之为预写锁,不同的是Oracle中锁的粒度可以细化到表甚至到行,因此该种锁在Oracle中对并发的影响程度不像SQLite中这样大。
    (4)PENDING: 该锁的意思是说,某个进程正打算在该数据库上执行写操作,然而此时该数据库中却存在很多共享锁(读操作),那么该写操作就必须处于等待状态,即等待所有共享锁消失为止,与此同时,新的读操作将不再被允许,以防止写锁饥饿的现象发生。在此等待期间,该数据库文件的锁状态为PENDING,在等到所有共享锁消失以后,PENDING锁状态的数据库文件将在获取排他锁之后进入EXCLUSIVE状态。
    (5)EXCLUSIVE: 在执行写操作之前,该进程必须先获取该数据库的排他锁。然而一旦拥有了排他锁,任何其它锁类型都不能与之共存。因此,为了最大化并发效率,SQLite将会最小化排他锁占有的时间总量。
     2、回滚日志
    当一个进程要修改数据库文件的时候(并且不在WAL模式下),它首先将未改变之前的内容记录到回滚日志文件中。回滚日志还要记录数据库的初始大小,以便以后进行回滚操作。如果SQLite中的某一事务正在试图修改多个数据库中的数据(使用了ATTACH命令),那么此时每一个数据库都将生成一个属于自己的回滚日志文件,用于分别记录属于自己的数据改变,与此同时还要生成一个用于协调多个数据库操作的主数据库日志文件,在主数据库日志文件中并不包含要回滚的页数据,它只是包含各个数据库回滚日志文件的文件名。在每个回滚日志文件中也同样包含了主数据库日志文件的文件名信息。然而对于无需主数据库日志文件的回滚日志文件,其中也会保留主数据库日志文件的信息,只是此时该信息的值为空。
    我们可以将回滚日志视为"HOT"日志文件,因为它的存在就是为了恢复数据库的一致性状态。当某一进程正在更新数据库时,应用程序或OS突然崩溃,这样更新操作就不能顺利完成,于是产生HOT日志。因此我们可以说HOT日志只有在异常条件下才会生成,如果一切都非常顺利的话,该文件将永远不会存在。
    在没有主数据库日志情况下,如果一个日志有非零头部,并且相关的数据库文件没有RESERVED锁,则它是HOT的。在有主数据库日志情况下,如果一个日志的主数据库日志存在,且在相关的数据库文件上没有RESERVED锁,则它也是HOT的。理解一个日志什么时候是HOT的非常重要,可以把前面的这些规则写成下面形式:
    * 一个日志是HOT的,如果
        * 它存在,且
        * 它的空间大小大于512字节,且
        * 日志头部非零,结构良好,且
        * 它的主数据库日志存在,或者主数据库文件名为空字符串,且
        * 在相关的数据库文件没有RESERVED锁。
    在读数据库之前,SQLite总是先检查它是否有一个HOT日志。如果有,则在读数据库之前先执行回滚,以保证数据库状态是一致的。当一个进程想要读取数据库时,先要完成以下步骤:
    (1)打开数据库文件并获取一个共享锁。如果不能获取共享锁,则立刻失败并返回SQLITE_BUSY。
    (2)检查数据库文件是否有HOT日志,如果没有,则工作完成,立刻返回。如果有,则这个日志必须根据下面的算法步骤进行回滚。
    (3)对数据库文件获取等待锁,再获取排他锁(注意不要获取保留锁,因为这会让其他进程认为日志不再是HOT的了)。如果获取失败,意味着另外一个进程正尝试做回滚操作。这时只能释放所有的锁,关闭数据库,返回SQLITE_BUSY。
    (4)读取日志文件并且回滚之前的修改。
    (5)等待回滚写入到持久存储设备,以恢复数据库的完整性。
    (6)删除日志文件(或者如果设置了PRAGMA journal_mode=TRUNCATE指令,则把日志缩短成0字节;如果设置了PRAGMA journal_mode=PERSIST指令,则把日志头部清零)。
    (7)删除主数据库日志,如果这样做安全的话。该步是可选的,只是为避免过期的主数据库日志文件塞满磁盘。
    (8)释放排他锁和等待锁,但仍保持共享锁。
    在这些算法步骤成功完成后,就可以安全读取数据库了。一旦所有的读取完成,释放共享锁。
    过期的主数据库日志不再有任何用途,删除它只是为了释放磁盘空间。一个主数据库日志是过期的,如果没有单独的日志文件指向它。为了断定一个主数据库日志是否过期,SQLite首先读取主数据库日志文件以获取所有日志文件名。然后检查这些日志文件,看其中是否有主数据库日志文件名字段指向该主数据库日志的,如果有则主数据库文件不是过期的,否则主数据库文件过期。
     3、数据写入
    如果某一进程要想在数据库上执行写操作,那么必须像前面描述一样先获取共享锁(如果有HOT日志,则要回滚未完成的更改),在共享锁获取之后再获取保留锁。因为保留锁预示着在将来某一时刻该进程将会执行写操作,所以在同一时刻只有一个进程可以持有一把保留锁,但是其它进程可以继续持有共享锁以完成数据读取的操作。如果要执行写操作的进程不能获取保留锁,那说明另一进程已经获取了保留锁。在此种情况下,写操作将失败,并立即返回SQLITE_BUSY错误。在成功获取保留锁之后,该写进程将创建回滚日志。日志的头部初始化为数据库文件的原有大小。日志头部中也有主数据库日志文件名的字段,初始时为空字符串。
    在对任何数据做修改之前,写进程会将待修改页中的原有内容先行写入回滚日志文件中,然而将要发生变化的页起初并不会直接写入磁盘文件,而是先保留在内存中。这样数据库仍然是未修改的,其它进程就可以继续读取该数据库中的数据。
    或者是因为内存中的cache已满,或者是应用程序已经提交了事务,最终,写进程将数据更新到数据库文件中。然而在此之前,写进程必须确保没有其它的进程正在读取数据库,同时回滚日志中的数据确实被物理地写入到磁盘文件中(以便系统崩溃或断电时能用它来进行回滚)。其步骤如下:
    (1)确保所有的回滚日志数据被物理地写入磁盘文件,以便在出现系统崩溃时可以将数据库恢复到一致的状态。
    (2)对数据库文件获取等待锁,再获取排他锁,如果此时其它的进程仍然持有共享锁,写入线程将不得不被挂起并等待直到那些共享锁消失之后,才能进而得到排他锁。
    (3)将内存中持有的修改页写入到原有的磁盘文件中。
    如果写入到数据库文件的原因是因为cache已满,那么写入进程将不会立刻提交,而是继续对其它页进行修改。但是在后续的修改被写入到数据库文件之前,回滚日志必须被再一次刷新到磁盘中。还要注意的是,写入进程获取的排他锁必须被一直持有,直到所有的更改被提交为止。这意味着从数据第一次被刷新到磁盘文件开始,直到事务被提交之前,其它的进程不能访问该数据库。
    当写入进程准备提交更改时,将执行以下步骤:
    (4)获取排他锁,同时通过上面的步骤1-3确保所有内存中的变化数据都被写入到磁盘文件中。
    (5)将数据库文件的所有修改物理地写入到磁盘中。
    (6)删除日志文件(或者如果PRAGMA journal_mode为TRUNCATE或PERSIST,截短日志文件或者对头部清零)。如果在删除之前出现系统故障,进程在下一次打开该数据库时仍将基于该HOT日志进行恢复操作。因此只有在成功删除日志文件之后,我们才可以认为该事务成功完成。
    (7)从数据库文件中删除所有的排他锁和PENDING锁。
    一旦PENDING锁被释放,其它的进程就可以开始再次读取数据库了。在当前的实现中,保留锁也会被释放,但这不是必须的。将来的SQLite版本可能提供一个SQL命令"CHECKPOINT",用于提交当前事务所做的所有更改,但持有保留锁,以便可以做更多的更改,而不给任何其他进程写数据的机会。
     如果一个事务中包含多个数据库的修改,那么它的提交逻辑将更为复杂,见如下步骤:
    (4)确保每个数据库文件都已经持有了排他锁和一个有效的日志文件。
    (5)创建主数据库日志文件,其文件名是随机的。同时将每个数据库的回滚日志文件的文件名写入该主数据库日志文件,并刷新到磁盘上。
    (6)再将主数据库日志文件的文件名分别写入到每个数据库回滚日志文件的指定位置,并刷新到磁盘。
    (7)将所有的数据库变化持久化到数据库磁盘文件中。
    (8)删除主日志文件,如果在删除之前出现系统故障,进程在下一次打开该数据库时仍将基于该HOT日志进行恢复操作。因此只有在成功删除主日志文件之后,我们才可以认为该事务成功完成。
    (9)删除每个数据库各自的日志文件。
    (10)从所有数据库中删除掉排他锁和PENDING锁。
    最后需要说明的是,在SQLite2中,如果多个进程正在从数据库中读取数据,也就是说该数据库始终都有读操作发生,即在每一时刻该数据库都持有至少一把共享锁,这样将会导致没有任何进程可以执行写操作,因为在数据库持有读锁的时候是无法获取写锁的,我们将这种情形称为“写饥饿”。在SQLite3中,通过使用PENDING锁则有效的避免了“写饥饿”情形的发生。当某一进程持有PENDING锁时,已经存在的读操作可以继续进行,直到其正常结束,但是新的读操作将不会再被SQLite接受,所以在已有的读操作全部结束后,持有PENDING锁的进程就可以被激活并试图进一步获取排他锁以完成数据的修改操作。
     4、数据库文件是怎么损坏的
    pager模块是非常健壮的,但有时候也会被破坏。如果一个流氓进程打开数据库文件或日志,写入无用的数据,则数据库将损坏。对这种情况,无需更多讨论。
    在Unix上,SQLite使用POSIX建议的锁来实现加锁功能。在Windows上则使用LockFile(), LockFileEx()和UnlockFile()系统调用。SQLite假设这些系统调用能正确工作,否则数据库也有可能损坏。有一点要注意,POSIX建议的锁比较简单,但甚至在许多NFS上都没有实现(包括当前的Max OS X版本),有很多报告称Windows下的网络文件系统也有锁的问题,因此你最好避免在网络文件系统上使用SQLite。
    Unix下SQLite使用fsync()系统调用来把数据刷新到磁盘,Windows下则使用FlushFileBuffers()。重申一下,SQLite假设这些操作系统服务函数是正确工作的。但有报告称fsync()和FlushFileBuffers()并不总是能正确地工作,特别是在廉价的IDE硬盘上。有一些IDE硬盘厂商的控制器芯片报告数据已经写入到磁盘表面,但实际上数据还在硬盘驱动电路的易失性Cache中。也有报告称Windows有时由于一些不确定的原因会忽略FlushFileBuffers()。如果这些报告属实,那意味着因为断电而导致数据库损坏是有可能的。SQLite并不能防止硬件和OS的漏洞。
    如果Linux ext3文件系统在/etc/fstab中没有"barrier=1"选项的情况下被挂载,且磁盘驱动的写缓存是激活的,则当掉电或OS崩溃时文件系统损坏就有可能发生,特别对于廉价消费级的硬盘。而带有非易失性写缓存的企业级存储设备发生文件系统损失的可能性则小得多。据说有许多Linux发行版不使用barrier=1选项,并且不禁用写缓存,因此许多Linux发行版对这个问题是比较脆弱的。注意这是操作系统和硬件问题,SQLite无能为力,其他的数据库引擎也有这个问题。
    如果发生崩溃或断电,则产生HOT日志,但是这个HOT日志被删掉了。下一进程打开数据库时将不知道数据库需要回滚,数据库处于不一致的状态。有很多原因会导致回滚日志被删除:
    (1)系统管理员可能会在OS崩溃或系统掉电后做清理工作,看到日志文件认为它是垃圾,删除掉。
    (2)有人(或者某个进程)可能会重命名数据库文件,但却没有得命名相关的日志。
    (3)如果数据库文件有别名(硬链接或软链接),且通过链接别名来打开数据库文件,则生成的日志文件将以链接名来命名,若下次打开数据库时使用另一个链接名,将找不到日志。为了避免这个问题,你不应该对SQLite数据库文件创建链接。
    (4)断电导致的文件系统损坏可能导致日志被重命名或被删除。
    当SQLite在Unix上创建一个日志文件时,会打开这个日志文件所在的目录,并且调用fsync(),试图把目录信息写入磁盘。但假设另外一个进程正在向该目录添加或从该目录中删除不相关的文件,这时突发断电,就有可能导致日志文件从该目录中被删除并移到"lost+found"。这是一个罕见的场景,但有可能发生。避免这种情况的最好方式是使用日志文件系统。
    对涉及多个数据库和一个主数据库日志的事务提交,如果这些数据库位于不同的磁盘卷上,在事务提交时发生断电,机器重新起来后磁盘可能用不同的名称来挂载,或者一些磁盘根本就不挂载。这样的情况下,各个日志文件和主数据库日志文件可能互相不能找到对方,最坏的结果是提交变得不再是原子性的了。一些数据库可能回滚,另一些则没有回滚。为了避免这样的问题,我们应该把所有数据库存放在一个磁盘卷上,并且断电后使用同样的名字来挂载硬盘。
     5、SQL级别的事务控制
    SQLite 3在实现上针对锁和并发控制做了一些精细的变化,特别是对于事务这一SQL语言级别的特征。在缺省情况下,SQLite 3会将所有的SQL操作置于antocommit模式下,这样所有针对数据库的修改操作都会在SQL命令执行结束后被自动提交。在SQLite中,SQL命令"BEGIN TRANSACTION"(其中TRANSACTION关键字可选)用于显式的声明一个事务,禁用autocommit模式,即其后的SQL语句在执行后都不会自动提交,而是需要等到SQL命令"COMMIT"或"ROLLBACK"被执行时,才考虑提交还是回滚。注意BEGIN命令并不获得任何类型的锁,在BEGIN之后,当执行第一个SELECT语句时才得到一个共享锁,当执行第一个DML语句(INSERT, UPDATE或DELETE)时才获得一个保留锁。至于排它锁,只有在数据从内存写入磁盘时开始,直到事务提交或回滚之前才能持有排它锁。
    SQL命令COMMIT命令并不实际提交更改到磁盘,它只是重新打开autocommit模式。然后,在命令结束时,正式的自动提交逻辑才实际提交更改到磁盘。SQL命令ROLLBACK也是打开autocommit模式,但是它设置一标志,以告诉自动提交逻辑执行回滚,而不是提交。如果自动提交逻辑提交更改失败,因为另外有进程持有共享锁,则autocommit模式会自动关闭。这允许用户在共享锁释放之后重新COMMIT。
    如果多个SQL命令在同一个时刻同一个数据库连接中被执行,autocommit将会被延迟执行,直到最后一个命令完成。比如,如果一个SELECT语句正在被执行,在这个命令执行期间,需要返回所有检索出来的行记录,如果此时处理结果集的线程因为业务逻辑的需要被暂时挂起并处于等待状态,而其它的线程此时或许正在该连接上对该数据库执行INSERT、UPDATE或DELETE命令,那么所有这些命令作出的数据修改都必须等到SELECT检索结束后才能被提交。

你可能感兴趣的:(SQLite剖析(7):锁和并发控制)