SQLite3 文件锁和并发

SQLite3 文件锁和并发

原文地址

1.0 SQLite3的文件锁及并发

SQLite Version 3.0.0介绍了一种新的锁和日志机制,为了提升SQLite 2的并发性,以及减少“写饥饿”的问题。新机制还允许涉及多数据库文件的事务原子提交。这篇文档描述了这种新机制。目标读者是想理解,想修改pager模块代码,以及致力于验证SQLite 3设计的程序员。

2.0 概述

锁和并发控制是由pager模块处理的。pager模块负责SQLite的”ACID”( 原子性,一致性,隔离性以及持久性)。pager模块保证确保以下所有更改同时发生:(1)要么所有的变化都发生,要么它们都不发生;(2)两个或多个进程不会同时尝试以不兼容的方式访问数据库;(3)一旦编写了更改,它们就会一直存在,直到显式删除。此外,pager还提供了磁盘文件一些内容的内存缓存。

3.0 锁

从单进程角度来看,一个数据库文件可以是以下五种锁状态之一:

锁状态描述
UNLOCKED(无锁)在数据库中没有锁被持有。这个数据库可能既没有被读取也没有被写入。任何内部缓存的数据都被认为是可疑的,并且在使用之前要对数据库文件进行验证。只要进程的锁状态被允许,那么他们就可以读写数据库。这是默认状态。
SHARED(共享锁)数据库可能在读但是没有被写。因为同时可以允许任意多个进程持有SHARED锁,因此会有多个同步读。但是,当一个或多SHARED锁处于活动状态时,不允许其他线程或进程向数据库文件写入。
RESERVED(保留锁)RESERVED锁意味着,一个进程准备在未来的某个时间点向数据库文件写入,但是当前只能从数据库文件读取。虽然多个SHARED锁会和一个RESERVED锁同时存在,但是一次只能激活一个RESERVED锁。
PENDING(挂起锁)PENDING锁意味着,持有锁的进程想尽可能地向数据库写入,而且仅仅在等待所有的SHARED锁被清理,以便能获得EXCLUSIVE锁。如果PENDING锁处于活动状态,则数据库不允许使用新的SHARED锁,但允许继续使用现有的SHARED锁。
EXCLUSIVE(互斥锁)EXCLUSIVE锁是为了能够向数据库文件写入才被需要的。在数据库文件中只允许一个EXCLUSIVE锁,并且不能合其他任何类型的锁同时存在。为了并发最大化,SQLite致力于EXCLUSIVE持有时间的最小化。

操作系统接口层知道并跟踪以上描述的五种锁状态。pager模块只跟踪其中的四种锁状态。PENDING锁只是一种锁转换状态的临时垫脚石,所以pager模块没有跟踪PENDING锁。

4.0 回滚日志

当一个进程试图改一个数据库文件时(并且进程不是WAL模式),它首先会在回滚日志中记录原先未改变的数据库内容。回滚日志是一种普通的磁盘文件,它总是和数据库文件在相同的目录或者文件夹中,并且和数据库文件同名,但是会有额外的-journal结尾。回滚日志还记录数据库初始大小,以便于如果数据库文件增长,可以在回滚时将其截断回原来的大小。

如果SQLite同时和多个数据库工作(使用ATTACH命令),那么每个数据库会有自己的回滚日志。但是还会有一个叫master journal的独立的集合日志。主日志不包含用于回滚改变的分页数据。相反,主日志包含每个ATTACHed数据库的单个数据库回滚日志的名称。每个独立的数据库回滚日志还也包含主日志的名字。如果没有ATTACHed数据库(或者如果ATTACHed数据库正在参与当前事务),那么没有创建主日志,正常的回滚日志为了记录主日志名称,在通常保留的地方的位置包含一个空字符串。

如果回滚日志为了恢复它的数据库的完整性,那么回滚日志就被说成是“hot”。当一个进程处在数据库更新的中间状态,并且程序或者操作系统崩溃,或者电源故障阻止更新完成,那么hot日志就会被创建。hot日志是一种异常情况。hot日志是为了从崩溃或者电源故障恢复而存在的。如果每件事都正确工作(意思是如果没有崩溃或者电源故障),那么你从来不会得到hot日志。

如果没有主日志有参与,那么如果hot日志存在,并且有一个非零的头部,相对应的数据库文件没有RESERVED锁,那么日志就是hot的。重要的是要理解什么时候日志是hot的,所以前面的规则会在子弹中重复:
一个日志是hot,如果:

  1. 日志存在,且
  2. 它的大小比512字节大,且
  3. 日志头部是非零,格式正常,且
  4. 主日志存在或者主日志名称是空字符串,且
  5. 在相应的数据库文件上没有RESERVED锁。

4.1 处理hot日志

从数据库文件读取前,SQLite总是会检查看数据库文件是否有hot日志。如果数据库文件没有hot日志,那么在数据库文件读取前日志会回滚。用这个方法,我们确保数据库文件在读取前是连续状态。
当一个进程试图从数据库文件读取时,它会遵循以下步骤序列:
1. 打开数据库文件,获取一个SHARED锁。如果SHARED锁不能被获取,那么会立即返回失败SQLITE_BUSY。
2. 检查数据库文件是否有hot日志。如果数据库文件没有hot日志,那么会创建一个。立即返回。如果有hot日志,那么日志必须根据这个算法的子序列步骤进行回滚。
3. 获取一个PENDING锁,然后获取数据库文件上的EXCLUSIVE锁。(注意:不是获取RESERVED锁是因为这会让其他进程认为日志不再hot。)如果我们获取这些失败,那么意味着有另外的进程早已经在尝试回滚。在这种情况下,丢弃所有的锁,关闭数据,然后返回SQLITE_BUSY。
4. 读取日志文件,并且回滚改变。
5. 等待回滚的改变写入持久存储中。在电源故障或者崩溃情况下,这就保护了数据库的完整性。
6. 等待日志文件(或者如果PRAGMA日志模式为TRUNCATE,那么把日志文件截断为0字节大小,或者如果[PRAGMA日志模式被设置为PERSIST]。(https://www.sqlite.org/pragma.html#pragma_journal_mode)就将日志归零。)
7. 如果安全的话,删除主日志文件。这个步骤是可选的。这里只是为了防止陈旧的主日志在磁盘驱动器上混乱。可看下面的额细节讨论。
当以上算法成功完成后,就能进程就能安全地从数据库文件读取了。一旦所有读取完成,那SHARED锁就会被丢弃。

4.2 删除过期日志

过期主日志是一种不再被用于做任何事的主日志。不再需要的过期主日志会被删除。这么做的唯一原因是为了释放磁盘空间。
如果没有单个文件指向逐日之,那么这个主日志就是过期的。弄清楚一本主日记是否过期,我们首先读取主日志获取它包含的全部日志的名称。然后我们检查这些日志文件。如果有任何日志文件的名称在主日志里并且指向了主日志,那么主日志就不是过期的。如果所有的日志文件日志都不存在,或者指向其他的主日志,或者完全没有主日志,那么我们正在测试的主日志就是过期,可以安全删除。

5.0 写入数据库文件

为了写入数据库文件,一个进程必须先按照以上锁描述的那样获得一个SHARED锁(如果有hot日志,那么可能要回滚不完整的改变)。当获得SHARED锁后,那么RESERVED锁必须被获取。RESERVED锁表明,进程试图在未来某个时间点向数据库写入。一次只能一个进程获取RESERVED锁。当RESERVED锁被持有时,其他进程继续读取数据库。

如果想写入数据库的进程不能够获取到RESERVED锁,那么这一定意味着有另外的进程早已获得RESERVED锁。在那种情况下,写入企图会失败,并且返回SQLITE_BUSY。

当获取到RESERVED锁后,想向数据库写入的进程会创建一个回滚日志。日志的头部用数据库文件的原始大小初始化。日志头部的空间还是会为主日志名称保留,虽然此时主日志名称被初始化为空字符串。

在改变数据库某分页以前,进程会将那个分页的原始内容写入到回滚数据库。分页的改变部分首先会在内存中存在,不会立即写入到磁盘中。

最后,写过程会想要更新数据库文件,要么是因为它的内存缓存已经被填满,要么是因为它准备提交它的更改。这个发生前,写入者必须保证没有其他进程正在读取数据库,并且回滚日志数据在磁盘上是安全的,以便它可以在电源故障的情况下回滚不完整的改变。步骤如下:
1. 保证所有的回滚日志数据已实际写入到磁盘上(并且仅仅被操作系统持有或者磁盘控制器缓存)以便如果电源故障发生,那么电源恢复后数据依然存在。
2. 获取PENDING锁,然后再数据库文件上获取EXCLUSIVE锁。如果其他进程仍然有SHARED锁,那么其他写入者在能够获取EXCLUSIVE锁之前,可能必须等待知道这些SHARED锁清除。
3. 将当前在内存中持有的所有分页的修改写入到原始数据库磁盘文件中。

如果写入到数据库文件的原因是因为内存缓存满了,那么写入者不会立即提交。相反,写入者可能会继续改其他的分页。在子序列的变化被写入到数据库文件之前,回滚日志必须重新刷新到磁盘。还要注意,写入者为了写入数据库,它获取到的EXCLUSIVE锁必须持有,直到所有的改变都已提交。这意味着从内存缓存首次溢出到磁盘直到事务提交为止,没有其他进程能够访问数据库。

当写入者准备提交它的更改时,它会执行以下步骤:
4. 获取数据库文件上的EXCLUSIVE锁,并且使用以上的1-3步保证所有内存更改已经写入到数据库文件。
5. 刷新所有的数据库文件更改到磁盘。
等待这些更改实际写入到磁盘面上。
6. 删除日志文件。(或者如果PRAGMA日志模式是TRUNCATE或者PERSIST,阶段日志文件或者将日志文件头部置零。)当更改被提交时立即生效。在删除日志文件之前,如果电源故障或者崩溃发生,那么下个打开数据库的进程将会看到数据库文件有个hot日志,那么它会将更改进行回滚。当日志被删除后,就不再有hot日志,并且更改也会被持久化。
7. 丢弃数据库文件的EXCLUSIVE和PENDING锁。

只要PENDING锁从数据库文件释放,那么其他进程就会再次开始读取数据库。在当前实现中,RESERVED锁也会被释放,但是对于正确的操作没有必要。

如果一个事务涉及到多个数据库,那么更复杂的提交序列会被用到,如下:
4. 保证所有的独立数据库文件都有一个EXCLUSIVE锁和一个有效日志。
5. 创建主日志。主日志的名称随意。(当前实现中,会在主数据库文件的名字后面添加一个随机的后缀,直到找到一个先前不存在的名字。)填充将拥有所有独立日志的主日志,然后刷新它的内容到磁盘。
6. 将主日志的名字写入到所有单独的日志(为达到这个目的而在个别日志的标题中留出的空间),并且刷新独立日志的内容到磁盘,等待这些更改到达慈攀棉。
7. 刷新所有的数据库文件更改到磁盘。等待这些更改被实际写入到磁盘面上。
8. 删除所有主日志文件。当变更都提交时立即生效。326/5000
在删除主日志文件之前,如果发生电源故障或崩溃,则认为单个文件日志是热的,并将在下一个试图读取它们的进程中回滚。主日志被删除后,文件日志将不再被认为是热的,更改将持续存在
9. 删除所有单独日志文件。
10. 丢弃所有数据库文件的EXCLUSIVE和PENDING锁。

5.1写入者饥饿

在SQLite 2上,如果许多进程都是从数据库读取,那么它会出现:没有活跃的读取者。如果在数据库上总是有至少一个读取锁,那么没有任何进程能够更改数据库,因为不可能获取到写入锁。这种情况就叫做写入者饥饿

SQLite3通过PENDING锁的使用来避免写入者饥饿。PENDING锁允许已存在的读取者继续,但是会阻止正在连接数据库的新读取者。所以当一个进程想写入一个忙碌的数据库,那么它可以设置一个PENDING锁,这个锁会阻止新的读取者进入。假设存在多个读取者做最后的完成,所有的SHARED锁都将被清除,并且写入者将被给予机会做更改。

6.0 如何破坏你的数据库

pager模块非常健壮,但它可以被破坏。本节试图识别和解释风险。(可以看看Atomic Commit文章中的 Things That Can Go Wrong

显然,一个硬件或者操作系统错误将会造成错误,这个错误引入了不正确的数据到数据库文件或者日志中间。类似的,如果一个流氓进程打开一个数据文件或者日志,然后写入非法格式的数据到数据库文件或者日志中,那么数据库会被破坏。对于这类问题,我们无能为力,因此没有给予更多的关注。

在Unix上SQLite使用POSIX警报锁来实现锁。在Windows上,它使用LockFile(),LockFileEx()UnlockFile()系统调用。SQLite假设这些系统调用按所声明的那样工作。如果不是这样的话,那么数据就会被破坏。我们应该注意到,POSIX咨询锁在许多NFS实现上都存在bug,甚至没有实现(包括Mac OS X最近的几个版本),还Windows下的网络文件系统的锁问题的报告。最好的防御是不在网络文件系统的文件上使用SQLite。

在Unix下,SQLite使用fsync()系统调用刷新数据到磁盘,而在Windows下使用FlushFileBuffers()做相同的事情。再次声明,SQLite嘉定这些操作系统调用服务函数都能按声明的那样工作。但是有报告反映fsync()和FlushFileBuffers()不会总是正确地工作,尤其是在便宜的IDE磁盘上。明显的是,一些IDE磁盘的生产厂商的控制芯片报告称,数据已达到磁盘面,然后试试是数据还停留在磁盘电子器件上的易变的内存缓存上。还有报告称,Windows有时候会因为不确定的原因而选择忽略FlushFileBuffers。作者不能验证这些报告的问题。但是如果他们存在,那么这意味着数据库破坏是可能性。有硬件或者操作系统的bugs,SQLite也无法防御。

如果一个Linux的ext3文件系统在/etc/fstab里没有”barrier=1”下被挂载,并且磁盘驱动写缓存启用,那么文件系统崩溃接下来会引起功耗损失或者系统崩溃。无论是否有崩溃发生,都依赖于磁盘公职硬件的细节;崩溃容易在消费级磁盘上发生,在企业级存储设备上很少发生,因为有一些像非易失性缓存等高级功能。各个ext3专家都证实了这种行为。我们被告知,大多数Linux发行版没有使用barrier=1,以及没有启动写缓存,所以大多数Linux发行版都很容易遇到这个问题。注意,这是操作系统和硬件的问题,SQLite对此无能为力。其他的数据库引擎也会有同样的问题。

如果崩溃或者电源故障引起并导致hot日志,但是日志被删除了。那么下一个打开数据库的进程不知道它需要包含哪些需要回滚的更改。回滚就不会发生,数据库停留在非一致性状态。回滚日志会因为各种原因被删除:

  • 在操作系统崩溃或者电源故障后,一个管理员可能会进行清理,看到了日志文件,认为这是个垃圾文件,然后就删除了。
  • 如果数据库文件具有别名(硬链接或软链接),并且文件使用与创建日志不同的别名打开,那么将不会找到日志。为了避免这个问题,你不应该给SQLite数据库文件创建链接。
  • 电源故障后的文件系统损坏可能导致日志被重命名或删除

上面的最后(第四个)项值得进一步评论。当SQLite在Unix上创建日志文件时,它打开包含那个文件的目录,然后在那个目录上调用fsync(),将目录信息推送到磁盘。但是假设有一些其他的进程正在添加或者删除那个包含数据库和日志的目录下的不相关的文件时,刚好遇到电源故障。另一个进程的不相关操作可能导致日志文件从目录中删除,并移动到“lost+found”中。这看似是个不可能的场景,但是实际上是会发生的。最好的做法是使用日志文件系统或者将数据库和日志单独保存在一个目录中。

对于涉及多个数据库和一个主日志的提交,如果各个数据库在不同的磁盘组,并且在提交时刚好电源故障,那么当机器恢复当机器重新启动时,磁盘可能会重新安装上不同的名称。或者一些磁盘可能完全就不挂载了。当这种情况发生的时候,单独的文件日志和主日志可能不能互相找到。这种情况的最坏结果是提交不再是原子性了。一些数据库可能会回滚,而有些不会。所有的数据库将会继续自我一致。为了防御这种情况,在电源故障后,请让在相同磁盘组或者重新挂载的磁盘上的所有数据库使用相同的名称。

7.0 SQL级别的事务控制

SQLite 3中对锁定和并发控制的更改还在SQL语言级别的事务工作方式中引入了一些微妙的更改。默认情况下,SQLite 3是以autocommit模式操作的。在autocommit模式下,只要与当前数据库连接相关联的所有操作完成,就会提交对数据库的所有更改。

SQLite命令”BEGIN TRANSACTION”(TRANSACTION关键字是可选的)被用于从自动提交模式取出SQLite。注意,BEGIN命令没有获取在数据库上的任何锁。BEGIN命令后,当第一条SELECT语句被执行时,将会获取SHARED锁。当第一条INSERT,UPDATE或者DELETE语句被执行时将会获取到RESERVED锁。在内存缓存填满并必须溢出到磁盘或事务提交之前,不会获得独占锁。这样,系统会延迟对文件文件的读访问,直到最后一刻

SQLite的“COMMIT”命令实际上不会提交任何变更到磁盘。仅仅只是返回自动提交。然后,在命令结束时,常规的自动提交逻辑接管并导致实际提交到磁盘。SQL命令“ROLLBACK”也通过启用自动提交来操作,但是它也设置了一个标志,告诉自动提交逻辑回滚而不是提交。

如果SQL COMMIT命令打开autocommit,然后autocommit逻辑试图提交更改,但由于其他进程持有共享锁而失败,则自动关闭autocommit。这允许用户在共享锁有机会清除后的稍后时间重新尝试提交。

如果同时对同一个SQLite数据库连接执行多个命令,则自动提交将推迟到最后一个命令完成时执行。例如,如果正在执行SELECT语句,当返回结果的每一行时,命令的执行将暂停。在此暂停期间,可以对数据库中的其他表执行其他插入、更新或删除命令。但是,在原始SELECT语句完成之前,这些更改都不会提交。

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值