介绍 Limbo:用 Rust 对 SQLite 的完全重写

2 年前,我们对 SQLite 进行了分叉。我们是 SQLite 嵌入式特性的忠实粉丝,但渴望更开放的开发模式。libSQL作为一个开源贡献项目诞生,我们邀请社区与我们一起构建它。

libSQL 取得了惊人的成功,拥有超过 12k 的 GitHub 星标、85 位贡献者,以及原生复制和向量搜索等功能,是为Turso平台提供动力的引擎。

今天,我们宣布了一个更雄心勃勃的实验:如果我们用内存安全的语言(Rust)完全重写 SQLite 会怎样?通过 Limbo 项目,现在可在github.com/tursodatabase/limbo获得,我们现在试图回答这个问题。

#分叉的利弊

分叉并非唯一选择,我们曾考虑完全重写,但担心需要很长的时间才能有可用于生产的东西,以及保持兼容性的工作。分叉还可以让我们继续从 SQLite 回溯合并,采用新的功能。
另一方面,也有缺点:SQLite 的测试套件是专有的,这使得很难有信心进行非常大的更改。它也是用不安全的 C 语言编写的,这使得更有信心地演进代码库更加困难。
权衡利弊后,分叉是可行的方法,libSQL 项目诞生。

#新方法

向 SQLite 添加向量搜索令人大开眼界。我们不想将其作为扩展,因为我们希望使语法尽可能简单自然,这需要更改字节码生成。我们能够将向量暴露为数据类型,在同一张表中一起查询关系和向量数据,只要查询不需要索引,就可以使用非常普通的 SQL 语法。
但对于带有索引的搜索,如果不进行非常侵入性的更改,很难实现我们想要的语法:

SELECT title, year
   FROM movies
   ORDER BY vector_distance_cos(embedding, vector('[4,5,6]'))
   LIMIT 3;

最终我们采用了

SELECT title, year
   FROM vector_top_k('movies_idx', vector('[4,5,6]'), 3)
   JOIN movies
   ON movies.rowid = id;

索引表示为一个单独的表,我们必须显式地将其与主表连接。
此时,我们决定尝试一种新方法,回答这个问题:从零开始重写 SQLite 到底需要多少努力?我们能否以一种毫不费力地保持兼容性的方式做到这一点?这是否会使我们更有信心进行一些我们在分叉中想做的事情(如异步 I/O)?
为了回答这些问题,Pekka 在他的个人 GitHub 账户上开始了一个雄心勃勃的实验。它暂时被命名为 Limbo,并且进展非常顺利。没有太多宣传,只是我在 𝕏 上谈论它,该项目就获得了 1000 个 GitHub 星标,并自然地吸引了 30 多位贡献者。

#下一步

随着这个实验的成功,我们决定将 Limbo 变成 Turso 的一个官方项目。它仍然是一个实验,但现在是一个官方的 Turso 实验,这将使我们能够投入更多资源,包括公司其他工程师的更多时间。
我们的目标是从头开始重新实现 SQLite,在语言和文件格式级别完全兼容,具有与 SQLite 相同或更高的可靠性,但具有完全的内存安全性和新的现代架构。
这并不是说我们在构建 libSQL 的竞争对手或替代品:如果它成功,这个代码库就成为 libSQL。代码根据与 libSQL 相同的许可证(MIT)发布,并具有定义我们项目的同样友好的社区态度。

#我们能达到 SQLite 世界闻名的可靠性吗?

由于这是一个重新实现,这是否意味着测试更加困难?实际上情况正好相反。由于我们是从头开始重新实现它,我们从一开始就内置了确定性模拟测试(DST)。我们既在数据库核心中添加了 DST 设施,又与 Antithesis 合作,在数据库中实现了与 SQLite 声誉相符的可靠性水平。
确定性模拟测试是TigerBeetle的团队所推崇的一种范式,我们在 Turso 已经在我们的服务器端代码中进行了尝试。通过 DST,我们相信我们可以实现比 SQLite 更高的稳健性,因为在模拟器中更容易模拟不太可能出现的场景,用不同的事件顺序测试多年的执行,并在发现问题时 100%可靠地重现它们。
在 DST 世界中,编写我们自己的模拟器就像编写单元测试一样:它们允许我们快速行动,轻松实验,并彻底测试变化。但就像单元测试并没有消除对更高层次的集成测试的需求一样,这种测试在更高层次上测试系统的行为,我们觉得我们需要付出额外的努力来达到我们想要的可靠性水平。
为了完成这个难题,我们希望确定性地测试数据库与操作系统和其他组件交互时的行为。为此,我们与Antithesis合作,这是一家提供系统级确定性模拟测试框架的公司,可以模拟各种硬件和软件故障。Antithesis 通过提供一个确定性的虚拟机来实现这一点,该虚拟机并行运行许多模糊测试线程,使我们能够快速搜索输入空间。
例如,他们已经帮助我们在部分写入的情况下发现了io_uring实现中的问题。我们自己的 DST 框架不会捕获到这一点,因为在测试中实际的 I/O 循环被模拟的 I/O 循环所取代。部分写入是一种极其罕见的情况,因此很难以自动化的方式进行测试。
除了确定性模拟测试,我们还定期对输入进行模糊测试,然后确保生成的字节码对于 Limbo 和 SQLite 都是相同的。

#当前状态

虽然 Limbo 仍处于早期阶段,但已经取得了一些重要的里程碑,值得注意的有以下几点:

#完全异步 I/O

Limbo 被设计为完全异步。SQLite 本身有一个同步接口,这意味着想要异步行为的驱动程序作者需要额外的复杂性来使用辅助线程。由于 SQLite 查询通常很快,因为不涉及网络往返,许多驱动程序只是采用同步接口。然而,这有两个根本问题:并非所有的 SQLite 查询都很快。例如,对大量数据的聚合总是很慢,即使数据完全是本地的。在现代环境中,实际上希望查询通过网络进行。一个例子是 Turso,它通过 HTTP 提供 SQLite。另一个例子是在 S3 上实现的 SQLite,提供无限存储空间的假象,其中数据可以在本地缓存,但部分数据可能是远程的。
Limbo 从一开始就被设计为异步的。它扩展了sqlite3_step,这是 SQLite 的主要入口点 API,使其异步,允许在数据尚未准备好立即使用时返回给调用者。在 Linux 上,Limbo 使用io_uring,这是一个用于异步系统调用的高性能 API。

#为 WASM 设计

虽然 SQLite 可以编译为 WASM,但这对 SQLite 来说大多是事后的想法。实际上,像 wa-sqlite 这样的项目存在,以扩展 SQLite 并使其在像Stackblitz这样的 WASM 环境中运行。Limbo 从一开始就被设计为具有 WASM 构建,并且已经有一个 VFS 实现,可以在不进行任何更改的情况下与 Drizzle 等流行工具一起使用。例如,可以编写:

import { drizzle } from 'drizzle-orm/better-sqlite3';
import * as s from 'drizzle-orm/sqlite-core';
import { Database } from 'limbo-wasm';

const sqlite = new Database('sqlite.db');
const db = drizzle({ client: sqlite });

const users = s.sqliteTable('users', {
  id: s.integer(),
  name: s.text(),
});

const result = db.select().from(users).all();

console.log(result);

浏览器支持正在开发中。

#性能

SQLite 以其出色的性能而闻名,但在许多操作中,Limbo 已经与 SQLite 相当或更快。在 Limbo 的主目录上运行cargo bench,我们可以比较 SQLite 运行SELECT * FROM users LIMIT 1(我的 Macbook Air M2 上为 620ns)和 Limbo 执行相同查询(506ns),快 20%。

#简单性

尽管 SQLite 的基于文件的特性使其使用极其简单,但多年来 SQLite 增长了相当多的可调参数,这使得要获得最佳性能并不容易(上面基准测试中的 SQLite 数字是经过调整后的)。为了获得最佳性能,用户必须选择 WAL 模式而不是 journal 模式,禁用 POSIX 咨询锁等。
Limbo 在保持与 SQLite 字节码和文件格式兼容的同时,放弃了我们认为对现代环境不太重要的许多功能(包括 SQLite 的“合并”,即生成单个 C 文件的构建系统),提供了更好的开箱即用体验。

#对了解更多感兴趣?

Limbo 在 MIT 许可证下可在我们的 GitHub上获得。如果你有兴趣构建一个具有大胆愿景的嵌入式数据库,将 SQLite 的承诺提升到一个新的水平,来与我们一起构建吧。

阅读 10
0 条评论