事务型数据库(如 SQLite)的一个重要特性就是“原子性提交”。 原子性提交意味着在一次事务中,所有对数据库的改变或者全部发生, 或者什么也不做。通过原子性提交, 就好像对数据库文件中很多不同部分的写入同时、立即发生。 真正的硬件会将数据连续地写入磁盘,并且每写一个扇区都需要一定的时间。 所以,不可能真正实现多个不同的扇区立即、同时的写入。 但 SQLite 中的原子性可以让所有的写操作看起来都是立即地、同时地。
即使事务由于操作系统崩溃或电源故障而被打断,SQLite 都能确保事务的原子性。
本文描述了 SQLite 中用于创建原子性提交这一魔术的的技术。
在整个文章中,我们将大容量存储设备称作“磁盘”, 即使用于存储的设备可能是闪存我们也这么叫。
同时我们也假设磁盘是以块为单位存取的,我们称之为“扇区”。 修改小于一个扇区的任何一部分都是不可能的。如果确实需要修改扇区的一部分, 那么你必须读入整个扇区,在内存中进行修改,然后再将整个扇区的数据写回的磁盘中。
对于普通的旋转磁盘来说,扇区是在读和写中传输数据的最小单位。 但在闪存中,最小的读单位通常会比最小的写单位要小。 SQLite 只关心最小的写单位,所以,在本文中, 当我们说“扇区”时,我们指在一次写操作中可以向存储设备中写入的最小数据量。
在 SQLite 3.3.14 之前,在任何情况下都假设一个扇区是 512 字节。 在编译时可以修改一个选项来改变该值,但我们从来都未使用更大的值进行过测试。 由于所有的磁盘驱动器都在内部使用 512 字节的扇区, 所以,在这里我们使用 512 字节也是合情合理的。 不过,最近,一些需求倾向于将这一值增大到 4096 字节。 并且闪存中的扇区也通常会大于 512 字节。 鉴于这些原因,从 SQLite 3.3.14 开始, 在操作系统层增加了一个函数,用于与底层的文件系统进行协商, 以确实扇区的大小。 在当前的版本(3.5.0)实现中,该函数仍然返回一个硬编码的 512 字节。 这是因为在 WIN32 或 UNIX 上,没有标准的方法来获得真正的扇区大小。 但通常在嵌入式设备中存在这样的方法,因为那些厂商自己也需要使用该函数。 同时,我们也留下了一下开放的可能性-未来可能在 UNIX 和 WIN32 上有更有意义地实现方式。
SQLite 并不 假设扇区的写是原子的。 但是,它确实假设扇区的写入是“线性的”。 线性是说,当 SQLite 向一个扇区写入数据时,是从硬件地址的一头写入, 一个字节一个字节地直到另一头。写入可以是从头到尾, 也可以从尾到头。如果在写扇区的中间出现了电源故障, 可能会出现扇区的一部分被修改,而剩余的部分没有改变。 SQLite 关键的假设是,如果一个扇区中任何部分被改变, 那么,第一个或者是最后一个字节都将会改变。 这样,硬件是不会在一个扇区的中间开始写数据。 我们还不知道这种假设是否正确,但看起来是合理地。
上一段中说明 SQLite 不假设扇区的写的是原子地。 这在默认情况下是正确的。但在版本 3.5.0 中, 有一个新的接口叫虚拟文件系统(VFS)接口。 VFS 只是表示 SQLite 与底层的文件系统通信。 代码中有 WINDOWS 和 UNIX 平台上默认的 VFS 实现, 但也提供了一个机制,利用该机制可以在运行时创建新的个性化的 VFS 实现。 在新的 VFS 接口中,有一个函数叫做 xDeviceCharacteristics。 该函数与底层的文件系统协商很多其支持或不支持的特性和行为。 该函数可能会指出扇区写是原子地,并且,如果它真的这样指出, 那么 SQLite 将会利用这一事实带来的便利。 但在默认情况下对于 UNIX 和 WINDOWS,该函数不会指示扇区的原子性写入, 所以通常优化器会忽略它。
SQLite 假设操作系统会对写进行缓冲,这样,一个写操作会正常结果并返回, 但实际的数据可能没有真正写到磁盘上。 进而,SQLite 会假设这些写操作会由操作系统重新组织。 由于这一原因,SQLite 会在一些关键点执行一些 flush 或 fsync 操作 。 SQLite 假设 flush 或 fsync 直到将所有写操作中的数据完全写入磁盘文件中 才会返回。有人告诉我们,在某些版本的 windows 和 linux 上, flush 和 fsync 可能不正确。这非常不幸。因为它增加了 SQLite 在一个事务中由于电源故障而引起数据库文件损坏的可能性。 但是,SQLite 没有任何办法来检测或修复这种情况。 SQLite 假设它底层的操作系统以期望的方式运行。 如果不是这样的话,那么希望不的机器不会经常断电。
SQLite 假定当一个文件长度增长时, 新文件空间中的垃圾会被实际的数据填满。 也就是说,SQLite 假设文件的大小先改变,然后才是文件的内容。 这是一个悲观的假设。SQLite 不得不再做一些额外的工作, 以确保在文件大小改变后,文件内容写入之前的这段时间里, 电源故障不会引起数据库文件的损坏。 VFS 的 xDeviceCharacteristics 函数可能会指出文件系统将永远是先写入数据, 然后再改变文件的大小(对于那些查看源代码的人来说,这是 SQLITE_IOCAP_SAFE_APPEND 属性)。 当 xDeviceCharacteristics 函数指明文件内容先于文件长度写入时, SQLite 可以放弃一些自以为是的数据库保护步骤, 从而减少需要执行一个提交操作的磁盘I/O。 但在当前的实现中,对于 windows 和 unix 上默认的 VFS 未做任何假设。
SQLite 假设从一个用户进程来看,一个文件的删除是原子的。 该假设的意思是-如果 SQLite 想删除一个文件, 但在删除文件的过程中停电了,一旦电力恢复, 那么文件或者完全存在(未丢失任何内容), 或者在文件系统中完全不存在。如果由于停电导致一个文件只删除了一部分, 或者文件内容已被清空但文件没有被完全删除, 那么也会出现数据库文件损坏。
SQLite 假设检测和(或)修复那些由于宇宙射线、热噪声、 量子起伏、设备或驱动程序 BUG,或另外一些机制所引起的比特流错误 是底动的硬件和操作系统的责任。 SQLite 不会为数据库文件在任何以检错和纠正为目的的或I/O错误增加任何冗余。 SQLite 假设它如读的数据就是它先前写入的数据。
我们开始来看一个 SQLite 在单个文件上, 为完成一个原子性的数据库事务提交所要经过的步骤。 在此,我们仅做一个一般性的概括。 有关用于在多个文件上执行原子提交时, 防止电源故障引起失败的技术的详细文件格式我们将在以后的章节中讨论。
当一个数据库连接刚刚打开时,计算机状态的概念显示见如右图。 图中,最右边标有“DISK”的区域表示存储在磁盘中的信息。 其中的每一个矩形都代表一个扇区。蓝色代表扇区中包含原来的数据。 中间的区域是操作系统的磁盘缓冲区。在本例中,白色代表缓冲区是空的。 左边的区域用于显示 SQLite 进程所用的内存中的内容。 数据库刚刚连接,此时还没有读入任何数据,所以暂时用户空间是空的。
在 SQLite 写入数据库之前,它必须先读取数据库,来确定数据库是否已经准备好。 即使刚刚追加了新的数据,SQLite 也必须从 sqlite_master 表中读取数据库的结构。这样,它才能知道如何来分析一个 INSERT 语句并确实新的信息要存储在数据库文件的什么地方。
从数据库文件中读的第一步要要在获得数据库文件上的一个共享锁。 一个共享锁允许两个或多个数据库连接同时从数据库文件中读。 但它在读时,禁止其它的数据库连接进程对数据库写入。 这样做是必须的。因为,如果在我们读的同时有另一个进程在写数据, 我们可能会读到其它进程写入之前或之后的数据, 这样看起来其它进程的写操作可能是非原子性的。
注意,写锁是加在操作系统磁盘缓冲区上的,而不是加上磁盘文件本身上。
文件锁也通常仅仅是操作系统内核中的一些符号标志。
(详细情况依赖于特定的操作系统层接口。)而且,
在系统崩溃或停电的情况下,锁将立即消失。
通常,创建锁的进程退出时,锁也会自动消失。
获得共享锁之后,我们就可以开始从数据库文件中读取信息了。 在这里,我们假设有一个空的缓冲区。所以, 信息必须先从磁盘读到操作系统缓冲区, 先后再传递给用户空间。在以后的读操作中, 其中的一些或者全部的信息都可能在缓冲区中找到, 只需要传递给用户空间就可以了。
通常,只会读取一个数据库中的部分页。
在本例中,我们显示读取 8 页中的 3 页。
在一个典型的应用中,一个数据库将会有数在千计的页,
并且通常一个查询只会接触到很少的比例。
在改变数据库之前,SQLite 先在数据库文件上获得一个保持锁。 保持锁跟共享锁类似,它们都允许其它进程同时读数据库文件。 一个保持锁可以与其它进程的多个共享锁共存。但是, 一个数据库文件上只能有一个保持锁。而且, 在同一时间,只有一个进程可以试图对数据库进行写操作。
一个保持锁的思想是,它表示一个进程将要对数据库进行修改,
但还没有开始。并且由于还没有开始修改,其它的进程仍然能够继续读数据库。
但是,别的进程就不能再试图写数据库了。
在对数据库文件进行任何改变之前,SQLite 先创建一个回滚日志文件, 并且将等修改的数据库页写入回该文件中。使用回滚日志的思想是 用它存储足够的信息,以备将数据库变回原来的状态。
回滚日志包含一个小的文件头(在图中以绿色表示), 它记录数据库文件的原始大小。所以,如果对数据库的改变引起数据库文件增长, 我们仍然可以知道它原来来的长度。每一个数据库页的页号也同时存储在回滚日志中。
当新的文件创建后,很多桌面操作系统都不会真正身磁盘中写任何数据。
新文件只存在于操作系统磁盘缓存中。如果以后操作系统有时间,
会将数据库到磁盘上去。这会给用户留下很好的印象-
磁盘 I/O 发生时,比它实际需要的时间要快。在这里,
我们用右图来描述该思想-已建立了新的回滚日志,
但它仅存在于操作系统磁盘缓冲区中。
当数据库页中原来的内容都写入回滚日志后,
这些页就可以在内存中用户空间中修改了。所以,
它在用户内存中修改后的内容也只有该用户自己可见。
其它的数据库连接仍然看到我们没有修改的在操作系统缓冲区中的数据。
所以,即使一个进程忙于修改数据库,
其它进程仍然可以继续读取它们自己从数据库中读取的拷贝。
下一步是将回滚日志文件写到非易失的存储设备(磁盘)上去。 像我们后面看到的一样,这是一个非常重要的步骤。 它能保证在遇到非期望的停电时数据库文件能恢复到原来的样子。 该步骤与会消耗不少的时间,因为往非易失的存储设备写数据通常是很慢的操作。
该步骤不是简单地将回滚日志写到磁盘那么简单,而通常会很复杂。
在大多数的平台上,需要两个 flush() (或 fsync() )操作。
第一个 flush 写基本的回滚日志内容,接下来回滚日志的 header
会被改变,以指示有多少页已写入日志文件,接下来会将 header 也写入磁盘。
有关为什么要改变 header 且做一次额外的 flush 的信息可在以后的章节中找到。
在对数据库文件进行修改之前,我们必须先获得一个排它锁。 实际上,获取一个排它锁是一个两阶段的过程。 首先,SQLite 获取一个“待决”(pendding)锁。 然后,待决锁会升级为一个排它锁。
一个待决锁允许其它已持有共享锁的进程继续读取数据库文件。 但不允许其它进程再申请共享锁。 这种做法是为了防止由于大量的读者引起的写饥饿。 同时,可能会有几十甚至上百个进程试图读取数据库文件。 每一个进程获取一个共享锁并开始读。读完后将锁释放。 但是,如果有好多不同的进程都在读同一个数据库, 可能会发生这样的情况-在一个进程未释放共享锁之前, 又有新的进程获得共享锁。那么,数据库中就不会出现没有共享锁的情况, 所以,写进程就没有机会占有一个排它锁。 待决锁的设计就是用于通过允许已持有共享锁的进程继续运行, 而阻止新的进程再占用共享锁来防止出现这种循环。 最后,所有的共享锁都会被释放,而此时, 被持有的待决锁则升级为一个排它锁。
一旦成功持有一个排它锁,我们就知道此时没有其它进程读数据库文件了, 这时,就可以安全地将改变写入数据库文件了。 通常,这些改变只会非常快地写入操作系统缓存中, 而不管是否真正写到磁盘中。
之后,必须启动另一个 flush 操作以确保所有改变都真正写到非易失性的磁盘中去。 这是至关重要的一环,它保证数据库在断电时不会引起损坏。 但是,由于写入磁盘或闪存固有的速度问题, 该步骤跟 3.7 节中的写回滚日志一样,会占用一个事务提交的大部分时间。
当对数据库的改变会部安全地写到磁盘上后,回滚日志就可以删除了。 这会在事务提交后立即执行。如果在这时发生系统崩溃或断电, 那么后来的系统恢复进程会进行处理,使用它数据库文件看起来跟没发生任何改变一样。 如果崩溃发生在回滚日志删除之后,那么它它会保留所有的改变。 如此,SQLite 就会根据回滚日志是否存在, 来确定所有对数据库的改变或者没有任何作用,或者全部都写到了数据库文件中。
实际上删除一个文件不是一个原子操作,但在一个用户进程来看, 它就像是一个原子操作。一个进程总是可以询问操作系统: “该文件存在吗?”同时进程会得到一个“是”或“否”的回答。 在一个事务提交失败后,SQLite 会询问操作系统回滚日志是否存在。 如果回答是“是”,那么事务就是不完整的,就会回滚; 而如果回答是“否”,就意味着事务已经正常提交了。
事务的存在依赖于回滚日志是否存在,而对于一个用户进程来说,
删除一个文件看起来是原子性的。所以,
一个事务看起来就是一个原子操作。
提交操作的最后一步就是释放排它锁, 以允许其它进程可以再度访问数据库文件。
在后边的图中,当锁被释放是,用户空间的信息同时被清除。 对于老版本的 SQLite 来说,这通常是正确的。 但最近的版本仍保持用户空间的信息, 因为下一个事务可能还会用到它。 相对于重新从操作系统缓存或从磁盘上获取数据来说, 重用已存在于本地内存中的信息需要更少的代价。 在重用用户空间的信息之前,我们需要先获得一个共享锁, 并且需要确信在我们没有持有锁的时间里没有其它进程修改数据库文件。 在数据库的第一页会有一个计数器,当每次数据库文件被修改时它将自动增加。 我们通过检查该计数器来确定是否有其它进程修改过数据库文件。 如果数据库文件已改变,那么用户空间的缓存就需要清除并重新读入。 但在大多数情况下不会有其它进程修改数据库, 从而用户空间的缓存可以重用来获得相当有效的性能提升。
NOTE:以下章节引自: http://blog.vckbase.com/localvar/archive/2008/02/13/32581.html
原子提交看起来是瞬间完成的,但很明显,前面介绍的过程需要一定的时间才能完成。 如果在提交过程中电源被切断,为了让整个过程看起来是瞬时的, 我们必须回滚那些不完整的修改,并把数据库恢复到事务开始之前的状态。
电力恢复后日志文件是完整的,这是个关键。
3.7节中的操作就是为了保证在对数据文件做任何改变之前回滚日志的所有内容已经安全的写到持久性存储器中去了。
任何进程第一次访问数据库文件之前,必须获得一个3.2节中描述的共享锁。 然后,如果发现还有一个日志文件,SQLITE就会检查这个回滚日志是不是“热的”。 我们必须回放热日志文件,从而把数据库恢复到一致的状态。 只有在一个程序正在提交事务时发生掉电或崩溃的情况下,才会出现热日志文件。
日志文件在符合以下所有条件时才是热的:
热日志文件告诉我们:之前有进程试图提交一个事务,但由于某种原因,这个提交没有完成。
也就是说:数据库处于一种不一致的状态,使用之前必须修复(回滚)。
处理热日志的第一步是获得数据库文件上的独占锁,这可以防止两个或更多的进程同时回放一个热日志。
日志中的所有信息都回放到数据库文件,并将数据库文件刷到磁盘(回滚时可能会再次掉电)以后,就可以删除热日志文件了。
回滚的最后一步是把独占锁降级为共享锁。
此后,数据库的状态看起来就像那个中断了的事务根本没有开始过一样了。
由于整个回滚过程是完全自动、透明的,使用SQLITE的那个程序根本就不会知道有一个事务中断并回滚了。
通过 ATTACH DATABASE 命令, SQLITE允许一个数据库连接 (database connection ) 使用多个数据库文件。 当在一个事务中修改多个文件时,所有文件都会被原子的更新。 换句话说,或者所有文件都会被更新,或者一个也不会被更新。 在多个文件上实现原子提交比在单个文件上实现更复杂,本章将解释SQLITE是如何做到这一点的。
当一个事务涉及了多个数据库文件时,每个数据库都有自己回滚日志,并且对它们的锁也是各自独立的。 下图展示了三个数据库文件在一个事务中被修改的情况,它所描述的状态相当于单文件事务在第 3.6节中中的状态。 每个数据库文件有各自的预定锁,它们将要被修改的那些页的原始内容已经写进回滚日志了,但还没有刷到磁盘上。 用户内存中的数据已经被修改了,不过数据库文件本身还没有任何变化。
相比之前,下图做了一些简化。
在这张图上,蓝色仍然代表原始数据,粉红色仍然代表新数据。
但上面没有画出回滚日志和数据库的页,并且也没有明确区分操作系统缓存中的数据和磁盘上的数据。
所有这些在这张图上仍然适用,不过即使把它们画出来我们也学不到什么新的东西,所以,为了缩小图幅,我们把它们省略掉了。
多文件提交中的下一步是创建一个“主日志文件”。 这个文件的名字是最初的数据库文件名(也就是用 sqlite3_open() 打开的那个数据库,而不是之后附加上来的那些)加上后缀“-mjHHHHHHHH”。 其中HHHHHHHH是一个32位16进制随机数,每次生成新的主日志文件时,它都会不同。
(注意:上面一段中用来生成主日志文件名的方法是3.5.0版中使用的方法。 这个方法并没有规范化,也不是SQLITE对外接口的一部分,在未来版本中,我们可能会修改它。)
主日志中没有与原始数据库页面内容相关的信息,它里面保存的是所有参与到这个事务中的回滚日志文件的完整路径。
主日志生成完毕后,会被立即刷到磁盘上,中间没有任何别的操作。
在unix系统上,主日志所在的目录,也会被同步一下,以确保掉电后它也会出现在这个目录下。
下一步是把主日志的路径记录到回滚日志的文件头中去,回滚日志创建时在文件头预留了相应的空间。
主日志路径写到回滚日志文件头之前和之后,要分别把回滚日志的内容往磁盘上刷一次。 这可能有些效率损失,但非常重要,而且,幸运的是,刷第二次时一般只有一页(最开始的那页)数据有变化,所以整个操作可能并没有想象的那么慢。
这个操作大致相当于单文件提交时的第7步,也就是
第3.7节中的内容。
把回滚日志刷到磁盘上后,就可以安全的更新数据库文件了。
我们需要获得所有数据库文件上的独占锁,然后写数据,并把这些数据刷到磁盘上去。
这一步相当于单文件提交时的第
3.8、
3.9、和第
3.10
步。
下一步是删除主日志文件,这是多文件事务被实际提交的时间点。 它相当于单文件提交时的 第11步,也就是删除日志文件的那一步。
如果掉电或系统崩溃发生在这之后,重启时,即使存在回滚日志文件,事务也不会被回滚。
这里的区别在于回滚日志的文件头里面有主日志的路径。
SQLITE只认为文件头中没有主日志文件路径的回滚日志(单文件提交的情况)或主日志文件仍然存在的回滚日志是“热的”,并且只会回放热的回滚日志。
最后是删除所有的回滚日志文件,释放独占锁以便其他进程发现数据的变化。 这一步对应的是单文件提交时的 第12步。
由于事务已经提交了,所以删除这些文件在时间上并不是非常紧迫。
当前的实现是删除一个日志文件,并释放其对应的数据库文件上的独占锁,然后再接着处理下一个。
今后,我们可能把它改成先删除所有日志文件,再释放独占锁。
这里,只要保证删除日志文件在前,释放其对应的锁在后就行,文件被删除的顺序或锁被释放的顺序并不重要。
第3章从总体上介绍了SQLITE原子提交的实现方法,但漏掉了几个重要的细节,本章将对它们进行一些补充说明。
在把数据库页面的原始内容写进回滚日志时(如 第 5.5 节 所示), 即使页面比扇区小,SQLITE也会把完整的扇区写进去。 从前,SQLITE中的扇区大小是硬编码的512字节,而最小页面也是512字节,所以不会有什么问题。 但从3.3.14版开始,SQLITE也支持扇区大小超过512字节的存储器了,所以,从这一版起,当某个扇区中的任何页面被写进日志时,这个扇区中的其它页面也会被一同写进去。
掉电可能在写扇区时发生,总是记录整个扇区可以在这种情况下保证数据库不被破坏。 例如,我们假设每个扇区有四个页面,现在2号页面被修改了,为了把变化写入这个页面,底层硬件,因为它只能写完整的扇区,也会把1、3、4号页面重新写一遍,如果写操作被打断,这三个页面的数据可能就不对了。 为了避免这种情况,必须把扇区中的所有页面写到回滚日志中去。
向日志文件末尾追加数据时,SQLITE一般悲观的假设文件系统会先用垃圾数据把文件撑大,再用正确的数据覆盖这些垃圾。 换句话说,SQLITE假设文件体积先变大,之后才是写入实际内容。 如果掉电发生在文件已经变大但数据还未写入时,回滚日志中就会包含垃圾数据。 电力恢复后,另一个SQLITE进程会发现这个日志文件,并试图恢复它,这就有可能把垃圾数据拷贝到数据库文件,进而对其造成破坏。
为对付这个问题,SQLITE建立了两道防线。 首先,SQLITE在回滚日志的文件头中记录了实际的页面数。 这个数字一开始是0,所以,在回放一个不完整的回滚日志时,SQLITE会发现文件中没有包含任何页面,也就不会对数据库做任何修改。 提交之前,回滚日志会被刷到磁盘上,以保证其中没有任何垃圾。 之后,文件头中的页面数才会被改成实际的数值。 文件头总是保存在一个单独的扇区去,所以,如果在覆盖它或把它刷到磁盘上时发生掉电,其它页面是不会被破坏的。 注意回滚日志要往磁盘上刷两次:第一次是写页面的原始内容,第二次是写文件头中的页面数。
上一段描述的是同步选项设置为“full”(PRAGMA synchronous=FULL)时的情形, 这也是默认的设置。
PRAGMA synchronous=FULL;不过,当同步选项低于“normal”时,SQLITE只会刷一次日志文件,也就是修改完页面数后的那一次。 由于(大于0的)页面数可能先于其它数据到达磁盘,这样做有一定的风险。 SQLITE假设文件系统会记录写请求,所以即使先写数据后写页面数,页面数也可能会先被磁盘记录下来。 所以,作为第二道防线,SQLITE在日志文件中为每页数据都记录了一个32位的校验码。 回滚日志文件时,SQLITE会检查这个校验码,一旦发现错误,就会放弃回滚操作。 要注意的是,校验码无法完全保证页面数据的正确性,数据有错误但校验码正确的概率虽然极小,却不是零.。 不过,校验码机制至少让类似的事情看起来不那么容易发生了。
在同步选项设置为“full”时,就没有必要用校验码了,我们只在同步选项低于“normal”时才需要它。 然而,鉴于校验码是无害的,故不管同步选项如何设置,它们总是出现在回滚日志中的。
第三章描述的过程假设提交之前所有的数据库变化都能保存在内存中。 一般来说就是这样的,但特殊情况也会出现。 这时,数据库变化会在事务提交之前用完用户缓存,需要把缓存中的内容提前写入数据库才行。
操作之前,数据库连接处于第3.6步时的状态: 原始页面的内容已经保存到回滚日志了,修改后的页面位于用户内存中。 为了回收缓存,SQLITE执行第3.7到3.9步, 也就是把回滚日志刷到磁盘上,获取独占锁,然后把变化写入数据库。 但后续步骤在事务真正提交之前都有所不同。 SQLITE会在日志文件的最后追加一个文件头(使用一个单独的扇区),独占锁继续保留,而执行流程将跳到第3.6步。 当事务提交或再次回收缓存时,将重复执行第3.7和3.9步 (由于第一次回收缓存时获得了独占锁且一直没有释放,3.8步将被跳过)。
把预定锁提升为独占锁将降低并发度,额外的刷磁盘操作也非常慢,所以回收缓存会严重影响系统效率。 因此,只要有可能,SQLITE就不会使用它。
对程序的性能分析显示,在绝大多数系统和绝大多数情况下,SQLITE把绝大部分时间消耗在了磁盘I/O上。 所以,减少磁盘I/O的数量是最有可能大幅提升效率的方法。 本章将介绍SQLITE在保证原子提交的前提下,为减少磁盘I/O而使用的一些技术。
在第3.12节中,我们说过当释放共享锁时会丢弃所有已经在用户缓存中的数据库信息。 之所以这样做,是因为没有共享锁的时候其他进程能够随意修改数据库文件的内容,从而导致已经缓存的数据过时。 所以,每当一个新事务开始时,SQLITE都必须重新读一次以前读过的东西。 这个操作并不像大家想象的那么糟糕,因为要重新读的数据极有可能仍在操作系统的缓存中,所谓的“重读”一般仅仅是把数据从内核空间拷贝到用户空间而已。 不过,即使如此,也是需要一些时间的。
从3.3.14版开始,我们在SQLITE中增加了一个机制来避免不必要的重读。 这些版本中,释放共享锁后,用户缓存的页面继续保留。 等到SQLITE启动下一个事务并获得共享锁后,它会检查是否有其他进程修改了数据库文件。 如果自上次释放锁后有修改,用户缓存会被清空并重读。 但一般不会有任何修改,所以用户缓存仍然有效,这样很多不必要的读操作就被避免了。
为了判断数据库文件是否被修改,SQLITE在文件头(第24到27字节)中使用了一个计数器,每个修改操作都会递增它。 释放数据库锁之前,SQLITE会记下这个计数器的值,等到再次获得锁以后,它比较记录的值和实际的值,相同则重用已有的缓存数据,不同则清空缓存并重读。
自3.3.14版开始,SQLITE中增加了“独占访问模式”。在这种模式下,SQLITE会在事务提交后继续保留独占锁。这样一来,其他进程就不能访问数据库了。不过,由于大多数的部署方案都只有一个进程访问数据库,所以一般不会有什么问题。独占访问模式让以下三个减少磁盘I/O的方法成为了可能:
第三项优化,也就是用截断代替删除,并不要求一直拥有独占锁。 理论上说,总是实现它,而不是只在独占访问模式下实现它是可能的,也许我们会在未来版本中让其成为现实。 不过,到目前为止(3.5.0版),这项优化仍然只在独占访问模式下有效。
从数据库中删除数据时,那些不再使用的页面会被加到“空闲页表”里去。 之后的插入操作将首先使用这些页面,而不是扩大数据库文件。
一些空闲页面中也有重要数据,比如说其他空闲页面的位置等等。 但大多数空闲页面的内容没有用,我们把这些页面称为“叶页”。 修改叶页的内容对数据库没有任何影响。
由于叶页的内容没用,SQLITE不会把它们在提交过程的第3.5步中记录到回滚日志里去。 也就是说,修改叶页,但不在回滚过程中恢复它们对数据库无害。 同样的,一个新叶页的内容既不会在第3.9步中写入数据库也不会在第3.3步中被读出来。 在数据库文件有空闲空间时,这项优化大幅减少了磁盘I/O的数量。
从3.5.0版开始,新的VFS接口包含了一个名叫xDeviceCharacteristics的方法,它可以报告底层存储器是否支持一些特性。 这些特性中,有一个是“原子扇区写”。
我们前面说过,SQLITE假设写扇区是线性的,而不是原子的。 线性写从扇区的一端开始,逐字节写到另一端结束。 如果在线性写的中间发生掉电,则可能扇区的一端被修改了,另一端却保持不变。 但在原子写的情况下,扇区或者被完全更新了,或者完全没有变化。
我们相信大多数现在磁盘驱动器实现了原子扇区写。 掉电时,驱动器使用电容中的电能和(或)盘片旋转的动能完成正在进行的操作。 然而,在系统写调用与磁盘电子元件之间存在太多的层次,所以我们在Unix和windows的默认VFS实现上做了一个保守的假设,认为写扇区不是原子的。 另一方面,能对其使用的文件系统有更多发言权的设备厂商,如果它们的硬件确实支持原子扇区写,也许会选择打开xDeviceCharacteristics中的这个选项。
当写扇区是原子的、数据库页面和扇区一样大,而且数据库的变化只涉及到一个页面时,SQLITE会跳过整个记日志和同步过程,直接把修改后的页面写到数据库文件上。 数据库文件第一页上的修改计数器也会独立修改,因为即使在更新它之前掉电也是无害的。
译注:个人认为,如果硬件不支持原子扇区写,是无法在软件层次上实现绝对意义上的原子提交的。
3.5.0版加入的另一项优化措施是基于文件系统的“安全追加”功能的。 SQLITE假设向文件(特别是回滚日志文件)追加数据时,文件大小的改变早于文件内容增加。 所以,如果掉电发生在文件变大之后,数据写完之前,文件中就会包含垃圾数据。 也可以通过VFS中的xDeviceCharacteristics方法指出文件系统支持“安全追加”功能,这意味着内容的增加早于大小的改变,所以掉电或系统崩溃不可能向日志文件中引入垃圾。
文件系统支持安全追加时,SQLITE总是在日志文件头的页面数字段中填入-1,表示回滚时要处理的页面数应该根据日志文件的大小自动计算。 这个-1不会被修改,所以提交时,我们可以不用单独刷一次日志文件的第一页。 而且,当回收缓存时,也没有必要在日志文件末尾再写一个新的文件头了,我们只要继续在已有的日志文件上追加新页面即可。
我们作为SQLITE的开发者,对其在掉电和系统崩溃时的健壮性充满自信,因为,我们的自动测试过程在模拟的掉电故障下,对它的恢复能力进行了非常多的检测。 我们把这种模拟的故障称为“崩溃测试”。
崩溃测试使用了一个修改过的VFS,以便模拟掉电或崩溃时可能出现的各种文件系统错误。 它可以模拟出没有完整写入的扇区、因为写操作没有完成而包含垃圾数据的页面、顺序错误的写操作等,这些错误在测试场景的各个路径点上都会出现。 崩溃测试不停地执行事务,让模拟的掉电或系统崩溃发生在各个不同的时刻,造成各种不同的数据损坏。 在模拟的崩溃事件发生之后,测试程序重新打开数据库,检测事务是否完全完成或者(看起来)根本没有启动,也就是数据库是否处于一个一致的状态。
SQLITE的崩溃测试帮助我们发现了恢复机制中的很多小问题(现在都已经修复了)。 其中的一部分非常隐晦,单单通过代码检查和分析可能是发现不了的。 这些经验让SQLITE的开发者相信:那些没有使用类似崩溃测试的数据库系统,非常有可能包含在系统崩溃或掉电时导致数据库损坏的BUG。
虽然SQLITE的原子提交机制本身是健壮的,但它却有可能被恶意的对手或不那么完善的操作系统实现给打垮。 本章将介绍几个可能在掉电或系统崩溃时导致数据库损坏的情形。
SQLITE使用文件系统的锁来保证某一时刻只有一个进程和数据库连接可以修改数据库。 文件系统的锁机制是在VFS层实现的,并且在每种操作系统上都有所不同。 SQLITE自身的正确性依赖于这个实现的正确性。 如果它出了问题,导致两个或更多进程能同时修改一个数据库文件,肯定会严重损坏数据库。
有人向我们报告说windows的网络文件系统和(Unix的,译注)NFS的锁都有些问题。 我们验证不了这些报告,但是考虑到在网络文件系统上实现一个正确的锁的难度,我们也无法否定它们。 由于网络文件系统的效率也很低,所以我们建议你最好是避免在其上使用SQLITE。 如果一定要这么做的话,请考虑使用一个附加的锁机制来保证即使文件系统自身的锁机制不起作用时,也不会出现多个进程同时写一个数据库文件的情况。
苹果Mac OS X计算机上预装的SQLITE进行了一个扩展,可以在苹果支持的所有网络文件系统上使用一个替代的加锁策略。 只要所有进程使用统一的方式访问数据库文件,这个扩展就工作的很好。 但不幸的是,这些加锁机制是相互独立的,如果一个进程用AFP锁,另一个用点文件(dot-file)锁,那这两个进程就可能发生冲突,因为AFP锁并不能禁止点文件锁,反之亦然。
在第3.7节和3.10节中你已经看到, SQLITE要把系统缓存刷到磁盘上。 在unix系统上,这是用fsync()系统调用来完成的,windows上则是用FlushFileBuffers()。 可是,我们收到的报告显示,很多系统上的这些接口没有广告宣传的那么好。 我们听说,在一些windows版本上,通过修改注册表,可以完全禁用FlushFileBuffers();而linux的某些历史版本中的fsync仅仅是个什么也不干的空操作。 我们还知道,即使是在FlushFileBuffers()或fsync()可以正常工作的系统上,IDE磁盘控制器也经常会在数据仍处在自己的缓存中时,撒谎说数据已经到达磁盘表面了。
在苹果的系统上,如果你把fullsync选项打开(PRAGMA fullsync=ON),它可以保证数据确实刷到磁盘上了。
PRAGMA fullfsync=ON;Fullsync本身就很慢,而fullsync的实现还需要重置磁盘控制器,这会让其他根本不相关的磁盘I/O也变慢,所以我们不建议你这样做。
SQLITE假设从用户程序的角度看文件删除是原子操作。 如果删除文件时掉电,电力恢复后,SQLITE期望这个文件或者不存在,或者是一个完整的、和删除前一模一样的文件。 如果操作系统做不到这一点,事务就有可能不是原子的。
SQLITE的数据库文件是普通的文件,其它用户程序也可以打开它并任意的往里面写数据,一些流氓程序就可能这样做。 垃圾数据的来源也可能是操作系统或磁盘控制器的BUG,尤其是那些会在掉电时触发的BUG。 对此类问题,SQLITE无能为力。
如果发生了掉电或崩溃,并且生成了热日志文件,那么,在另一个SQLITE进程打开它和数据库文件并完成回滚之前,这两个文件的名字绝对不能改变。 在第4.2步时,SQLITE会在打开的数据库文件所在的目录下,寻找热日志文件,这个文件的名字是从数据库文件名派生而来的。 所以,只要这两个文件中的任何一个被移走或改名,就会找不到热日志,也就不会进行回滚。
我们认为SQLITE恢复过程的失败模式一般是这样的:发生了掉电;电力恢复后,一位好心的用户或者系统管理员开始清点损失;他们发现有一个名为“important.data”的文件,他们可能很熟悉这个文件,所以没有对其进行任何操作;但崩溃后,磁盘上还有一个名为“important.data-journal”的热日志文件,用户把它删除了,因为他们认为这个文件是系统中的垃圾。 防止此类事件的唯一方法可能就是加强用户教育了。
如果有多个链接(硬链接或符号链接)指向一个数据库文件,那么生成的日志文件会依据打开数据库文件时使用链接名来命名。 如果发生了崩溃,并且下次打开数据库时使用了另一个链接,则也会因为找不到热日志文件而不进行回滚。
某些时候,掉电会导致文件系统出错,以致新更改的文件名无法记录,这时,文件就会被移动到“/lost+found”目录下。 为防止此类错误,SQLITE会在同步日志文件的同时,打开并同步一下这个文件所在的目录。 但是,一些八竿子打不着的程序,在数据库文件所在目录下创建其他文件的操作,也可能会导致文件被移动到“/lost+found”里去,这是SQLITE控制不了的,所以SQLITE对它也没什么办法。 如果你正在使用此类名字空间易被损坏的文件系统(我们相信大多数现代的日志文件系统没有此问题),我们建议你把SQLITE的数据库文件放在单独的子目录中。
不论是过去还是现在,总有人能发现一些SQLITE原子提交机制的失败模式,开发者也不得不为此做一些补丁。 但这类事情发生的已经越来越少了,失败模式也变得越来越隐晦。 不过,如果藉此认为SQLITE的原子提交逻辑已经无懈可击了,肯定是相当愚蠢的。 开发者们能承诺的只是尽量快速的修复新发现的BUG。
同时,我们也在寻找新的方法来优化这个提交机制。 在Linux、MacOSX和windows上,当前的VFS实现都做了悲观的假设。 也许在与一些熟悉这些系统工作原理的专家交流之后,我们能放宽一些限制,让它跑得更快些。 特别的,我们猜测大部分现代文件系统已经具有了“安全追加”和“原子扇区写”这两个特性,但在确认之前,我们仍会保守的做最坏假设。