前端工程師的 SQLite 震撼教育:全庫寫鎖、WAL、還有 batch 為什麼差四百倍

我是前端工程師,對資料庫的心智模型來自 MySQL/Postgres 的常識:row-level lock、多個連線併發寫入、connection pool。直到我用 Turso(雲端版 SQLite / libSQL)跑了一個爬蟲系統、經歷了幾次事故(覆盤見系列上一篇),才發現這套常識在 SQLite 世界整組不適用

這篇整理我補課學到的 SQLite 併發模型——如果你也是從 row-lock 資料庫的直覺出發來用 SQLite / Turso / Cloudflare D1,這些反直覺的點大概也會咬到你。

不是鎖表,是鎖整個資料庫

我原本以為 SQLite 寫入時「會鎖整張表」,已經覺得夠粗了。實情更粗一級:整個資料庫同一時間只允許一個寫入者,鎖的粒度是整個資料庫1

為什麼?因為鎖的粒度跟著儲存單位走。MySQL/Postgres 是一個 server 程序管理表空間、分頁、資料列,所以鎖可以做細;SQLite 的設計是「一個檔案就是一個資料庫」,鎖本質上就是檔案層級的鎖。不是它偷懶,是儲存模型決定的。

三個直接推論:

  1. 多連線不會加速寫入。 N 個連線同時寫,到了資料庫端全部排隊串行化。我的爬蟲曾經開 10 條平行連線各自寫入,平行的其實只有上游 API 的抓取——DB 寫入端永遠是單線道。
  2. connection pool 是 row-lock 資料庫的習俗,搬過來只是多付連線成本。單例 client 就是正確設計。
  3. transaction 開多久,全世界的寫入就排隊多久。 這讓「transaction 裡夾網路呼叫」從壞習慣升級成災難——你拿著全庫唯一的寫鎖去等一個外部 API。

WAL:讀不被寫擋,但它不是讀寫分離

聽到「寫入時鎖整庫」,直覺的下一個恐慌是:那讀取不就也被擋住?不會——現代 SQLite(包含 Turso)跑 WAL(Write-Ahead Logging)模式:寫入者把新資料 append 到 WAL 檔尾巴,讀取者沿用自己開始那一刻的快照視圖,讀不擋寫、寫不擋讀1

我第一反應是「這不就是讀寫分離?」——像,但層次完全不同,值得校準:

讀寫分離(read replica)WAL 模式
本質拓撲模式:兩台以上機器單機儲存引擎的日誌技巧
一致性replica 有複製延遲,讀到舊資料是常態commit 後的下一個讀取立刻看到新資料,強一致
目的水平擴展讀取容量單機上的讀寫併發

機制上更準的類比是 Postgres 的 MVCC 快照隔離——「讀舊版本、寫新版本、互不相擾」是同一族的思想。有趣的是,Turso 的 edge replica 功能才是真的讀寫分離,而它的實作恰好就是把 WAL frames 運送到各地。WAL 是磚,讀寫分離是可以用這種磚蓋出來的房子之一。

batch 為什麼差四百倍:你付的是每筆交易的固定成本

這是最有感的一課。同樣寫入 3.8 萬筆資料到 Turso:

寫法估算耗時為什麼
逐筆 execute()~95 分鐘每筆一次 HTTPS 往返(~150ms)× 38,000
單一 batch()~13 秒全部 statements 打包成一個請求

寫入的成本分三層,batch 的意義是把「每筆付一次」變成「整包付一次」:

  1. Commit 成本(SQLite 本體):每次 commit 是一次 fsync(磁碟寫入屏障)。SQLite 官方 FAQ 的名句:它每秒能執行 50,000 條 INSERT,但每秒只能完成幾十個 transaction2——慢的從來不是 INSERT,是 commit。autocommit 模式(不包 transaction)等於每條 statement 自帶一次 commit。
  2. 網路往返(遠端 DB 加成):Turso 的每個請求是一次 HTTPS round trip,跨區域 ~100-200ms,比 fsync 貴兩個數量級。
  3. 鎖排程:每個 transaction 拿放一次全庫寫鎖。

batch 也有代價:整包是單一 transaction、全有全無(通常是優點,但失敗就整包重來);單次鎖持有時間變長;payload 變大。所以不是越大越好,一般甜蜜點在每批 500–2000 筆。

hrana:為 fetch() 時代重造的資料庫協議

最後補一塊讓我「一片陌生就在用」的拼圖:client 和 Turso 之間在網路上說的話,是一個叫 hrana 的協議3(捷克文「邊緣」)。

它存在的理由:傳統資料庫的線路協議是有狀態的二進位 TCP 長連線,在 serverless/edge 環境(沒有 raw socket、冷啟動頻繁)根本養不起。hrana 的答案是把資料庫協議建立在 HTTP/WebSocket 之上——任何有 fetch() 的地方就能當資料庫 client。一個 execute 就是一個 POST /v2/pipeline、body 是 JSON。

兩個核心概念:

  • Pipeline:把多個操作打包成一個 HTTP POST。前面說的「batch 只付一次往返」,機制就是它。
  • Baton:跨請求的會話接力棒,像 DB 版的 session cookie3。HTTP 無狀態,那開到一半的 transaction 怎麼跨請求存活?server 每次回應帶一個 baton,client 下次請求帶上。這也精確解釋了「互動式 transaction 走 HTTP 為什麼危險」——全庫寫鎖在兩次往返之間掛在 server 上等你

順帶一提:正因為協議是 fetch 原生的,你可以對 createClient 注入自訂 fetch,在請求層做 timeout、abort、觀測——這在傳統二進位協議的資料庫上無從下手。我們就是靠這一點修掉了連線 black-hole 的事故(見系列上一篇)。

五條實用法則

  1. Transaction 裡絕對不要夾網路呼叫——先在記憶體組好完整的 statements 陣列,一次 batch() 送出。
  2. 寫入永遠合併成 batch——你付的是每筆交易的固定成本,不是每筆資料的成本。
  3. 不要指望多連線加速寫入——單一寫入者是物理,不是設定。
  4. 讀取是免費的,不用繞著寫入排程——WAL 快照讀不等鎖也不擋鎖。
  5. 冪等寫入 + 重試是這個模型的原生搭檔——單一寫入者保證交易嚴格排序,配上 ON CONFLICT DO UPDATE,timeout 後盲目重送是安全操作。

環境資訊

  • Database: Turso(libSQL)
  • @libsql/client: 0.17.x / @libsql/hrana-client: 0.9.x(hrana v3, pipeline over HTTP)
  • Node.js: v22.x

本文撰寫時間:2026 年 7 月,技術版本可能隨時間更新,請以官方文件為準。

Reference

  1. Write-Ahead Logging - SQLite — "WAL provides more concurrency as readers do not block writers and a writer does not block readers";同頁明確說明寫入者仍然全庫僅允許一個。 2
  2. Frequently Asked Questions - SQLite — "SQLite will easily do 50,000 or more INSERT statements per second... But it will only do a few dozen transactions per second."
  3. The Hrana protocol specification (v3) - tursodatabase/libsql — pipeline endpoint 與 baton 機制("the baton is similar to a session cookie")的官方規格。 2