为什么我们暂时不使用数据库

张高歌张高歌

我们为 iPad 手写应用评估了 Turso/libSQL,测量了各项数据,并选择了扁平快照文件。这个工作负载不需要数据库,每一步都只为已经出现的问题付出代价。

Lulucat Notes 是一款 iPad 手写应用。直到上周,它只有一块画布,也没有第二份笔记的概念。我们准备加入笔记库,其中包含多个文档,每个文档包含多个页面。首先要解决的是存储问题。

笔记应用存储结构化数据,数据库通常用于存储这类数据。我们评估了 Turso 及其 Swift SDK,在 iOS 模拟器上运行,用真实笔画数据做了基准测试,最后决定不使用它。

我们改用了扁平文件,原因如下。

木桌上打开的笔记本,里面有手写笔记,旁边放着一支笔。

照片由 Gabriel Cox 拍摄,来自 UnsplashUnsplash License

应用实际如何处理数据

手写应用的数据访问模式单一且可预测。读取操作会打开一个页面,并把页面上的每个元素——所有笔画和图片——一次性加载到内存。画布会持有全部内容,不会执行部分查询。写入操作会在完成一笔笔画后,向页面追加一个元素。少数情况下,用户会擦除一笔的部分内容、移动选区或删除某个元素,但这些操作仍然只涉及单页中的单个元素。

不存在并发访问。一个人一次只会在一个文档的一页上书写。应用也不需要跨文档搜索——笔记库只需读取每个文档的标题、时间戳、页数和封面缩略图,不需要读取页面内容。

数据库是为查询、索引和并发协调而构建的。我们的应用不需要其中任何一项。

Turso 评估

我们评估了 libsql-swift,这是 Turso 的 libSQL 引擎官方 Swift SDK。

SDK 可以正常工作。 它的全部 9 个测试用例都通过了。我们把它集成到应用副本中,为 iOS 模拟器构建并启动,然后在应用沙盒中创建了一个本地数据库。我们在一个事务中写入了 100 笔,每笔包含 3,400 个采样点——共 4,080,000 字节的 BLOB 数据。在开发用 Mac 上,这一操作耗时约为 0.019s。

执行 PRAGMA wal_checkpoint(TRUNCATE) 后,WAL 文件缩小为 0。我们可以只把主 .db 文件复制到另一个位置,打开它,并读回全部数据。引擎本身运行正常。

SDK 有成本。 CLibsql.xcframework 占用 161 MB。链接之后,我们的 Debug 模拟器构建产物从约 1.9 MB 增加到约 8.2 MB。API 是同步且阻塞的,没有 Swift Concurrency 封装。它没有显式的 close() 方法。Transaction.commit() 不会抛出异常,因为底层 C API 返回 void。仓库的 README 将这个 SDK 标为 technical preview,最近一次提交是在评估前约一年,也就是 2025 年 7 月。

Turso 生态存在缺口。 Turso 现在建议新项目使用新的 Turso Database 引擎和 Turso Sync 协议。Turso Sync 为 TypeScript、Python、Go 和 Rust 提供客户端 SDK,但没有 Swift SDK。旧的 Embedded Replica 模式存在于 libsql-swift 中,但它的 Swift 初始化器没有暴露完整的本地优先移动应用所需的 offline 参数。现在采用 libsql-swift,只能得到本地 SQLite 分支,无法获得让 Turso 与众不同的同步能力。

数据库目前会增加哪些成本

即使 SDK 已经成熟,我们仍然要为当前工作负载用不上的能力付出成本:

WAL 伴随文件管理。 运行中的数据库会创建 -wal-shm 伴随文件。复制文档时,要么先执行 checkpoint,要么原子地复制三个文件。把 .lnote 包导出到 Files 或 AirDrop,就需要一个用户看不见、开发者也不能忘记的预导出步骤。

适配层。 笔画需要序列化为 BLOB,再反序列化回来。页面元素天然有数组顺序,画布可以直接按这个顺序渲染;数据库则会引入行顺序和 z-index 列。我们需要在同一数据的两种表示之间编写转换层,并在每次 schema 变更后维护它。

一个 161 MB 的依赖。 对于 Debug 构建不到 2 MB 的应用,一个大于应用本身 80× 的依赖值得注意,尤其是它还被标为 technical preview,并且已经一年没有活动。

这些成本会在依赖链接时产生;换来的能力——查询、索引和并发写入——当前的应用都用不上。

我们采用的方案:快照文件包

一个 .lnote 文档是一个目录包:

Documents/Notes/<UUID>.lnote/
  manifest.json              # library cache: title, time, page count, cover
  document.json              # source of truth: document metadata + page order
  pages/
    <page-uuid>.content      # one snapshot per page
  assets/                    # document-level shared resources
    <asset-uuid>.jpg
  thumbnails/
    <page-uuid>.jpg          # per-page thumbnail; first page doubles as cover

document.json 是文档结构的权威来源:其中包含文档 ID、标题、时间戳,以及按顺序排列的页面列表;每个页面还记录画布尺寸、时间戳和元素数量。页面内容文件以应用已经使用的相同量化整数编码存储元素数组——坐标和半径精确到 0.1 点,压力精确到千分之一,时间戳使用相对毫秒数。

笔记库只读取 manifest.json 和封面缩略图。它从不解析 document.json 或任何页面内容。打开页面时只加载一个 .content 文件,这是唯一涉及笔画数据的文件读取。

页面解决写放大问题

手写应用本来就有页面这个概念:它既是用户思考的单位,也是用户滑动切换的对象。把页面作为持久化单位后,自动保存只需重写发生变化的页面。

一页手写内容——例如 1,000 到 2,000 笔——在我们的量化格式下约占 3–5 MB。一段包含 21 笔、每笔有 3,400 个采样点的记录,量化后约为 55 KB。把一个页面快照写入闪存,在现代硬件上耗时 10–20 ms。配合 0.5s 防抖,保存过程不会被用户察觉。

保存成本取决于当前页面的书写量,而不是文档中的页面总数。200 页的笔记本与 2 页的笔记本保存速度完全相同,因为每次只重写已修改的页面。

每次写入都使用原子文件操作——先写入临时文件,再重命名——因此保存过程中发生崩溃不会产生截断的页面文件。进入后台时,应用会立即写入所有已修改的页面,这与单画布时代的行为一致。

没有事务也能保持一致性

文件包没有事务,但有一套作用相同、而且清晰的归属规则:

先写资源,再写引用。 用户插入图片时,资源文件会立即写入 assets/。引用该资源 ID 的页面快照由防抖自动保存稍后写入。因此,页面不会引用磁盘上不存在的资源。

权威来源优先。 document.jsonpages/ 目录是权威来源,manifest.json 是缓存。如果两者不一致,下次保存会把缓存调整为与权威来源一致。缩略图是派生数据,可以随时重新生成。

保留孤立文件,不留下悬空引用。 崩溃可能留下孤立资源——assets/ 中有一个没有页面引用的文件。关闭文档时会清理孤立资源。相反,不会出现页面引用缺失文件的情况,因为资源总是在引用它的页面快照之前写入。

这些规则比 WAL checkpoint 和事务隔离更容易分析,也完全符合应用单进程、单页面的访问模式。

存储引擎的升级路径

现在选择扁平文件只是当前方案。包结构经过设计,可以在不改变包本身的情况下升级存储引擎,只改变包内的内容。

第 1 级:快照加追加日志。 如果写放大变得明显——例如一页有数千笔时持续书写,导致保存明显变慢——每个页面文件就拆成一个快照和一个只追加的日志。新元素以 [length][CRC][type][payload] 帧追加。重放日志时,CRC 不匹配的帧会被丢弃,从而提供崩溃安全性。当日志超过阈值或页面关闭时,它会重新合并回快照。这大约只需要 ~200 LOC,不依赖任何外部库。

由于页面级快照已经消除了跨页面写放大,这一级可能很长时间都用不上。每 0.5s 重写一次 5 MB 页面,仍然在闪存写入预算之内。

第 2 级:SQLite 数据库。 如果应用将来需要跨笔记全文搜索、逐元素同步或跨文档索引,SQLite 就会成为合适的工具。届时很可能使用 GRDB,它是成熟、源码编译的 Swift 封装,几乎不会增加二进制体积。只有当 Turso 生态——具体来说,是 Turso Sync for Swift——成为真实的产品需求时,我们才会重新考虑 libsql-swift

迁移路径很直接:每个页面的元素数组都可以映射到 strokes / images 表,每笔对应一个不可变 BLOB,每个采样点在 little-endian 二进制编码中占用 16 bytes。100 笔的 0.019s 基准测试已经证明这种方法可行。正式发布前,迁移会验证旧包与新数据库中的元素数量和资源数量是否一致,并验证崩溃恢复、WAL 边界和后台写入是否全部通过。

什么时候值得付费

这个选择基于应用当前的需求。SQLite 能处理共享状态中的并发写入、复杂查询和崩溃恢复,但我们的应用目前不需要这些能力。在应用出现这些问题之前就为相应能力付费,只会增加成本,暂时得不到相应收益。

数据库的成本——依赖、WAL 管理、适配层和二进制体积——从链接库的那一刻就开始。它的收益要等到应用需要执行查询、维护索引或协调并发写入时才会出现。目前应用没有这三类需求。

存储演进的每一步都只为已经出现的问题付费。页面级快照解决当前的问题:保存多页文档时,不必重写整个文件。如果写放大变得可测量,追加日志可以处理这个问题。如果搜索或同步成为产品需求,数据库可以处理这个问题。

包结构、manifest 和文档 schema 都不绑定任何存储引擎。边界划分正确,切换成本就低。真正需要数据库时,我们会针对一个已经测量过的具体问题采用它,而不是为假设的问题引入数据库。