目录
7.5 Filesystems With Safe Append Semantics 22
1.0 简介
“原子提交”是 SQLite 这种支持事务的数据库的一个重要特性。原子提交意味着某个事务中数据库的变化会完整完成或者根本不完成。原子提交意味着不同的写入分别写入到数据库的不同部分就似同时发生在同一个时间点一样。
实际上硬件会连续的写到海量存储器中,只是写一个扇区所用的时间非常少。所以,同时或瞬间写入到数据文件的不同部分成为可能。 SQLite 的原子提交逻辑会使得一个事务中的变化就象同时发生的一样。
事务的原子是 SQLite 的重要特性,即使事务由于操作系统出错或掉电发生中断也能保持其原子性。
本文描述了 SQLite 实现原子操作的技术。
2.0 硬件设定
在这 往篇文章中,我们把海量存储特指定为“硬盘”,即使它可能是flash memory.
我们假定硬盘是以扇区为单位进行整块写入的。我们不能单独修改硬盘的小于扇区的部分。如果需要修改硬盘小于扇区的部分,你也必须整个读入此部分所在扇区,对此扇区进行修改,然后将整个扇区写回硬盘。
在传统的 Spinning disk 中 , 扇区是最小的传输单元 --- 无论是读还是写。然而,对于 flash memory ,每次读的最小数目通常都远小于最小写操作数目。 SQLite 只关心写操作的最小数目,因此在本文中,当我们说“扇区”的时候,就是指单次写入的最少字节总数。
SQLite 3.3.14 以前的版本,我们假定任何情况下,一个扇区是 512 字节。这是一个编译时设定的值,而且从没针对更大数进行测试过。当磁盘驱动器内部使用的是以 512 字节为单位的扇区时, 512 字节的假定显得非常合理。然而,现在的磁盘都已经发展到 4k 每扇区了。同样, flash memory 的扇区大小通常都大于 512 字节。因此,从 3.3.14 版本开始, SQLite 有一个函数去获取文件系统的扇区真实大小。在当前的实现中 (3.5.0) ,这个函数仍然简单的返回 512— 因为在 win32 及 unix 环境下,没有标准方法去取得扇区的真实大小。但这个方法在人们需要针对他们应用进行调整的时候是非常有意义的。
SQLite 并不 假 定扇区写操作是原子的。然而,我们假定扇区写操作是线性的。所谓“线性”是指,当开始扇区写操作时,硬件从前一个扇区的结束点开始,然后一字节一字节的写 入,直到此扇区的结束点。这个写操作可能是从尾向头写,也可能是从头向尾写。如果在一个扇区写入操作时发生掉电故障,这个扇区可能会一部分已经修改完成, 还有一部分还没来得及进行修改。 SQLite 的关键设定是这样的:如果一个扇区的任何部分发生修改,那么不是它开始的部分发了变化,就是它结束部分发生了变化。所以硬件从来都不会从一个扇区的中间部分开始写入。我们不知道这个假定是否总是真实的,但无论如何,看起来还是蛮合理的。
上段中, SQLite 并没有假定扇区写操作是原子的。在 SQLite3.5.0 版本中,新增了一个 VFS (虚拟文件系统)接口。 SQLite 通过 VFS 与实际的文件系统进行交互。 SQLite 已经为 windows 及 unix 编写了一个缺省的 VFS 实现。并且可以让用户在运行时实现一个自定义的 VFS 实现。 VFS 接口有一个方法叫: xDeviceCharacteristics. 此方法读取实际的文件系统各种特性。 xDeviceCharacteristics 方法可以指明扇区写操作是原子的,如果确实指定扇区写是原子的, SQLite 是不会放过这等好处的。但在 windows 及 unix 中,缺省 xDeviceCharacteristics 的实现并没有指明扇区写是原子的,所以这些优化通常会忽略掉了。
SQLite 假定操作系统会对写进行缓冲,因此写入请求返回时,有可能数据还没有真实的写入到存储中。 SQLite 同时还假定这种写操作会被操作系统记录。因此, SQLite 需要在关键点做 "flush" 或 "fsync" 函数调用。 SQLite 假定 flush 或 fsync 在数据没有真实的写入到硬盘之前是不会返回的。不幸的是,我们知道在一些 windows 及 unix 版本中,缺少 flush 或 fsync 的真正实现。这使得 SQLite 在写入一个提交发生掉电故障后数据文件得到损坏。然而,这不要紧, SQLite 能够做一些测试或补救。 SQLite 假定操作系统会是广告中那样漂亮运行。如果这些都不是问题,那么剩下的只期望你家的电源不要间歇性的休息。
SQLite 假定文件增长方式是指新分配的文件空间,刚分配的时候是随机内容,后来才被填入实际的数据。换而言之,文件先变大,然后再填充其内容。这是一悲观假定,因而 SQLite 不得不做一些额外的操作来防止因断电发生的破坏数据文件 — 发生在文件大小已经增大,而文件内容还没完全填入之间的掉电。 VFS 的 xDeviceCharacteristics 可以指明文件系统是否总是先写入数据然后才更变文件大小的。 ( 这就是那个: SQLITE_IOCAP_SAFE_APPEND 属性,如果你想查看代码的话 ) 当 xDeviceCharacteristics 方法指示了文件内容先写入然后才改变文件大小的话, SQLite 会减少一些相当的数据保护及错误处理过程,这将大大减少一个提交磁盘 IO 操作。然而在当前的版本, windows 及 unix 的 VFS 实现并没有这样假定。
SQLite 假定文件删除从用户进程角度来讲是原子的。也就说当 SQLite 要 求删除一个文件,也在这删除的过程中间,断电了,一旦电源恢复,只有下列二种情况之一分发生:文件仍然存在,所有内容都没有发生变化;或者文件已经被删除 掉了。如果电源恢复之后,文件只发生了部分删除,或者部分内容发生了变化或清除,或者文件只是清空,那么数据库还有用才怪呢。
SQLite 假定发现或修改由于宇宙射线,热噪声,量子波动,设备驱动 bug 等等其他可能所引发的错误,都由操作系统或硬件来完成。 SQLite 并不为此类问题增加任何数据冗余处理。 SQLite 假定在写入之后去读取所获得的数据,是与写入的数据完全一致的!
我们着手观察 SQLite 在针对一个数据库文件时,为保证一个原子提交所采取的步骤。关于在多个数据库文件之间为防止电源故障损坏数据库及保证提交的原子性所采用的技术及具体的文件格式在下一节进行讨论。
3.1 实始状态
当一个数据库第一次打开时计算机的状态示意图如右图所示。图中最右边( ”Disk” 标注)表示保存在存储设备中的内容。 每个方框代表一个扇区。蓝色的块表示这个扇区保存了原始资料。图中中间区域是操作系统的磁盘缓冲区。在我们的案例开始的时候,这些缓存是还没有被使用 — 因此这些方框是空白的。图中左边区域显示 SQLite 用户进程的内存。因为这个数据库联接刚刚打开,所以还没有任何数据记录被读入,所以这些内存也是空的。
3.2 申请一个共享锁
SQLite 在可以写数据库之前,它必须先读这个数据库,看它是否已经存在了。即使只是只是增加添加新的数据, SQLit 仍然必须从 sqlite_master 表中读取数据库格式,这样才知道如何分析 INSERT 语句,知道在哪儿保存新的信息。
为了从数据库文件读取,第一步是获得一个数据库文件的共享锁。一个“共享”锁允许多个数据库联接在同一时刻从这个数据库文件中读取信息。 “共享”锁将不允许其他联接针对此数据库进行写操作。这是必然的,如果一个联接在向数据库写入数据的同时,我们去读到信息,也可能读到的一部分数据是修改之前的,而另一部分数据是修改之后的。这将使得另外联接的修改操作看起来是非原子的。
请注意共享锁只是针对操作系统的磁盘缓存,并非磁盘本身。通常文件锁只是操作系统内核的一些标识(详情要根据具体的操作系统)。因此,锁会立即消失一旦操作系统崩溃或者停电。当然创建该锁的进程消失,该锁也会随之而去。
3.3 从数据库里面读取信息
当 共享锁取得之后,我们就可以开始从数据库文件中读取信息了。在当前环节,我们已经假定了系统缓存是空的,所以信息必须首先从读硬盘读取到系统缓存中去,然 后从系统缓存中传递到用户空间。针对之后的读取,部分或者全部数据都可能可以从操作系统缓存中取得,所以只需要传递到用户空间即可。
一般的,数据库文件只有部分被读取。这个例子中, 8 页中只有 3 页被读取。一个典型应用中,一个数据库文件拥有成千上万页,一个查询通常读取到的页码数量只占总数一个很小的百分比。
3.4 申请一个 Reserved Lock
在修改一个数据库之前, SQLite 首先得拥有一个针对数据库文件的“ Reserved ” 锁。 Reserved 锁类似于共享锁,它们都允许其他数据库联接读取信息。单个 Reserved 锁能够与其他进程的多个共享锁一起协作。然后一个数据库文件同时只能存在一个 Reserved 。因此只能有一个进程在某一时刻尝试去写一个数据库文件。
Reserved 锁的存在是宣告一个进程将打算去更新数据库文件,但还没有开始。因为还没有开始修改,因此其他进程可以读取数据,其他进程不应该去尝试修改该数据库。
3.5 生成一个回滚日志文件
在修改数据库文件之前, SQLite 会生成一个单独的回滚日志文件,并在其中写进将会被修改的页的原始数据。回滚日志文件意味它将包含了所有可以将数据库文件恢复到原始状态的数据。
回滚日志文件有一个小的头部(图中绿色标记部分)记录了数据库文件的原始大小。因此,如果一旦即使数据库文件变大,我们还是会知道它原始大小。数据库文件中被修改的页码及他们的内容都被写进了回滚日志文件中。
当一个新文件刚被创建,大部分的桌面操作系统( windows,linux,macOSX )实际并不会马上写入数据到硬盘。此文件还只是存在于操作系统磁盘缓存中。这个文件还不会立即写到存储设备中,一般都会有一些延迟,或者到操作系统相当空闲的时候。用户的对于文件生成感觉是要远远快(先)于其真实的发生磁盘 I/O 操作。右图中我们用图例说明了这一点,当新的回滚日志文件创建之后,它还只是出现在操作系统磁盘缓存之中,还没真实在写入到硬盘之上。
3.6 修改用户进程中的数据页
当原始的数据已经被保存到回滚日志文件中之后,用户内存的数据就可以被修改了。任何一个数据库联接都有其他私有用户内存空间,所以用户内存空间发生的变化只有当前数据库联接才可见。
其他数据库联接仍然可以读取那些存在于操作系统磁盘缓存中还没有被修改的数据。所以即使一个联接忙于某些修改,其他进程还可以读取原始数据到它们各自的空间中去
3.7 刷新回滚日志文件到存储设备中
接下来的步骤是将回滚日志文件刷新到硬盘中去。接下来我们会看到,这是一个紧要步骤用来保证我们可以从突然掉电中救回数据。这个步骤将要花费大量的时间 — 因为通常写入到硬盘是一个耗时操作。 .
这个步骤通常要比简单的直接刷新这个回滚文件到硬盘要复杂一些。在大部分的操作系统中,二个单独的 flush 是必须的。第一个 flush 处理日志文件的内容部分。接下来,将日志文件的页码总数写入到日志文件头部,然后将日志头部 flsuh 到硬盘中。至少为什么我们要做一个头部修改及做一个额外的 flush 操作的原因我们会在后面的章节解释。
3.8 获得一个独享锁
在修改数据库文件本身之前,我们必须取得一个针对此数据库文件的独享锁。取得此锁的过程是分二步走的。首先 SQLite 取得一个“临界”锁,然后将此锁提升成一个独享锁。
一 个临界锁允许其他所有已经取得一个共享锁的进程从数据库文件中继续读取数据。但是它会阻止新的共享锁的生成。也就说,临界锁将会防止因大量连续的读操作而 无法获得写入的机会。这些读取者可能有一打,也可能上百,甚至于上千。任何一个读取者在开始读取之前都要申请一个共享锁,然后开始读取它需要的数据,然后 释放共享锁。然而存在这样一种可能:如果有太多的进程来读取同一个数据文件,在老的进程释放它的共享锁之前总是会有新的进程申请共享锁,因此不会存在某一 时刻这个数据库文件上没有共享锁的存在,也因此写入者不会拥有取得一个独享锁的机会。临界锁的概念可以使现有的读