rqlite如何(以及为何)掌控SQLite的预写日志

Lobsters Hottest 新闻

摘要

本文介绍了rqlite(一种分布式SQLite数据库)如何掌控SQLite的预写日志(WAL),从而实现对Raft共识的高效快照,通过将WAL作为增量状态来避免完整的数据库复制。

<p><a href="https://lobste.rs/s/pdcnxa/how_why_rqlite_takes_control_sqlite_write">评论</a></p>
查看原文
查看缓存全文

缓存时间: 2026/05/13 18:18

# rqlite 如何(以及为何)掌控 SQLite 预写日志 – Philip O'Toole 来源:https://philipotoole.com/how-and-why-rqlite-takes-control-of-the-sqlite-write-ahead-log/ *rqlite (https://rqlite.io/) 是一个轻量级、开源、容错的关系型数据库,基于 SQLite (https://www.sqlite.com/) 和 Raft (https://raft.github.io/) 构建。版本 10 (https://github.com/rqlite/rqlite/releases) 已发布。* rqlite 与 SQLite 预写日志 (WAL) (https://sqlite.org/wal.html) 有着特殊的关系。SQLite 自行管理其 WAL:当 WAL 增长时执行检查点,当最后一个连接关闭时执行检查点,并决定何时将帧从 WAL 移入主数据库文件。 这对 rqlite 来说是个问题。在 rqlite 中,WAL 不仅仅是 SQLite 的实现细节,而是 rqlite 运行的核心部分。如果 SQLite 在错误的时间对 WAL 执行检查点,rqlite 就无法再依赖 WAL 作为需要跟踪的增量状态。 为了更好地理解 rqlite 为何如此运作,并了解一些 SQLite WAL 的内部机制,我们来理解一下 rqlite 需要解决的问题。 ## Raft 只有一个目标 从概念上讲,Raft 只有一个目标:创建一个对*状态机*的变更日志,并确保该日志在集群中的一组机器上**完美**复制 (https://www.youtube.com/watch?v=8XbxQ1Epi5w&t=443s)。就是这样。其他一切皆源于此。 但如果这个日志要存储每一个事件——在 rqlite 中,一个事件是一条 SQL 语句,状态机是 SQLite 数据库——如何防止日志无限增长?Raft 对此有答案——它被称为*快照*。快照意味着 Raft 定期请求状态机的一份副本,将其持久化到磁盘,然后删除该副本中反映的所有日志。每个基于 Raft 的实用系统都必须实现快照机制。 rqlite 构建在 HashiCorp Raft 库 (https://github.com/hashicorp/raft) 之上。HashiCorp 库提供了默认的快照方法 (https://pkg.go.dev/github.com/hashicorp/[email protected]#FileSnapshotStore),但也允许应用程序提供自己的方法。rqlite 曾经使用过默认方法,但现在不再使用了 (https://github.com/rqlite/rqlite/tree/v10.0.4/snapshot),而这正是 SQLite WAL 发挥作用的地方。 ### 对 SQLite 进行快照 早期版本的 rqlite 以简单的方式对 SQLite 进行快照。当 Raft 请求快照时,rqlite 会提供**整个** SQLite 数据库的一致性副本 (https://sqlite.org/c3ref/serialize.html)。这种方法非常稳健,但有一个明显的缺点:如果你有一个 2GB 的数据库,只更改了几百行,那么复制整个 2GB 来捕捉快照中的这些更改是非常浪费的。随着 rqlite 部署的增长到多 GB 级别,这种方法变得不切实际。 幸运的是,SQLite 提供了一个解决方案:WAL。在 WAL 模式下运行时,SQLite 将所有更改写入 WAL,作为一系列帧——每个帧持有一个修改后的数据库页面。只有当 SQLite 执行检查点时,数据才会从 WAL 移回主数据库文件。在检查点之间,WAL 正好包含自上次检查点以来所做的更改。 rqlite 利用 WAL 的方式如下:当 Raft 请求快照时,rqlite 复制当前的 WAL,并将该副本交给 Raft。然后 rqlite 将 WAL 检查点到主 SQLite 数据库文件中。WAL 再次变为空,准备好累积下一批更改。因此,在任何时刻,WAL 正好包含相对于上次接受的 Raft 快照的未快照 SQLite 状态。这确实意味着我们需要一个*快照存储*系统 (https://github.com/rqlite/rqlite/tree/v10.0.4/snapshot),该系统能够接收一系列 WAL 文件,而不是数据库的自包含副本。这是一个独特的挑战,但我将留待以后的文章讨论。 这一切都不会自动发生。SQLite 自行管理 WAL 的计划——而这个计划对 rqlite 来说是错误的。 ### rqlite 如何掌控 SQLite 是设计良好的软件:它开箱即用,对大多数用例来说完美无缺,但它仍然允许 rqlite 行使所需的控制。这不需要修补 SQLite。它需要配置 SQLite,使检查点仅在 rqlite 要求时发生,并防止用户发出违反该契约的命令: - 禁用所有自动检查点 (https://sqlite.org/pragma.html#pragma_wal_autocheckpoint),这样 SQLite 就不会在 rqlite 不知情的情况下将 WAL 帧移入数据库文件。 - 捕获任何用户发出的 `PRAGMA` 命令,这些命令会执行检查点或更改 WAL 模式,并返回错误。 - 显式禁用关闭时检查点。虽然这不是严格必需的,但这样做可以实现快速重启——稍后将解释其原理。 之后,rqlite 自己驱动检查点。它在快照过程中发出显式检查点,总是请求一个 `TRUNCATE` 检查点,但要做好该操作可能失败的准备。什么是 `TRUNCATE` 检查点?这是一种检查点操作,成功检查点后将 WAL 文件截断为零字节。但此操作可能无法完成。rqlite 如何为这种失败做准备,是 rqlite 数据库层中比较有趣的部分之一。 ## 欢迎来到现实世界 以上是理想路径。现在让我们看看 rqlite 还必须解决的实际问题。 ### 总是从完整副本开始 WAL 快照是增量的。这意味着它们需要一个基础。因此,链中的第一个快照必须是 SQLite 数据库的完整副本。之后,rqlite 可以只快照自上次快照以来写入的 WAL 帧。在实践中,初始完整快照很快,因为新节点通常只有很少的 SQLite 状态需要复制。但任何后续的完整快照都可能涉及复制大量数据,因此 rqlite 尽力避免它们。 罕见的边缘情况可能会破坏链,迫使下一次快照成为完整快照。一种情况是当 Raft 请求快照时,但 rqlite 的最新更改是集群成员资格的更改 (https://github.com/hashicorp/raft/blob/ce1d06bfa084c8b9a54c1ebd68e4f51fd38ed695/snapshot.go#L165)。由于技术原因,这意味着 SQLite 将被快照,快照将被中止,WAL 将被丢弃。结果将安排一个完整快照。但在 v10 中,在不利条件下保持链存活的机制得到了极大改进,恢复为完整快照的情况几乎不会发生——包括在此类成员资格更改期间。 ### 当读取器阻碍时 到目前为止,关于 Raft 快照的讨论省略了一个关键细节:在快照过程中,对 rqlite 的写入会被阻塞。为什么?因为 Raft 需要一个与其日志同步的一致快照,而阻塞写入是保证这一点最佳方式。这意味着 rqlite 需要一个始终快速的快照过程。 什么会减慢快照速度?好吧,如果自上次快照以来插入了大量新数据,那么 rqlite 将有更多数据需要复制到 Raft——这将导致写入被阻塞更长时间。操作员可以通过增加快照频率来缓解此问题 (https://rqlite.io/docs/guides/config/)——写入被阻塞得更频繁,但每次阻塞时间更短。但还有第二种类型的访问会阻塞快照,这与 SQLite 自身的工作方式有关。 在 SQLite 中,读取器可以阻止 WAL 检查点完成,因此 rqlite 希望尽量减少等待任何阻塞读取器完成的时间。默认情况下,它会等待最多 250 毫秒。如果读取器在那时仍未完成工作,SQLite 将放弃并返回错误给 rqlite。由于 rqlite 总是请求一个 `TRUNCATE` 检查点——它要求在检查点后 WAL 文件为零字节——失败的检查点操作将使 SQLite 数据库处于以下两种状态之一: #### **SQLite 无法重置 WAL** 在这种情况下,至少有一个读取器正在从 WAL 中除最后一帧以外的某个位置读取。 在这种情况下,SQLite 无法将所有 WAL 帧移回主数据库文件,因为这样做可能会破坏读取隔离性,即读取器会在查询过程中看到其下方的数据发生变化。这种失败对 rqlite 来说很容易处理。虽然 SQLite 只将部分帧检查点回主数据库文件,但 SQLite **没有重置 WAL——这意味着下一次对数据库的写入将追加到 WAL 文件中**。这一点至关重要,因为这意味着 WAL 将继续包含自上次成功快照以来的所有写入。rqlite 可以向 Raft 发回快照过程失败的信息,Raft 将稍后重试。 #### **更困难的情况:SQLite 检查点了帧但无法截断 WAL** 当这种情况发生时,意味着所有读取器都在从 WAL 的最后一帧读取。 这是最有趣的情况。在这种情况下,WAL 中的所有帧都已移回主数据库,但 WAL 文件未被截断。WAL 会在下一次写入时重置吗?rqlite 直到下一次写入 SQLite 数据库时才会知道——但这将是未来的某个未知时间点。它应该认为快照失败了吗?这是关键点。在这种情况下,rqlite 的做法是继续执行,就像快照成功一样。它向 Raft 快照系统提供 WAL 数据,但记录 WAL 头部中的盐值 (https://sqlite.org/fileformat.html) 和 WAL 的长度。在 SQLite 的 WAL 格式中,盐值用于区分不同的 WAL 世代。 在下一次快照时,rqlite 检查 WAL。如果盐值发生了变化,则说明 SQLite 重置了 WAL,后续写入从文件开头开始。如果盐值没有变化,则后续写入是从 rqlite 在上一次快照期间记录的偏移量之后追加的。然后,它可以从上一次快照操作期间记录的 WAL 中的正确偏移量开始读取 WAL 帧。 #### **为什么费这么大劲?** 早期版本的 rqlite 认识到所有这些都可能发生,但以更简单的方式处理。它使用了两步方法: - 等待更长时间——默认最多五分钟——让任何读取器完成并解除检查点的阻塞。显然,这是一种粗糙的方法,但实际上 rqlite 的大多数读取时间很短。 - 如果检查点仍然失败,则退出进程。这意味着节点将在启动时从*快照存储*重建其状态。 v10 从根本上改变了这种行为 (https://github.com/rqlite/rqlite/blob/v10.0.4/db/checkpoint_manager.go#L48)。要么快照很快完成,要么被中止,几秒后重试。快照——因此写入器——不再被慢速读取器过度阻塞,因为系统针对读取器阻止 WAL 截断的情况有了策略。 ## 揭开 WAL 的盖子 一旦你开始从这个层面理解 SQLite,你就会发现你会用到那些你原本没计划用到的理解。 ### 压缩 WAL 在快照时,WAL 包含自上次快照以来的所有更改——具体来说是所有修改过的数据库页面。如果你遍历 WAL,你会找到一页又一页的数据库数据。但有趣的是:同一个页面号经常在 WAL 中出现多次。在检查点操作期间,后面转移到数据库的页面会覆盖前面转移的页面。当检查点完成时,数据库中只存在给定页面的最后一个实例。 这一见解导致了*WAL 压缩* (https://github.com/rqlite/rqlite/blob/v10.0.5/db/wal/compacting_section_scanner.go)。rqlite 在快照期间不仅仅是复制 WAL——它创建一个副本,该副本只保留任何给定页面号的最后一个版本。而压缩后的副本被交给 Raft。这意味着在快照过程中传输到 Raft 的数据要少得多,这反过来意味着 Raft *快照存储*的磁盘占用更小。即使是简单的测试也表明 (https://github.com/rqlite/rqlite/blob/252cc03515cec54d66c3db98947c0b05ca7f5fec/db/wal/compacting_section_scanner_test.go#L346),压缩后的 WAL 可能比原始 WAL 小一百倍。 需要明确的是,压缩是一种优化,而不是正确性机制。由未压缩的 WAL 构建的快照将在*快照存储*中产生相同的状态。压缩后的 WAL 只是更小且更易于处理。 ### 更快的重启 在所有基于 Raft 的系统中,Raft 本身就是真相的来源。状态机——在 rqlite 中是 SQLite 数据库——可以随时从头重建。具体来说,真相的来源是最后一个快照(如果有)和任何 Raft 日志条目的组合。 这导致了易于推理和恢复的系统——但当管理多 GB 数据集时,这可能导致 rqlite 重启缓慢。重启意味着必须从快照存储复制数据库,然后应用剩余的日志。但这是完全必要的吗? 不,但需要一些谨慎的编程。 当 rqlite 对当前数据库状态进行快照时,最后一步是计算 SQLite 数据库文件的校验和。然后,它将此校验和存储在与主数据库文件并行的侧车文件中。重启时,rqlite 重新计算 SQLite 文件的校验和,将其与侧车文件中存储的校验和进行比较,如果匹配,则完全跳过从快照存储的恢复。这意味着即使是拥有多 GB 数据集的系统也能在几秒内重启。 快照的第一步是删除侧车文件——这意味着如果 rqlite 在快照期间崩溃,重启时会简单地从已知完好的快照中恢复。这是一个很好的例子,一个设计细节同时满足两个需求。 这也是为什么必须禁用关闭时检查点。如果 SQLite 在最后一个连接关闭时检查点 WAL,SQLite 文件将在写入侧车校验和后被修改。下一次重启时,校验和将不匹配,快速重启路径永远不会触发。本文前面提到这一点——禁用关闭时检查点以实现“快速重启时间”——就是我们刚刚描述的。 ## 因为这些东西当然经过了测试 rqlite 经过了广泛测试 (https://philipotoole.com/how-is-rqlite-tested/),但即便如此,认识到 rqlite 以原始设计者可能未考虑过的方式使用 SQLite 也很重要。让我们看两个例子,看看 rqlite 如何确保其按设计工作。 ### 让 SQLite 检查我们的工作 SQLite 支持完整性检查 (https://sqlite.org/pragma.html#pragma_integrity_check),因此 rqlite 可以要求 SQLite 检查其工作。在单元测试和集成测试期间,rqlite 持续对快照存储中的合并数据库执行完全完整性检查。然而,对大型数据库运行完整性检查会花费大量时间,因此它在生产构建中已禁用。但是,如果 WAL 管道中的任何内容不正确——压缩扫描器、写入磁盘、快照存储中的处理——这些测试都会捕捉到。 ### 测试我们的假设 我们如何确保 SQLite 确实按照 rqlite 的假设运行?我们测试。一个例子是确保 SQLite 实际上不会在关闭时执行检查点 (https://github.com/rqlite/rqlite/blob/v10.0.5/db/db_test.go#L148)。对于 rqlite 依赖的 SQLite 中的每个行为,都有一个测试来确保这种依赖是合理的。 ## 后续步骤 如果你对使用 rqlite 感兴趣,请务必下载 rqlite 10.0 (https://github.com/rqlite/rqlite/releases/tag/v10.0.5) 并立即试用。

相似文章

SQLite:持久化工作流的全部所需

Hacker News Top

这篇博文认为,SQLite 结合 Litestream 进行异步备份,为许多工作流系统(尤其是 AI 智能体)提供了一种简单而有效的持久化执行方法,无需单独编排层或网络数据库。

sqlite-utils 4.0rc1 新增迁移和嵌套事务

Simon Willison's Blog

sqlite-utils 4.0rc1 是一个候选发布版本,新增了内置数据库迁移(从 sqlite-migrate 移植而来)以及通过 db.atomic() 实现的嵌套事务,同时包含少量不向后兼容的更改。

如何破坏SQLite数据库文件

Hacker News Top

这篇来自SQLite官方文档的文章解释了SQLite数据库可能被损坏的各种方式,例如恶意进程覆盖文件、文件描述符的误用以及不安全的备份程序,同时提供了相应的缓解策略。