SQLite 数据库对损坏具有很强的抵抗力。如果应用程序崩溃,或者操作系统崩溃,甚至在事务执行过程中出现电源故障,部分写入的事务应该在下一次访问数据库文件时自动回滚。恢复过程是全自动的,不需要用户或应用程序进行任何操作。
虽然 SQLite 能够抵抗数据库损坏,但并非完全免疫。本文档描述了 SQLite 数据库可能损坏的各种方式。
SQLite 数据库文件是普通的磁盘文件。这意味着任何进程都可以打开该文件,并用垃圾数据覆盖它。SQLite 库无法防御这种情况。
我们已经看到了多个案例,其中一个文件描述符在一个文件上打开,然后该文件描述符关闭并在 SQLite 数据库上重新打开。之后,另一个线程继续写入旧的文件描述符,没有意识到原始文件已经关闭。但是,由于文件描述符已被 SQLite 重新打开,因此打算写入原始文件的信息最终覆盖了 SQLite 数据库的部分内容,导致数据库损坏。
其中一个例子发生在 2013 年 8 月 30 日左右,在 Fossil DVCS 的规范存储库中。在该事件中,文件描述符 2(标准错误)在 sqlite3_open_v2() 之前被错误地关闭(我们怀疑是 stunnel 造成的),因此用于存储库数据库文件的文件描述符是 2。后来,应用程序错误导致断言语句通过调用 write(2,...) 输出错误消息。但是,由于文件描述符 2 现在连接到数据库文件,因此错误消息覆盖了数据库的一部分。为了防止此类问题,SQLite 版本 3.8.1(2013 年 10 月 17 日)及更高版本拒绝对数据库文件使用低编号的文件描述符。(有关更多信息,请参阅 SQLITE_MINIMUM_FILE_DESCRIPTOR。)
Facebook 工程师在 2014 年 8 月 12 日的博客文章中 报告 了由使用已关闭的文件描述符引起的损坏的另一个例子。
2019 年 7 月 11 日,针对 Fossil 报告了此错误的另一个例子。一个文件描述符将被打开用于调试输出,但随后被 SQLite 关闭并重新打开。但调试逻辑继续写入原始文件描述符。有关错误报告和修复链接,请参阅 论坛讨论。
在后台运行自动备份的系统可能会尝试在事务执行过程中备份 SQLite 数据库文件。备份副本可能会包含一些旧内容和一些新内容,因此会损坏。
备份 SQLite 数据库的最佳方法是使用 SQLite 库中提供的 备份 API。如果没有,只要没有进程正在进行事务,就可以安全地复制 SQLite 数据库文件。如果前一个事务失败,那么重要的是任何回滚日志文件(*-journal文件)或预写日志文件(*-wal文件)必须与数据库文件本身一起复制。
SQLite 通常将所有内容存储在一个磁盘文件中。但是,在执行事务时,用于在崩溃或电源故障后恢复数据库所需的信息将存储在辅助日志文件中。此类日志文件被描述为 "热" 文件。日志文件与原始数据库文件具有相同的名称,但添加了-journal或-wal后缀。
SQLite 必须看到日志文件才能从崩溃或电源故障中恢复。如果在崩溃或电源故障后 热日志文件 被移动、删除或重命名,则自动恢复将不起作用,数据库可能会损坏。
此问题的另一个表现形式是 由 8+3 文件名使用不一致导致的数据库损坏。
前一个例子是更一般问题的具体案例:SQLite 数据库的状态由数据库文件和日志文件共同控制。在静止状态下,日志文件不存在,只有数据库文件很重要。但是,如果日志文件存在,则必须将其与数据库一起保存,以防止损坏。以下操作都可能导致损坏
SQLite 在数据库文件上使用文件锁,以及在 预写日志 或 WAL 文件上使用文件锁,来协调并发进程之间的访问。如果没有协调,两个线程或进程可能会尝试在同一时间对数据库文件进行不兼容的更改,从而导致数据库损坏。
SQLite 依赖于底层文件系统来执行锁定,如文档中所述。但是,一些文件系统在其锁定逻辑中包含错误,导致锁并不总是按预期工作。这在网络文件系统,尤其是 NFS 中尤其常见。如果 SQLite 在存在锁定原语错误的文件系统上使用,并且两个或多个线程或进程尝试同时访问同一个数据库,则可能会导致数据库损坏。
SQLite 在类 Unix 平台上使用的默认锁定机制是 POSIX 咨询锁定。不幸的是,POSIX 咨询锁定存在设计缺陷,使其容易被误用和故障。特别是,同一个进程中任何具有文件描述符的线程,只要该文件描述符持有 POSIX 咨询锁,就可以使用另一个文件描述符覆盖该锁。一个特别有害的问题是,close()系统调用将取消进程中所有线程和所有文件描述符对同一文件的 POSIX 咨询锁。
因此,例如,假设一个多线程进程有两个或多个线程,它们使用单独的 SQLite 数据库连接连接到同一个数据库文件。然后,第三个线程出现,想要自己从同一个数据库文件中读取一些内容,而不使用 SQLite 库。第三个线程执行open()、read(),然后是close()。人们会认为这是无害的。但是,close()系统调用导致所有其他线程持有的数据库锁被释放。这些其他线程无法知道它们的锁刚刚被破坏(POSIX 没有提供任何机制来确定这一点),因此它们继续运行,假设它们的锁仍然有效。这会导致两个或多个线程或进程尝试同时写入数据库,从而导致数据库损坏。
请注意,两个或多个线程使用 SQLite 库访问同一个 SQLite 数据库文件是完全安全的。SQLite 的类 Unix 驱动程序了解 POSIX 咨询锁定的缺陷并进行了解决。此问题只会在线程尝试绕过 SQLite 库并直接读取数据库文件时出现。
如前一段所述,SQLite 采取了措施来解决 POSIX 咨询锁定的缺陷。部分解决方法包括维护一个打开的 SQLite 数据库文件全局列表(使用互斥锁保护)。但是,如果将多个 SQLite 副本链接到同一个应用程序中,那么将会有多个此全局列表的实例。使用一个 SQLite 库副本打开的数据库连接将不知道使用另一个 SQLite 库副本打开的数据库连接,并且无法解决 POSIX 咨询锁定的缺陷。一个close()操作可能无意中清除另一个数据库连接的锁,导致数据库损坏。
上述场景听起来很牵强。但是,SQLite 开发人员知道至少有一款商业产品发布时就存在此错误。供应商找到了 SQLite 开发人员,寻求帮助以追踪他们在 Linux 和 Mac 上看到的一些不常见的数据库损坏问题。问题最终追溯到应用程序链接了两个独立的 SQLite 副本。解决方案是更改应用程序构建过程,改为链接一个 SQLite 副本,而不是两个。
SQLite 在类 Unix 平台上使用的默认锁定机制是 POSIX 咨询锁定,但还有其他选项。通过使用 sqlite3_vfs 接口选择备用锁定机制,应用程序可以使用其他更适合某些文件系统的锁定协议。例如,点文件锁定可以用于必须在不支持 POSIX 咨询锁定的 NFS 文件系统上运行的应用程序。
重要的是,对同一个数据库文件的所有连接都使用相同的锁定协议。如果一个应用程序使用 POSIX 咨询锁定,而另一个应用程序使用点文件锁定,那么这两个应用程序将看不到彼此的锁,并且无法协调数据库访问,这可能会导致数据库损坏。
如果两个进程对同一个数据库文件建立了开放连接,其中一个进程关闭了连接,取消了文件的链接,然后在其位置创建了一个具有相同名称的新的数据库文件并重新打开了新文件,那么这两个进程将与具有相同名称的不同数据库文件进行通信。(请注意,这只能在允许在文件仍处于打开状态以供读取和写入时取消其链接的 Posix 和类 Posix 系统上实现。Windows 不允许这种情况发生。)由于回滚日志和 WAL 文件基于数据库文件的名称,因此这两个不同的数据库文件将共享同一个回滚日志或 WAL 文件。其中一个数据库的回滚或恢复可能会使用来自另一个数据库的内容,从而导致损坏。如果数据库文件在打开时被重命名,并且用旧名称创建了一个新文件,也会出现类似的问题。
换句话说,取消链接或重命名打开的数据库文件会导致行为未定义,并且可能不受欢迎。
从 SQLite 版本 3.7.17(2013-05-20)开始,如果数据库文件在仍然使用时被取消链接,则 unix 操作系统界面将向 错误日志 发送 SQLITE_WARNING 消息。
如果单个数据库文件具有多个链接(硬链接或软链接),则这只是另一种说法,即该文件具有多个名称。如果两个或多个进程使用不同的名称打开数据库,那么它们将使用不同的回滚日志和 WAL 文件。这意味着,如果一个进程崩溃,另一个进程将无法恢复正在进行的事务,因为它将在错误的位置查找相应的日志。
换句话说,打开和使用具有两个或多个名称的数据库文件会导致行为未定义,并且可能不受欢迎。
从 SQLite 版本 3.7.17(2013-05-20)开始,如果数据库文件具有多个硬链接,则 unix 操作系统界面将向 错误日志 发送 SQLITE_WARNING 消息。
从 SQLite 版本 3.10.0(2016-01-06)开始,unix 操作系统界面将尝试解析符号链接并使用其规范名称打开数据库文件。在 3.10.0 版本之前,通过符号链接打开数据库文件类似于打开具有多个硬链接的数据库文件,并会导致行为未定义。
不要打开 SQLite 数据库连接,然后 fork(),然后尝试在子进程中使用该数据库连接。所有类型的锁定问题都会导致结果,并且您很容易最终得到一个损坏的数据库。SQLite 并非设计为支持这种行为。在子进程中使用的任何数据库连接都必须在子进程中打开,而不是从父进程继承。
如果连接是在父进程中打开的,即使在子进程中也不要对数据库连接调用 sqlite3_close()。关闭底层文件描述符是安全的,但 sqlite3_close() 接口可能会调用清理活动,这些活动会从父进程中删除内容,从而导致错误,甚至可能导致数据库损坏。
为了保证数据库文件始终一致,SQLite 偶尔会要求操作系统刷新所有挂起的写入到持久存储,然后等待该刷新完成。这是通过以下方式实现的fsync()unix 下的系统调用和FlushFileBuffers()Windows 下的系统调用。我们称此挂起写入的刷新为“同步”。
实际上,如果一个人只关心原子和一致的写入,并且愿意放弃持久写入,那么同步操作不需要等到内容完全存储在持久介质上。相反,同步操作可以被认为是 I/O 障碍。只要在同步之前发生的写入都在同步之后发生的写入之前完成,就不会发生数据库损坏。如果同步操作作为 I/O 障碍而不是真正的同步操作,那么断电或系统崩溃可能会导致一个或多个先前提交的事务回滚(违反“ACID”的“持久”属性),但数据库至少将继续保持一致,而这正是大多数人关心的。
不幸的是,大多数消费级大容量存储设备在同步方面撒谎。磁盘驱动器将在内容到达磁道缓冲区之前报告内容已安全地存储在持久介质上,而不会实际写入氧化物。这使得磁盘驱动器看起来运行速度更快(这对制造商来说至关重要,这样他们就可以在贸易杂志中显示良好的基准测试数据)。而且公平地说,这个谎言通常不会造成任何伤害,只要在磁道缓冲区实际写入氧化物之前没有断电或硬重置。但是,如果确实发生了断电或硬重置,并且导致在同步后写入的内容到达氧化物,而同步之前写入的内容仍然在磁道缓冲区中,那么可能会发生数据库损坏。
USB 闪存盘似乎特别善于撒谎关于同步请求。可以通过对 USB 闪存盘上的 SQLite 数据库提交大型事务来轻松地看到这一点。COMMIT 命令将相对快速地返回,表明闪存盘已告知操作系统,而操作系统已告知 SQLite 所有内容已安全地存储在持久存储中,但闪存盘末端的 LED 将继续闪烁几秒钟。在 LED 仍然闪烁时拔出闪存盘将经常导致数据库损坏。
请注意,SQLite 必须相信操作系统和硬件告诉它的关于同步请求状态的一切。SQLite 无法检测到两者都在撒谎,并且写入可能以非顺序方式发生。但是,在 WAL 模式 中的 SQLite 比在默认的回滚日志模式中更能容忍非顺序写入。在 WAL 模式下,失败的同步操作仅在 检查点 操作期间才会导致数据库损坏。在 COMMIT 期间的同步失败可能会导致持久性丢失,但不会导致数据库文件损坏。因此,抵御由于同步操作失败而导致的数据库损坏的一条防线是在 WAL 模式下使用 SQLite 并尽可能少地进行检查点。
SQLite 执行以帮助确保完整性的同步操作可以使用 synchronous pragma 在运行时禁用。通过设置 PRAGMA synchronous=OFF,所有同步操作都会被省略。这使得 SQLite 似乎运行速度更快,但它也允许操作系统自由地重新排序写入,如果在所有内容到达持久存储之前发生断电或硬重置,这可能会导致数据库损坏。
为了获得最大的可靠性和抵御数据库损坏的能力,SQLite 应该始终使用其默认的 FULL 同步设置运行。
如果由于磁盘驱动器或闪存故障导致文件内容发生更改,SQLite 数据库可能会损坏。这非常罕见,但磁盘偶尔会在扇区中间翻转一个位。
我们被告知,在一些闪存控制器中,如果在写入过程中断电,磨损均衡逻辑会导致随机文件系统损坏。例如,这可能表现为在当时甚至没有打开的文件中间的随机更改。因此,例如,设备正在将内容写入闪存中的 MP3 文件,此时发生断电,这可能会导致 SQLite 数据库损坏,即使数据库在断电时甚至没有使用。
市面上有很多欺诈性的 USB 闪存盘,它们声称具有很高的容量(例如 8GB),但实际上只能存储更小的容量(例如 1GB)。尝试写入这些设备通常会导致无关的文件被覆盖。因此,使用欺诈性的闪存设备很容易导致数据库损坏。在互联网上搜索诸如“假容量 usb”之类的词语会显示出大量关于此问题的令人不安的信息。
SQLite 是一个 C 库,它与它服务的应用程序在同一个地址空间中运行。这意味着,应用程序中的杂散指针、缓冲区溢出、堆损坏或其他故障可能会损坏内部 SQLite 数据结构,最终导致数据库文件损坏。通常,这些类型的问题会在发生任何数据库损坏之前表现为段错误,但也有过应用程序代码错误导致 SQLite 运行故障的情况,从而导致数据库文件损坏而不是恐慌。
在使用 内存映射 I/O 时,内存损坏问题变得更加严重。当数据库文件的所有或部分内容被映射到应用程序的地址空间时,任何覆盖该映射空间的任何部分的杂散指针都会立即损坏数据库文件,而无需应用程序进行后续的 write() 系统调用。
有时操作系统会表现出非标准行为,这会导致问题。有时这种非标准行为是故意的,有时是实现中的错误。但在任何情况下,如果操作系统的行为与 SQLite 预期的不同,都可能存在数据库损坏的可能性。
一些旧版本的 Linux 使用 LinuxThreads 库来支持线程。LinuxThreads 类似于 Pthreads,但在处理 POSIX 建议锁方面存在细微差别。SQLite 版本 2.2.3 到 3.6.23 识别到 LinuxThreads 在运行时被使用,并采取了适当的措施来解决 LinuxThreads 的非标准行为。但大多数现代 Linux 实现使用更新且正确的 NPTL 实现的 Pthreads。从 SQLite 版本 3.7.0(2010-07-21)开始,假设使用 NPTL。没有进行任何检查。因此,如果在使用 LinuxThreads 的旧版 Linux 系统上运行的多线程应用程序中使用 SQLite 的最新版本,SQLite 将会发生细微的故障,并可能损坏数据库文件。
QNX 上的 mmap() 存在一些细微的问题,即对单个文件描述符进行第二次 mmap() 调用会导致从第一次 mmap() 调用获得的内存被清零。SQLite 在 unix 上使用 mmap() 为 WAL 模式 中的事务协调创建共享内存区域,并且对于大型事务,它会多次调用 mmap()。已证明 QNX mmap() 在这种情况下会损坏数据库文件。QNX 工程师已意识到此问题并正在寻找解决方案;这个问题可能在您阅读本文时已经得到解决。
在 QNX 上运行时,建议不要使用 内存映射 I/O。此外,为了使用 WAL 模式,建议应用程序采用 独占锁定模式 才能使用 不使用共享内存的 WAL。
由于 SQLite 数据库是普通的磁盘文件,任何文件系统故障都可能导致数据库损坏。现代操作系统中的文件系统非常可靠,但错误仍然会发生。例如,在 2013 年 10 月 1 日,Tcl/Tk Wiki 所使用的 SQLite 数据库在主机迁移到一个有文件系统层问题的 (linux) 内核构建版本后几天就出现了损坏。在那次事件中,文件系统最终变得非常严重,以至于机器无法使用,但最早的故障迹象是 SQLite 数据库损坏。
SQLite 拥有许多针对数据库损坏的内置保护机制。但许多这些保护机制可以通过配置选项来禁用。如果禁用保护机制,数据库可能会损坏。
以下是禁用 SQLite 内置保护机制的示例:
设置 PRAGMA synchronous=OFF 可能会导致数据库在操作系统崩溃或电源故障时损坏,尽管此设置可以防止因应用程序崩溃造成的损坏。
在其他数据库连接打开时更改 PRAGMA schema_version。
使用 PRAGMA journal_mode=OFF 或 PRAGMA journal_mode=MEMORY 并在写入事务的中间发生应用程序崩溃。
设置 PRAGMA writable_schema=ON 然后使用 DML 语句更改数据库模式,如果不仔细操作,可能会使数据库完全无法读取。
SQLite 经过 非常仔细的测试,以确保它尽可能地无错误。在对每个 SQLite 版本进行的许多测试中,包括模拟电源故障、I/O 错误和内存不足 (OOM) 错误的测试,并验证在这些事件发生期间不会出现数据库损坏。SQLite 还经过了现场验证,大约有 20 亿个活动部署,没有出现严重问题。
但是,没有软件是 100% 完美的。SQLite 中曾经出现过一些历史错误(现已修复),这些错误会导致数据库损坏。并且可能还有一些尚未发现的错误。由于 SQLite 进行了广泛的测试和使用,导致数据库损坏的错误往往非常隐蔽。应用程序遇到 SQLite 错误的可能性很小。为了说明这一点,下面列出了 SQLite 在 2009 年 4 月 1 日至 2013 年 4 月 15 日的四年期间发现的所有数据库损坏错误。此列表应该可以让读者直观地了解 SQLite 中能够避开测试程序并进入发布版本的错误类型。
如果数据库由 SQLite 版本 3.7.0 或更高版本写入,然后由 SQLite 版本 3.6.23 或更低版本以使数据库文件大小减小的方式再次写入,那么下次 SQLite 版本 3.7.0 访问数据库文件时,它可能会报告数据库文件已损坏。但是,数据库文件并没有真正损坏。版本 3.7.0 在其损坏检测方面过于严格。
此问题已于 2011 年 2 月 20 日修复。修复程序首次出现在 SQLite 版本 3.7.6(2011 年 4 月 12 日)。
在一个进程或线程中重复地将 SQLite 数据库进出 WAL 模式 并在切换之间运行 VACUUM 命令,可能会导致具有打开数据库文件的另一个进程或线程错过数据库已更改的事实。然后,该第二个进程或线程可能会尝试使用过时的缓存修改数据库,从而导致数据库损坏。
此问题是在内部测试期间发现的,从未在现实中观察到。该问题已于 2011 年 1 月 27 日在版本 3.7.5 中修复。
如果操作系统在尝试获取 WAL 模式 中共享内存上的特定锁时返回 I/O 错误,那么 SQLite 可能会无法重置其缓存,如果随后尝试写入,则会导致数据库损坏。
请注意,此问题仅在尝试获取锁导致 I/O 错误时才会发生。如果锁只是没有授予(因为其他线程或进程已经持有冲突的锁),那么永远不会发生损坏。我们不知道任何会在尝试获取共享内存上的文件锁时发生 I/O 错误的操作系统。因此,这是一个理论问题,而不是实际问题。不用说,这个问题从未在现实中观察到。该问题是在对 SQLite 进行压力测试时发现的,测试程序模拟了 I/O 错误。
此问题已于 2010 年 9 月 20 日针对 SQLite 版本 3.7.3 修复。
从 SQLite 数据库中删除内容时,不再使用的页将被添加到空闲列表中,并被重用于保存后续插入添加的内容。SQLite 中存在于版本 3.6.16 到 3.7.2 之间的错误可能会导致使用 incremental_vacuum 时页从空闲列表中消失。这不会导致数据丢失。但这会导致数据库文件的大小大于必要。并且会导致 integrity_check pragma 报告空闲列表中缺少页。
此问题已于 2010 年 8 月 23 日针对 SQLite 版本 3.7.2 修复。
SQLite 版本 3.7.0 对 SQLite 数据库文件格式引入了一系列新的增强功能(例如但不限于 WAL)。3.7.0 版本是这些新功能的验证版本。我们预计会发现问题,并且没有失望。
如果数据库最初是使用 SQLite 版本 3.7.0 创建的,然后由 SQLite 版本 3.6.23.1 写入,以使数据库文件的大小增加,然后由 SQLite 版本 3.7.0 再次写入,则数据库文件可能会损坏。
此问题已于 2010 年 8 月 4 日针对 SQLite 版本 3.7.1 修复。
SQLite 版本 3.7.16.2 修复了 Windows 系统上锁定逻辑中的一个细微的竞争条件。当数据库文件需要恢复,因为之前写入它的进程在事务中间崩溃,并且两个或多个进程试图同时打开该数据库时,该竞争条件可能会导致其中一个进程错误地指示恢复已完成,从而允许该进程继续使用数据库文件而不首先运行恢复。如果该进程写入文件,则该文件可能会损坏。这种竞争条件显然存在于所有早期版本的 SQLite 中,可以追溯到 2004 年。但这种竞争非常紧密。实际上,您需要一台快速的、多核心的机器,您可以在其中启动两个进程以在两个单独的内核上同时运行恢复。此缺陷仅在 Windows 系统上存在,并不影响 posix OS 接口。
当使用 SAVEPOINT 启动嵌套事务时,SQLite 使用辅助回滚日志来跟踪嵌套事务的更改,以防内部事务需要回滚。辅助日志不会参与保护数据库免受程序崩溃或电源故障导致的损坏。辅助日志仅在回滚嵌套事务的内部事务时才会发挥作用。
这些辅助日志可以保存在内存中,也可以作为磁盘上的临时文件保存。默认行为是将它们存储在磁盘上。但这可以使用 -DSQLITE_TEMP_STORE 编译时选项或在运行时使用 PRAGMA temp_store 语句来更改。该错误仅在辅助日志保存在内存中时才会出现。
在 SQLite 版本 3.35.0(2021 年 3 月 12 日)中,添加了一个新的优化,以便当 SQLite 将辅助日志保存在内存中时,将使用更少的内存。不幸的是,新逻辑中的边界检查代码编写不正确。应该是“<”运算符的地方被写成了“<=”。如果辅助日志被回滚,则此错误可能会导致辅助日志进入不一致状态。如果进行其他更改,并且外部事务最终提交,则数据库可能会处于不一致状态。
此问题由一位 独立研究人员 发现,他试图使用模糊器查找 SQLite 中的错误。模糊器发现了 assert() 语句 中的错误,该语句用于帮助验证辅助日志的内部状态。该错误是一个非常隐蔽的极端情况,如果不是 SQLite 中广泛使用了 assert() 语句,以及安全研究人员的坚持和毅力,以及他们定制的先进模糊器,它可能在许多年内都不会被发现。
此问题已在 修复,并在 版本 3.37.2(2022 年 1 月 6 日)中发布。
此页面最后修改于 2023 年 10 月 10 日 17:29:48 UTC