SQLite 很適合桌面工具、單機服務、測試資料庫與邊緣應用,但「程式能開很多執行緒」不代表資料庫能同時接受很多 writer。真正可靠的設計不是無限制增加工作數,而是先辨認 SQLite 的鎖定邊界,再把連線生命週期、等待策略、交易範圍與查詢效率視為同一個併發契約。本文聚焦 SQLite 併發寫入 的資訊型搜尋意圖,提供能在本機暫存目錄安全重現的做法,不包含正式環境操作,也不把特定專案事故包裝成個人成果。
SQLite 併發寫入的核心限制是什麼
SQLite 即使切到 WAL,仍然一次只允許一個 writer;WAL 改善的是 reader 與 writer 的重疊,不是把單檔資料庫變成多 writer 引擎。
先把兩個常被混在一起的問題拆開。第一個問題是「讀取是否會因寫入而長時間停住」;第二個問題是「多個寫入能否真正同時提交」。官方 WAL 文件說明,WAL 將變更先追加到獨立紀錄,reader 依自己的 end mark 取得一致快照,因此 reader 與 writer 通常能並行。不過該文件也明確保留限制:同一時間仍只有一個 writer。若工作佇列同時放出大量寫入,競爭只是從 reader-versus-writer 轉成 writer-versus-writer,並不會消失。
Rollback journal 與 WAL 的差異也不能簡化成「前者完全不能讀、後者完全不鎖」。SQLite 的鎖會隨交易階段改變;短暫等待、快照可見性與 checkpoint 都會影響觀察。技術判斷應以應用程式真正執行的交易與檔案系統為準,而不是只看連線字串。WAL 需要同一主機上的程序透過共享記憶體協調,不適合把資料庫檔放在不支援該機制的網路檔案系統上。
因此容量規劃的正確單位不是「執行緒數」,而是每秒進入資料庫的寫入工作、每次交易持有 writer 的時間,以及等待超時後的應用程式行為。可把 CPU 密集工作平行化,但應在資料庫邊界設置有界 writer 佇列;這與單純在所有程式碼外層加一把大鎖不同,因為 reader、計算與準備工作仍可獨立進行。
WAL 與 checkpoint 應如何設定
先在新建的測試資料庫執行並讀回 PRAGMA journal_mode=WAL,確認結果確實為 wal;設定成功後仍要監控 checkpoint 與長交易。
journal_mode 不是只寫不查的開關。對檔案型資料庫執行 pragma 後,應讀取回傳列並拒絕在結果不符時繼續測試。WAL 模式通常會持續存在於資料庫檔,因此初始化可以做到冪等;然而部署腳本仍不應假設先前某次設定必然成功。唯讀檔案、檔案系統限制或同時存在的連線,都可能讓觀察結果不同。
WAL 把提交路徑變成追加,但資料最終仍要由 checkpoint 搬回主資料庫。官方文件指出 SQLite 預設會在 WAL 達到一定頁數時自動 checkpoint;實際延遲會因此呈現「多數提交很快,偶爾某次較慢」的型態。不要捏造固定效能倍數,也不要以一次短測試宣稱完成調校。先記錄交易時間分布、WAL 大小、checkpoint 結果與磁碟錯誤,再決定是否改變自動 checkpoint 門檻或安排獨立維護時段。
長時間 reader 可能讓 checkpoint 無法跨越其 end mark,WAL 便持續成長。這不是開更多連線能解決的問題。查詢端應縮小資料集、使用合適索引、及早完成 reader,並確認 DataReader、command 與 connection 都有明確 Dispose 邊界。若 ADO.NET provider 使用連線池,Dispose 通常代表把資源安全歸還池,而不是任由參考留到垃圾回收器處理。
Busy Timeout 為何不是併發控制器
Busy timeout 只是在鎖暫時不可用時等待一段上限,不能保證公平、不能增加 writer 數,也不能取代有界佇列、交易設計與錯誤分類。
PRAGMA busy_timeout=2000 的單位是毫秒,而且設定作用在當前資料庫連線。官方 pragma 文件也提醒,每個 database connection 只能有一個 busy handler;後續 provider 設定可能覆蓋先前值。故應在每條新連線的初始化路徑統一設定,並透過查詢 PRAGMA busy_timeout 讀回,而不是在應用程式啟動時只執行一次後便推定所有連線相同。
等待上限必須配合服務層的整體截止時間。例如上層工作只允許一秒,資料庫卻等待兩秒,取消與重試的語意便互相衝突。相反地,把 timeout 設得很大也只會把立即失敗改成長時間堆積。應區分可重試的短暫 busy、資料約束錯誤、磁碟錯誤與程式映射錯誤;只有第一類適合有限次數、帶抖動且受截止時間約束的重試。
如果多個 writer 長期飽和,應先量測交易內做了什麼。禁止把網路請求、檔案轉換或大量物件序列化放在寫入交易中;先在交易外準備資料,進入交易後只做參數化命令與必要查詢。timeout 是吸收短暫尖峰的保護帶,不是掩蓋持續超載的容量方案。
交易、批次與參數化如何縮短鎖定時間
把同一工作單元的批次寫入包在明確交易,重用參數化命令,並讓交易內容保持最小,可同時改善一致性與縮短反覆提交的成本。
SQLite 官方交易文件指出,除了 read transaction 外,同一時間只能存在一個 write transaction。BEGIN DEFERRED 不會立即開始交易;第一個存取決定它成為讀或寫交易。若流程一定要寫,可在測試中比較 BEGIN IMMEDIATE,它會更早嘗試取得寫入能力,也可能立刻回報 busy。選擇重點不是哪個關鍵字永遠最快,而是哪個時點讓你的應用程式最容易正確處理競爭。
逐筆自動提交會重複支付交易與同步成本,也讓部分成功更難處理。批次交易應明確定義原子性:任一步驟失敗就 rollback,呼叫端取得失敗結果,不可把半套資料當作完成。批次也不宜無上限;單次交易太大會延長 writer 持有時間、增加 WAL 與復原成本。用測試資料逐步調整批次量,記錄延遲分布與失敗類型,不要引用無來源的「最佳筆數」。
參數化不只是輸入安全,也能避免手動引號與型別格式化錯誤。欄位名稱或排序方向不能直接當一般 value parameter;這些結構元素應從固定 allow-list 映射。所有 command、reader 與 transaction 都要在可見的 using 或 try/finally 中結束,尤其不可回傳仍依賴已關閉 reader 的延遲列舉。
ADO.NET 型別與 DataReader 對映怎麼做
不要假設 SQLite INTEGER 就等於 CLR Int32;先讀 provider 實際值,檢查欄位是否存在,再以具範圍驗證的轉型建立穩定對映。
SQLite 採動態型別,型別是值的屬性;INTEGER storage class 可用不同位元組數儲存有號整數。這不等於特定 ADO.NET provider 必須讓 GetInt32 接受所有值。穩健 mapper 會先取得 ordinal 與 GetValue 的實際型別,再依目的屬性做 null、範圍及轉型檢查。若目的欄位只能容納 32 位元,超出範圍應明確失敗,不應截斷。
通用反射 mapper 也不能假設每個實體屬性都出現在每次 SELECT。先建立 reader 欄位名稱到 ordinal 的字典;缺少的欄位依契約保留預設值、標記錯誤或使用專用 DTO。三種策略都比捕捉 IndexOutOfRangeException 後忽略整列更可觀察。對 nullable、日期、decimal 與 enum 也要分別測試,不要讓 Convert.ChangeType 成為無差別萬用解。
資料讀取完成後應立即 Dispose reader,再結束 command 與 connection。若 logger 也由多個工作共享,使用單一 writer channel 或有鎖的 append;不要每次寫 log 都重讀並覆寫整個檔案。資料庫與紀錄檔是兩個不同共享資源,兩者的等待與失敗應分開觀測,避免把檔案 IOException 誤判成 SQLite 鎖定。
驗證與安全重現步驟
以下測試只在暫存目錄建立一次性 SQLite 檔案,固定使用 Python 3.11.15 與內建 SQLite 3.53.1,未連線到正式資料或外部帳號。
- 確認本機
python --version顯示Python 3.11.15,並建立空白測試目錄;若版本不同,先在隔離環境安裝固定版本。 - 將下列程式存成
verify_sqlite_article.py,執行python verify_sqlite_article.py;程式只操作TemporaryDirectory內的新檔案。 - 檢查輸出必須包含
journal_mode=wal、busy_timeout_ms=2000、committed_rows=3與integer_storage_class=integer;缺少任一列即視為失敗。 - 再執行一次,確認沒有殘留測試檔造成衝突;若 Windows 回報檔案仍被使用,代表連線未真正
close(),不可把測試判為通過。
import sqlite3, tempfile
from pathlib import Path
print(f"python_sqlite={sqlite3.sqlite_version}")
with tempfile.TemporaryDirectory() as directory:
database = Path(directory) / "verification.db"
connection = sqlite3.connect(database, timeout=2.0)
try:
mode = connection.execute("PRAGMA journal_mode=WAL").fetchone()[0]
connection.execute("PRAGMA busy_timeout=2000")
timeout = connection.execute("PRAGMA busy_timeout").fetchone()[0]
connection.execute("CREATE TABLE items (id INTEGER PRIMARY KEY, name TEXT NOT NULL)")
with connection:
connection.executemany("INSERT INTO items(name) VALUES (?)", [("alpha",), ("beta",), ("gamma",)])
count = connection.execute("SELECT COUNT(*) FROM items").fetchone()[0]
stored_type = connection.execute("SELECT typeof(id) FROM items LIMIT 1").fetchone()[0]
finally:
connection.close()
print(f"journal_mode={mode}")
print(f"busy_timeout_ms={timeout}")
print(f"committed_rows={count}")
print(f"integer_storage_class={stored_type}")
本次排程環境已實際執行兩輪修正流程。第一輪雖輸出預期值,但因 Python connection context manager 管理交易而不負責關閉連線,清理暫存檔時真實出現檔案使用中的失敗;加入 finally: connection.close() 後第二輪通過。最終真實輸出為 SQLite 3.53.1、WAL wal、timeout 2000、三列已提交、INTEGER storage class 為 integer。這段記錄保留失敗與修正,不虛構 benchmark 或成功率。
導入前檢查清單與結論
上線前應同時確認 WAL 回讀、timeout 邊界、writer 有界化、交易原子性、Dispose、索引與型別對映,缺少任一項都可能讓偶發錯誤回來。
journal_mode已在與目標相同類型的檔案系統讀回驗證,而不只是送出 pragma。- 每條新連線都有一致的 busy timeout,且不超過工作整體截止時間。
- writer 數量由 queue 或 semaphore 明確限制,並且拒絕無上限積壓。
- 批次交易有 rollback 測試,command 使用參數化值,交易內不做外部 I/O。
- reader、command、transaction 與 connection 都能在成功、例外及取消路徑 Dispose。
- 查詢計畫與索引以代表性資料檢查,沒有把全表 materialize 當作方便的預設。
- INTEGER 轉 CLR 整數前做 null 與範圍檢查,缺少欄位的契約可觀察。
- checkpoint 與 WAL 大小納入監控,長 reader 與檔案錯誤可分辨。
結論是:WAL、busy timeout 與批次交易各自只解決一部分問題。WAL 提升 reader/writer 重疊,timeout 吸收短暫競爭,短小交易降低 writer 持有時間;真正的可靠性來自把三者與有界併發、確定性 Dispose、索引及 mapper 測試組成一份可驗證契約。當單一 writer 的容量已無法滿足需求,應重新評估資料分割或 client/server 資料庫,而不是繼續堆高執行緒與等待時間。