Lulucat

為什麼我們暫時不用資料庫

張高歌張高歌

我們為 iPad 手寫 app 評估了 Turso/libSQL,測量各項資料後選擇扁平快照檔案。這個工作負載不需要資料庫,每一步都只為已經出現的問題付出代價。

Lulucat Notes 是一款 iPad 手寫 app。直到上星期,它只有一塊畫布,也沒有第二份筆記的概念。我們準備加入筆記庫,其中包含多份文件,每份文件包含多個頁面。首先要解決的是儲存問題。

筆記 app 儲存結構化資料,資料庫通常用來儲存這類資料。我們評估了 Turso 及其 Swift SDK,在 iOS 模擬器上執行,用真實筆畫資料做基準測試,最後決定不採用它。

我們改用扁平檔案,原因如下。

木桌上打開的筆記本,裡面有手寫筆記,旁邊放著一支筆。

照片由 Gabriel Cox 拍攝,來自 UnsplashUnsplash License

App 實際如何處理資料

手寫 app 的資料存取模式單一且可預測。讀取操作會開啟一個頁面,並把頁面上的每個元素——所有筆畫和圖片——一次載入記憶體。畫布會持有全部內容,不會執行部分查詢。寫入操作會在完成一道筆畫後,將一個元素附加到頁面。在少數情況下,使用者會擦除一筆的部分內容、移動選取範圍或刪除某個元素,但這些操作仍然只涉及單一頁面中的單一元素。

不存在並行存取。一次只有一個人在一份文件的一個頁面上書寫。App 也不需要跨文件搜尋——筆記庫只需讀取每份文件的標題、時間戳記、頁數和封面縮圖,不需要讀取頁面內容。

資料庫是為查詢、索引和並行協調而建立的。我們的 app 不需要其中任何一項。

Turso 評估

我們評估了 libsql-swift,這是 Turso 的 libSQL 引擎官方 Swift SDK。

SDK 可以正常運作。 它的全部 9 個測試案例都通過了。我們把它整合到 app 副本中,為 iOS 模擬器建置並啟動,然後在 app 沙盒中建立本機資料庫。我們在一個交易中寫入 100 筆,每筆包含 3,400 個取樣點——總共 4,080,000 bytes 的 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 初始化器沒有公開完整的本機優先行動 app 所需的 offline 參數。現在採用 libsql-swift,只能得到本機 SQLite 分支,無法取得讓 Turso 與眾不同的同步能力。

資料庫目前會增加哪些成本

即使 SDK 已經成熟,我們仍然要為目前工作負載用不上的能力付出成本:

WAL 附屬檔案管理。 執行中的資料庫會建立 -wal-shm 附屬檔案。複製文件時,要麼先執行 checkpoint,要麼以原子方式複製三個檔案。把 .lnote 封裝匯出到 Files 或 AirDrop,現在需要一個使用者看不見、開發者也不能忘記的匯出前步驟。

轉接層。 筆畫需要序列化成 BLOB,再反序列化回來。頁面元素天然具有陣列順序,畫布可以直接按照這個順序渲染;資料庫則會引入資料列順序和 z-index 欄位。我們需要在同一份資料的兩種表示法之間撰寫轉換層,並在每次 schema 變更後維護它。

一個 161 MB 的相依套件。 對於 Debug 建置不到 2 MB 的 app,一個大於 app 本身 80× 的相依套件值得注意,尤其是它還被標示為 technical preview,而且已經一年沒有活動。

這些成本會在相依套件連結時產生;換來的能力——查詢、索引和並行寫入——目前的 app 都用不上。

我們採用的方案:快照檔案封裝

一個 .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、標題、時間戳記,以及依順序排列的頁面清單;每個頁面還記錄畫布尺寸、時間戳記和元素數量。頁面內容檔案以 app 已經使用的相同量化整數編碼儲存元素陣列——座標和半徑精確到 0.1 點,壓力精確到千分之一,時間戳記使用相對毫秒數。

筆記庫只讀取 manifest.json 和封面縮圖。它從不解析 document.json 或任何頁面內容。開啟頁面時只載入一個 .content 檔案,這是唯一涉及筆畫資料的檔案讀取。

頁面解決寫放大問題

手寫 app 本來就有頁面這個概念:它既是使用者思考的單位,也是使用者滑動切換的物件。將頁面作為儲存單位後,自動儲存只需重寫發生變化的頁面。

一頁手寫內容——例如 1,000 到 2,000 筆——在我們的量化格式下約佔 3–5 MB。一段包含 21 筆、每筆有 3,400 個取樣點的記錄,量化後約為 55 KB。把一個頁面快照寫入快閃儲存,在現代硬體上耗時 10–20 ms。配合 0.5s 去抖動,儲存過程不會被使用者察覺。

儲存成本取決於目前頁面的書寫量,而不是文件中的頁面總數。200 頁的筆記本和 2 頁的筆記本儲存速度完全相同,因為每次只會重寫已變更的頁面。

每次寫入都使用原子檔案操作——先寫入暫存檔,再重新命名——因此儲存途中發生當機不會產生截斷的頁面檔案。進入背景時,App 會立即寫入所有已變更的頁面,這與 app 只有單一畫布時的行為一致。

沒有交易也能保持一致性

檔案封裝沒有交易,但有一套作用相同、而且清楚的歸屬規則:

先寫資源,再寫參照。 使用者插入圖片時,資源檔案會立即寫入 assets/。參照該資源 ID 的頁面快照會由經過去抖動的自動儲存稍後寫入。因此,頁面不會參照磁碟上不存在的資源。

權威來源優先。 document.jsonpages/ 目錄是權威來源,manifest.json 是快取。如果兩者不一致,下次儲存會把快取調整為與權威來源一致。縮圖是衍生資料,可以隨時重新產生。

保留孤立檔案,不留下懸空參照。 當機後可能留下孤立資源——assets/ 中有一個沒有頁面參照的檔案。關閉文件時會清理孤立資源。相反,不會出現頁面參照遺失檔案的情況,因為資源總是在參照它的頁面快照之前寫入。

這些規則比 WAL checkpoint 和交易隔離更容易分析,也完全符合 app 單一程序、單一頁面的存取模式。

儲存引擎的升級路徑

現在選擇扁平檔案只是目前方案。封裝結構經過設計,可以在不改變封裝本身的情況下升級儲存引擎,只改變封裝內的內容。

第 1 級:快照加追加日誌。 如果寫放大變得明顯——例如一頁有數千筆時持續書寫,導致儲存明顯變慢——每個頁面檔案就拆成一個快照和一個只追加的日誌。新元素以 [length][CRC][type][payload] frame 追加。重播日誌時,CRC 不相符的 frame 會被丟棄,從而提供當機安全性。當日誌超過閾值或頁面關閉時,它會重新合併回快照。這大約只需要 ~200 LOC,不依賴任何外部相依套件。

由於頁面層級快照已經消除了跨頁面寫放大,這一級可能很長時間都用不上。每 0.5s 重寫一次 5 MB 頁面,仍然在快閃儲存的寫入預算之內。

第 2 級:SQLite 資料庫。 如果 app 將來需要跨筆記全文搜尋、逐元素同步或跨文件索引,SQLite 就會成為合適的工具。屆時很可能使用 GRDB,它是成熟、以原始碼編譯的 Swift 包裝,幾乎不會增加二進位檔大小。只有當 Turso 生態系——具體來說,是 Turso Sync for Swift——成為真正的產品需求時,我們才會重新考慮 libsql-swift

遷移路徑很直接:每個頁面的元素陣列都可以對應到 strokes / images 資料表,每筆對應一個不可變 BLOB,每個取樣點在 little-endian 二進位編碼中佔用 16 bytes。100 筆的 0.019s 基準測試已經證明這種方法可行。正式推出前,遷移會驗證舊封裝與新資料庫中的元素數量和資源數量是否一致,並驗證當機復原、WAL 邊界和背景寫入是否全部通過。

什麼時候值得承擔成本

這項選擇是根據 app 目前的需求作出的。SQLite 能處理共享狀態中的並行寫入、複雜查詢和當機復原,但我們的 app 目前不需要這些能力。在 app 出現這些問題之前就為相應能力付費,只會增加成本,暫時得不到相應收益。

資料庫的成本——相依套件、WAL 管理、轉接層和二進位檔大小——從連結函式庫的那一刻就開始。它的收益要等到 app 需要執行查詢、維護索引或協調並行寫入時才會出現。目前 app 沒有這三類需求。

儲存演進的每一步都只為已經出現的問題付費。頁面層級快照解決目前的問題:儲存多頁文件時,不必重寫整個檔案。如果寫放大變得可測量,追加日誌可以處理這個問題。如果搜尋或同步成為產品需求,資料庫可以處理這個問題。

封裝結構、manifest 和文件 schema 都不綁定任何儲存引擎。邊界劃分正確,切換成本就低。真正需要資料庫時,我們會針對一個已經測量過的具體問題採用它,而不是為假設的問題引入資料庫。