SQLite教程(十二):锁和并发控制详解
一、概述:
在SQLite中,锁和并发控制机制都是由pager_module模块负责处理的,如ACID(Atomic,Consistent,Isolated,andDurable)。在含有数据修改的事务中,该模块将确保或者所有的数据修改全部提交,或者全部回滚。与此同时,该模块还提供了一些磁盘文件的内存Cache功能。
事实上,pager_module模块并不关心数据库存储的细节,如B-Tree、编码方式、索引等,它只是将其视为由统一大小(通常为1024字节)的数据块构成的单一文件,其中每个块被称为一个页(page)。在该模块中页的起始编号为1,即第一个页的索引值是1,其后的页编号以此类推。
二、文件锁:
在SQLite的当前版本中,主要提供了以下五种方式的文件锁状态。
1).UNLOCKED:
文件没有持有任何锁,即当前数据库不存在任何读或写的操作。其它的进程可以在该数据库上执行任意的读写操作。此状态为缺省状态。
2).SHARED:
在此状态下,该数据库可以被读取但是不能被写入。在同一时刻可以有任意数量的进程在同一个数据库上持有共享锁,因此读操作是并发的。换句话说,只要有一个或多个共享锁处于活动状态,就不再允许有数据库文件写入的操作存在。
3).RESERVED:
假如某个进程在将来的某一时刻打算在当前的数据库中执行写操作,然而此时只是从数据库中读取数据,那么我们就可以简单的理解为数据库文件此时已经拥有了保留锁。当保留锁处于活动状态时,该数据库只能有一个或多个共享锁存在,即同一数据库的同一时刻只能存在一个保留锁和多个共享锁。在Oracle中此类锁被称之为预写锁,不同的是Oracle中锁的粒度可以细化到表甚至到行,因此该种锁在Oracle中对并发的影响程序不像SQLite中这样大。
4).PENDING:
PENDING锁的意思是说,某个进程正打算在该数据库上执行写操作,然而此时该数据库中却存在很多共享锁(读操作),那么该写操作就必须处于等待状态,即等待所有共享锁消失为止,与此同时,新的读操作将不再被允许,以防止写锁饥饿的现象发生。在此等待期间,该数据库文件的锁状态为PENDING,在等到所有共享锁消失以后,PENDING锁状态的数据库文件将在获取排他锁之后进入EXCLUSIVE状态。
5).EXCLUSIVE:
在执行写操作之前,该进程必须先获取该数据库的排他锁。然而一旦拥有了排他锁,任何其它锁类型都不能与之共存。因此,为了最大化并发效率,SQLite将会最小化排他锁被持有的时间总量。
最后需要说明的是,和其它关系型数据库相比,如MySQL、Oracle等,SQLite数据库中所有的数据都存储在同一文件中,与此同时,它却仅仅提供了粗粒度的文件锁,因此,SQLite在并发性和伸缩性等方面和其它关系型数据库还是无法比拟的。由此可见,SQLite有其自身的适用场景,就如在本系列开篇中所说,它和其它关系型数据库之间的互换性还是非常有限的。
三、回滚日志:
当一个进程要改变数据库文件的时候,它首先将未改变之前的内容记录到回滚日志文件中。如果SQLite中的某一事务正在试图修改多个数据库中的数据,那么此时每一个数据库都将生成一个属于自己的回滚日志文件,用于分别记录属于自己的数据改变,与此同时还要生成一个用于协调多个数据库操作的主数据库日志文件,在主数据库日志文件中将包含各个数据库回滚日志文件的文件名,在每个回滚日志文件中也同样包含了主数据库日志文件的文件名信息。然而对于无需主数据库日志文件的回滚日志文件,其中也会保留主数据库日志文件的信息,只是此时该信息的值为空。
我们可以将回滚日志视为"HOT"日志文件,因为它的存在就是为了恢复数据库的一致性状态。当某一进程正在更新数据库时,应用程序或OS突然崩溃,这样更新操作就不能顺利完成。因此我们可以说"HOT"日志只有在异常条件下才会生成,如果一切都非常顺利的话,该文件将永远不会存在。
四、数据写入:
如果某一进程要想在数据库上执行写操作,那么必须先获取共享锁,在共享锁获取之后再获取保留锁。因为保留锁则预示着在将来某一时刻该进程将会执行写操作,所以在同一时刻只有一个进程可以持有一把保留锁,但是其它进程可以继续持有共享锁以完成数据读取的操作。如果要执行写操作的进程不能获取保留锁,那么这将说明另一进程已经获取了保留锁。在此种情况下,写操作将失败,并立即返回SQLITE_BUSY错误。在成功获取保留锁之后,该写进程将创建回滚日志。
在对任何数据作出改变之前,写进程会将待修改页中的原有内容先行写入回滚日志文件中,然而,这些数据发生变化的页起初并不会直接写入磁盘文件,而是保留在内存中,这样其它进程就可以继续读取该数据库中的数据了。
或者是因为内存中的cache已满,或者是应用程序已经提交了事务,最终,写进程将数据更新到数据库文件中。然而在此之前,写进程必须确保没有其它的进程正在读取数据库,同时回滚日志中的数据确实被物理的写入到磁盘文件中,其步骤如下:
1).确保所有的回滚日志数据被物理的写入磁盘文件,以便在出现系统崩溃时可以将数据库恢复到一致的状态。
2).获取PENDING锁,再获取排他锁,如果此时其它的进程仍然持有共享锁,写入线程将不得不被挂起并等待直到那些共享锁消失之后,才能进而得到排他锁。
3).将内存中持有的修改页写出到原有的磁盘文件中。
如果写入到数据库文件的原因是因为cache已满,那么写入进程将不会立刻提交,而是继续对其它页进行修改。但是在接下来的修改被写入到数据库文件之前,回滚日志必须被再一次写到磁盘中。还要注意的是,写入进程获取到的排他锁必须被一直持有,直到所有的改变被提交时为止。这也意味着,从数据第一次被刷新到磁盘文件开始,直到事务被提交之前,其它的进程不能访问该数据库。
当写入进程准备提交时,将遵循以下步骤:
4).获取排他锁,同时确保所有内存中的变化数据都被写入到磁盘文件中。
5).将所有数据库文件的变化数据物理的写入到磁盘中。
6).删除日志文件。如果在删除之前出现系统故障,进程在下一次打开该数据库时仍将基于该HOT日志进行恢复操作。因此只有在成功删除日志文件之后,我们才可以认为该事务成功完成。
7).从数据库文件中删除所有的排他锁和PENDING锁。
一旦PENDING锁被释放,其它的进程就可以开始再次读取数据库了。
如果一个事务中包含多个数据库的修改,那么它的提交逻辑将更为复杂,见如下步骤:
4).确保每个数据库文件都已经持有了排他锁和一个有效的日志文件。
5).创建主数据库日志文件,同时将每个数据库的回滚日志文件的文件名写入到该主数据库日志文件中。
6).再将主数据库日志文件的文件名分别写入到每个数据库回滚日志文件的指定位置中。
7).将所有的数据库变化持久化到数据库磁盘文件中。
8).删除主日志文件,如果在删除之前出现系统故障,进程在下一次打开该数据库时仍将基于该HOT日志进行恢复操作。因此只有在成功删除主日志文件之后,我们才可以认为该事务成功完成。
9).删除每个数据库各自的日志文件。
10).从所有数据库中删除掉排他锁和PENDING锁。
最后需要说明的是,在SQLite2中,如果多个进程正在从数据库中读取数据,也就是说该数据库始终都有读操作发生,即在每一时刻该数据库都持有至少一把共享锁,这样将会导致没有任何进程可以执行写操作,因为在数据库持有读锁的时候是无法获取写锁的,我们将这种情形称为"写饥饿"。在SQLite3中,通过使用PENDING锁则有效的避免了"写饥饿"情形的发生。当某一进程持有PENDING锁时,已经存在的读操作可以继续进行,直到其正常结束,但是新的读操作将不会再被SQLite接受,所以在已有的读操作全部结束后,持有PENDING锁的进程就可以被激活并试图进一步获取排他锁以完成数据的修改操作。
五、SQL级别的事务控制:
SQLite3在实现上确实针对锁和并发控制做出了一些精巧的变化,特别是对于事务这一SQL语言级别的特征。在缺省情况下,SQLite3会将所有的SQL操作置于antocommit模式下,这样所有针对数据库的修改操作都会在SQL命令执行结束后被自动提交。在SQLite中,SQL命令"BEGINTRANSACTION"用于显式的声明一个事务,即其后的SQL语句在执行后都不会自动提交,而是需要等到SQL命令"COMMIT"或"ROLLBACK"被执行时,才考虑提交还是回滚。由此可以推断出,在BEGIN命令被执行后并没有立即获得任何类型的锁,而是在执行第一个SELECT语句时才得到一个共享锁,或者是在执行第一个DML语句时才获得一个保留锁。至于排它锁,只有在数据从内存写入磁盘时开始,直到事务提交或回滚之前才能持有排它锁。
如果多个SQL命令在同一个时刻同一个数据库连接中被执行,autocommit将会被延迟执行,直到最后一个命令完成。比如,如果一个SELECT语句正在被执行,在这个命令执行期间,需要返回所有检索出来的行记录,如果此时处理结果集的线程因为业务逻辑的需要被暂时挂起并处于等待状态,而其它的线程此时或许正在该连接上对该数据库执行INSERT、UPDATE或DELETE命令,那么所有这些命令作出的数据修改都必须等到SELECT检索结束后才能被提交。
这是该系列中最后一篇关于SQLite理论与应用方面的博客了,后面将发布两篇关于如何利用SQLite编程的博客,其中还将包含四个典型的应用代码示例,望大家继续保持关注。