前端工程師的 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 的設計是「一個檔案就是一個資料庫」,鎖本質上就是檔案層級的鎖。不是它偷懶,是儲存模型決定的。
三個直接推論:
- 多連線不會加速寫入。 N 個連線同時寫,到了資料庫端全部排隊串行化。我的爬蟲曾經開 10 條平行連線各自寫入,平行的其實只有上游 API 的抓取——DB 寫入端永遠是單線道。
- connection pool 是 row-lock 資料庫的習俗,搬過來只是多付連線成本。單例 client 就是正確設計。
- 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 的意義是把「每筆付一次」變成「整包付一次」:
- Commit 成本(SQLite 本體):每次 commit 是一次 fsync(磁碟寫入屏障)。SQLite 官方 FAQ 的名句:它每秒能執行 50,000 條 INSERT,但每秒只能完成幾十個 transaction2——慢的從來不是 INSERT,是 commit。autocommit 模式(不包 transaction)等於每條 statement 自帶一次 commit。
- 網路往返(遠端 DB 加成):Turso 的每個請求是一次 HTTPS round trip,跨區域 ~100-200ms,比 fsync 貴兩個數量級。
- 鎖排程:每個 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 的事故(見系列上一篇)。
五條實用法則
- Transaction 裡絕對不要夾網路呼叫——先在記憶體組好完整的 statements 陣列,一次
batch()送出。 - 寫入永遠合併成 batch——你付的是每筆交易的固定成本,不是每筆資料的成本。
- 不要指望多連線加速寫入——單一寫入者是物理,不是設定。
- 讀取是免費的,不用繞著寫入排程——WAL 快照讀不等鎖也不擋鎖。
- 冪等寫入 + 重試是這個模型的原生搭檔——單一寫入者保證交易嚴格排序,配上
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
- Write-Ahead Logging - SQLite — "WAL provides more concurrency as readers do not block writers and a writer does not block readers";同頁明確說明寫入者仍然全庫僅允許一個。 ↩ ↩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." ↩
- The Hrana protocol specification (v3) - tursodatabase/libsql — pipeline endpoint 與 baton 機制("the baton is similar to a session cookie")的官方規格。 ↩ ↩2