在 C# 中將 SQLite 用於業務應用程式 ── WAL 模式・排他控制・損毀對策・與 EF Core 的取捨
· 小村 豪 · SQLite, C#, .NET, Microsoft.Data.Sqlite, EF Core, 資料儲存, Windows, 維運, 技術諮詢
在上一篇文章「Windows 應用程式的資料儲存位置選擇」中,我寫到持續增加的業務資料、歷程記錄的儲存位置,SQLite 是首選。這個判斷本身沒有問題,但實際要整合進系統時,又會冒出另一種猶豫。諮詢時常聽到的聲音是:「在 NuGet 搜尋 SQLite 會跳出好幾個套件,不知道該裝哪一個」「雖然能動,但偶爾會出現 database is locked,靠加重試撐著,不確定這樣做對不對」「備份是不是把資料庫檔案複製一份就好」。
這些都是業務應用程式要用 SQLite 運作好幾年時必然會遇到的議題,只要在最初設計時掌握好,後面就不會吃苦頭。本文以 Microsoft.Data.Sqlite 為前提,把函式庫選擇、連線字串與連線池、WAL 模式的原理、如何面對 SQLITE_BUSY、型別對應的陷阱、損毀對策與備份,一直到與 EF Core 的取捨,這些每次設計審查都會確認的項目完整整理一遍。
1. 先講結論
- 新專案要用的函式庫,基本上是
Microsoft.Data.Sqlite(或建立在其上的 EF Core SQLite 提供者)。它和System.Data.SQLite是連線字串與細節行為都不相容的兩個東西,使用網路上的範例時,要隨時留意它是以哪一個為前提。1 - 從第一個版本開始就啟用 WAL 模式。 讀取與寫入的並行度會提高,
database is locked大部分都會消失。這個設定會永久記錄在資料庫檔案本身,但無法在網路共用資料夾上使用。2 database is locked(SQLITE_BUSY)不是「加上錯誤處理」就能解決的對象,而是該重新檢視設計的訊號。Microsoft.Data.Sqlite會自動重試到逾時(預設 30 秒)為止3,但根本對策是把寫入路徑收斂成一條。- 大量執行細碎的 INSERT 時,光是用明確的交易包起來,速度就能快上 2、3 個位數。如果覺得「SQLite 很慢」,請先懷疑提交單位。
- SQLite 實質上只有 INTEGER / REAL / TEXT / BLOB 這 4 種型別,DateTime、Guid、decimal 都會以 TEXT 儲存。尤其 decimal 的比較與排序會變成字串規則,金額用最小貨幣單位的整數儲存才安全。4
- 備份禁止在執行中對檔案做單純複製,要用
VACUUM INTO或 Backup API(SqliteConnection.BackupDatabase)。手動刪除-wal/-shm檔案也是絕對禁止。5 - 純 SQLite 不支援加密。連線字串的
Password只有在導入 SQLCipher 系列原生函式庫時才會生效6,若只是少量機密資訊,建議先考慮用 DPAPI 保護,而不是直接把整個資料庫加密。
2. 函式庫選擇 ── 名稱相似,內容卻不同
.NET 中可以用來操作 SQLite 的套件不只一個,名稱容易混淆是第一個絆腳石。整理如下。
| 套件 | 定位 | 新專案採用建議 |
|---|---|---|
Microsoft.Data.Sqlite |
Microsoft 維護的 ADO.NET 提供者。輕量,NuGet 套件也內含原生 SQLite 本體 | ◎ 首選 |
Microsoft.EntityFrameworkCore.Sqlite |
EF Core 的 SQLite 提供者,內部使用 Microsoft.Data.Sqlite |
◎ 以實體為中心的應用程式(第 8 章) |
System.Data.SQLite |
SQLite 開發團隊系列的老牌提供者,在 .NET Framework 時代有大量實績 | △ 僅用於維護既有資產 |
Dapper |
建立在 ADO.NET 之上的輕量對映工具,並非 SQLite 專屬 | ○ 想減少純 ADO.NET 的重複轉換程式碼時 |
Microsoft.Data.Sqlite 由 EF Core 團隊維護,也是 EF Core SQLite 提供者的基礎。1 NuGet 套件裡已經內含原生二進位檔(SQLite 本體),不需要另外對客戶端 PC 進行部署,x86/x64/ARM64 的差異也由 NuGet 端吸收。
要注意的是,System.Data.SQLite 的相關資訊不能直接套用。因為使用歷史很長,網路上有大量以 System.Data.SQLite 為前提的範例,會踩到以下這些不相容之處。
- 連線字串不相容。
Version=3、UseUTF16Encoding,以及用來改變日期格式的DateTimeFormat這類關鍵字,在Microsoft.Data.Sqlite裡都不存在,指定了就會丟出例外。可用的關鍵字只有Data Source/Mode/Cache/Password/Foreign Keys/Default Timeout/Pooling等極少數。6 - 型別處理方式不同。 例如 Guid 在
System.Data.SQLite預設以 BLOB 儲存,在Microsoft.Data.Sqlite則是 TEXT。在兩個函式庫同時讀寫同一個資料庫檔案的遷移期間,這個差異會表現為資料不一致。
Microsoft.Data.Sqlite 刻意做得很薄,沒有自己的型別轉換或便利功能,這也讓 SQLite 官方文件的說明可以直接套用。如果既有應用程式用 System.Data.SQLite 穩定運作,不需要強行遷移;但新寫的程式碼應該靠向 Microsoft.Data.Sqlite,要遷移的話,原則上先把上述不相容之處列成遷移清單再動手。
3. 連線與連線字串 ── 連線池握著檔案不放
連線字串的基本形式只有檔案路徑。實務上要留意的是 Mode 和 Pooling。6
using Microsoft.Data.Sqlite;
var builder = new SqliteConnectionStringBuilder
{
DataSource = dbPath,
Mode = SqliteOpenMode.ReadWriteCreate // 預設值。沒有就建立
};
using var conn = new SqliteConnection(builder.ConnectionString);
conn.Open();
Mode:預設是ReadWriteCreate(沒有就建立)。如果要部署純參照用的主資料庫,指定ReadOnly可以防止程式錯誤或誤操作造成的寫入。Cache:通常維持預設值就好。文件明確指出Cache=Shared不建議與 WAL 模式並用,因此在本文採用 WAL 的方針下用不到它。6Password:指定後會在連線後立刻送出PRAGMA key,但標準的原生函式庫不支援加密,所以什麼都不會發生。6 如果需求是整個資料庫加密,就要換成 SQLCipher 系列的套件包(如SQLitePCLRaw.bundle_e_sqlcipher),而該加密金鑰的保管,最終還是需要 DPAPI(第 1 章)。Default Timeout:指令逾時(預設 30 秒),會成為第 5 章重試時間的上限。
另外一點,關於非同步 API。因為 SQLite 本身沒有非同步 I/O,ExecuteNonQueryAsync 之類的 async 方法內部其實是同步執行的。7 「因為用了 async 所以 UI 不會卡」這個說法不成立,繁重的查詢要明確用 Task.Run 之類的方式丟到工作執行緒。async/await 的判斷準則整理在「C# async/await 實務判斷表」裡。
3.1 連線池的陷阱 ── Close 之後檔案仍是開著的
Microsoft.Data.Sqlite 從版本 6.0 開始,連線池預設是啟用的。6 好處是不用每次 Open 都重新開檔,但代價是即使呼叫 Close / Dispose,原生連線依然留在連線池裡,持續握著資料庫檔案的控制代碼。結果就是下列操作會因為「檔案正在使用中」而失敗。
- 用「初始化資料」功能刪除資料庫檔案再重建
- 從備份還原時要替換資料庫檔案
- 卸載或搬移處理時要移動資料庫檔案
解決方法是在檔案操作前先清空連線池。
// 捨棄與此連線字串對應的連線池,釋放檔案控制代碼
SqliteConnection.ClearPool(new SqliteConnection(connectionString));
File.Delete(dbPath);
如果是 WAL 模式(下一章),-wal / -shm 檔案可能還殘留著,要一併清理。想在應用程式結束時全部釋放,可以用 SqliteConnection.ClearAllPools();如果原本就是不需要連線池的單次工具,也可以在連線字串裡設 Pooling=False。「明明 Close 了卻刪不掉」是轉移到 SQLite 後常見的問題,操作資料庫檔案的公用程式,請從一開始就加入 ClearPool。
4. WAL 模式的原理 ── 了解發生了什麼事再使用
上一篇文章只寫了「啟用 WAL 模式」,這次要深入到原理。預設的回滾日誌方式,在寫入期間讀取會被封鎖,這是 database is locked 的主要成因。WAL(Write-Ahead Logging,預寫式日誌)方式把變更寫進只能附加的日誌檔,而不是資料庫本體,所以讀取不會封鎖寫入,寫入也不會封鎖讀取。2 UI 執行緒一邊顯示歷程記錄、背景一邊寫入量測值,這種業務應用程式的典型架構就能直接成立。
轉成 WAL 模式後,資料庫本體(透過檢查點確定的內容)旁邊會出現 2 個檔案。
| 檔案 | 作用 |
|---|---|
app.db-wal |
附加寫入的變更日誌,包含已提交但尚未寫回本體的變更 |
app.db-shm |
稱為 wal-index 的共用記憶體,用來在行程之間調整 WAL 的讀取位置 |
要掌握的特性有 4 點。
- 檢查點(checkpoint):把
-wal的內容轉寫回本體的處理,預設在 WAL 累積到 1,000 頁(約 4MB)時自動執行。2 如果長時間的讀取交易一直不放手,檢查點就無法推進,-wal會膨脹,所以要避免「讀取連線一直開著到處傳用」的設計,用的時候開、用完就關(有連線池加持,重新開連線的成本很低)。 - 設定會永久記錄在資料庫裡:
PRAGMA journal_mode=WAL執行一次,就會記錄在資料庫檔案本身,之後不管用哪個連線開啟,都會維持 WAL 模式。2 不需要每次連線都重新下達。 - 寫入依然一次只能有一個:WAL 提升的是讀寫之間的並行度,寫入之間依然是互斥的。誤解成「因為換成 WAL 了,多執行緒就可以隨便寫」,就會遇到第 5 章的
SQLITE_BUSY。 - 無法在網路共用資料夾上使用:因為 wal-index 是以共用記憶體為前提,不同機器之間的行程無法運作。2 原本就不該把 SQLite 放在網路共用資料夾上,這點在上一篇文章已經說明過。
維運上要注意的是,-wal 檔案裡存放著已提交但尚未反映到本體的交易。「只複製本體的 .db 就好」「-wal 是暫存檔可以刪」這兩種想法都是錯的,會導致最近的提交消失,最糟的情況甚至讓資料庫損毀。5 這一點直接連結到第 7 章「備份不能用檔案複製」的討論。
5. 排他控制與 SQLITE_BUSY ── 把寫入收斂成一條
SQLITE_BUSY(例外訊息中顯示為 database is locked)發生在其他連線握有寫入鎖的時候。首先要知道的是,Microsoft.Data.Sqlite 對 busy/locked 錯誤會自動重試到指令逾時(預設 30 秒)為止。3 也就是說,應用程式端自己 catch 後睡一下再重跑的重試機制,通常是不需要的。如果還是到了逾時、拋出例外,通常是以下幾種情況之一。
- 另一個連線(或另一個行程)握著超過 30 秒的長交易
- 在以讀取開始的交易中,想要升級為寫入時和其他寫入衝突──等待也無法解決,所以會立即失敗。「先讀再寫」的處理,從一開始就設計成寫入交易才是正確做法
- 大量細碎的寫入從多個執行緒一起衝進來,變成搶鎖的局面
無論哪一種,都不是靠「增加重試次數」能解決的。對策是縮短交易,以及把寫入路徑收斂成一條。
5.1 用 System.Threading.Channels 建立寫入佇列
來源是多個執行緒的資料(量測值、操作記錄等),不要讓每個執行緒直接寫進資料庫,而是丟進佇列,由專責的寫入迴圈來處理。.NET 的話可以直接用 System.Threading.Channels。
using System.Globalization;
using System.Threading.Channels;
using Microsoft.Data.Sqlite;
public sealed record Measurement(string DeviceId, double Value, DateTime CreatedAtUtc);
public sealed class MeasurementWriter : IAsyncDisposable
{
private readonly Channel<Measurement> _channel =
Channel.CreateBounded<Measurement>(new BoundedChannelOptions(10_000)
{
FullMode = BoundedChannelFullMode.Wait // 滿了就讓生產端等待
});
private readonly string _connectionString;
private readonly Task _loop;
public MeasurementWriter(string connectionString)
{
_connectionString = connectionString;
_loop = Task.Run(WriteLoop);
}
// 從任何執行緒呼叫都可以。不會碰資料庫。寫入迴圈如果已經
// 掛掉,會拋出 ChannelClosedException,讓投入端也能馬上知道
public ValueTask EnqueueAsync(Measurement m) => _channel.Writer.WriteAsync(m);
private async Task WriteLoop()
{
try
{
await WriteLoopCore();
}
catch (Exception ex)
{
// 因磁碟已滿等原因導致寫入端掛掉時,透過 Channel 把這件事
// 傳達給投入端。若疏忽這一步,佇列滿了之後 EnqueueAsync 會
// 永遠等待下去,沒有人能察覺這個故障
_channel.Writer.TryComplete(ex);
throw;
}
}
private async Task WriteLoopCore()
{
using var conn = new SqliteConnection(_connectionString);
conn.Open();
var buffer = new List<Measurement>(500);
while (await _channel.Reader.WaitToReadAsync())
{
// 把目前累積的資料最多收集 500 筆,用 1 個交易寫入
buffer.Clear();
while (buffer.Count < 500 && _channel.Reader.TryRead(out var m))
buffer.Add(m);
using var tx = conn.BeginTransaction();
using var cmd = conn.CreateCommand();
cmd.Transaction = tx;
cmd.CommandText =
"INSERT INTO measurement (device_id, value, created_at) " +
"VALUES ($device, $value, $at)";
var pDevice = cmd.Parameters.Add("$device", SqliteType.Text);
var pValue = cmd.Parameters.Add("$value", SqliteType.Real);
var pAt = cmd.Parameters.Add("$at", SqliteType.Text);
foreach (var m in buffer)
{
pDevice.Value = m.DeviceId;
pValue.Value = m.Value;
// 明確指定 InvariantCulture,避免受地區預設日曆・數字格式影響
pAt.Value = m.CreatedAtUtc.ToString(
"yyyy-MM-dd HH:mm:ss.fffffff", CultureInfo.InvariantCulture);
cmd.ExecuteNonQuery();
}
tx.Commit();
}
}
public async ValueTask DisposeAsync()
{
// 即使寫入迴圈已經先因失敗關閉了 Channel,也不當成例外處理
// (若用 Complete() 會因為重複關閉而拋出例外,蓋掉原本的例外)
_channel.Writer.TryComplete();
await _loop; // 把剩下的寫完。如果迴圈已經掛了,原本的例外會在這裡出現
}
}
採用這種架構後,寫入衝突在結構上就不會發生,累積在佇列裡的資料也能自然地批次化,同時取得下一節要談的加速效果。結束時用 DisposeAsync 把佇列寫完再關閉、用 FullMode 明確決定滿了之後的行為,以及把寫入迴圈的失敗透過 TryComplete(ex) 傳播給投入端(收斂成單一寫入角色如果悄悄死掉,故障就會以佇列滿了之後永久等待的形式出現)——這些細節在業務應用程式裡都很重要。同樣「把路徑收斂成一條」的想法,在用檔案做連動時的排他設計(「檔案連動與鎖定的最佳實務」)也是共通的。
5.2 用交易包起來,速度會差好幾個位數
SQLite 每次提交都會對儲存裝置做同步寫入(fsync),如果一筆一筆用隱含提交執行 INSERT,就算是 SSD 也會在每秒數百到數千筆碰到瓶頸,HDD 則是每秒數十筆。只要把同樣的 INSERT 用 1,000 筆為單位包進明確交易(用 BeginTransaction 包住、最後 Commit),就能達到每秒數萬到數十萬筆的量級。這只是連調校都稱不上的 3 行修改,卻能改變 2、3 個位數。
「匯入 CSV 要花 20 分鐘」「開機時的資料轉移跑不完」,這類諮詢的原因多半就是這個,只靠交易化與參數重複使用(上一節程式碼的形式)就能解決。反過來說,交易拉得太長又會讓其他寫入等待,實務上把「數百到數千筆,或數百毫秒的量」包成一次提交,是比較合適的折衷點。
6. 型別對應的陷阱 ── 只有 4 種型別要怎麼分配
SQLite 實際能儲存的型別只有 INTEGER / REAL / TEXT / BLOB 這 4 種,.NET 的型別會被分配到其中一種。Microsoft.Data.Sqlite 主要的對應如下。4
| .NET 型別 | SQLite 型別 | 儲存格式 | 實務上的注意事項 |
|---|---|---|---|
| bool / int / long | INTEGER | bool 是 0 / 1 | |
| double | REAL | 浮點數誤差原樣保留 | |
| string | TEXT | UTF-8 | |
| DateTime | TEXT | yyyy-MM-dd HH:mm:ss.FFFFFFF |
格式與時區必須統一 |
| DateTimeOffset | TEXT | 含偏移量 | 偏移量混雜會導致無法排序 |
| Guid | TEXT | 以連字號分隔 | 與 System.Data.SQLite 的預設值(BLOB)不相容 |
| decimal | TEXT | 0.0###... 格式 |
比較・排序會變成字串規則 |
落到 TEXT 的這 3 種型別需要特別注意。
- DateTime:TEXT 的 ISO 8601 系列格式,只要格式與時區統一,字串排序就等於時間排序,實用上沒有問題。反過來說,一旦 UTC 和本地時間混在一起就會出問題。唯一的解法是一開始就決定「儲存用 UTC,顯示時再轉成本地時間」,並在所有路徑上遵守,
datetime('now')之類的 SQLite 函式回傳的也是 UTC。另外,自己做字串轉換時,ToString一定要傳入CultureInfo.InvariantCulture。如果維持地區預設值,只有跑在非格里高利曆地區(如和曆、佛曆)設定的裝置上,年份表示法會改變,排序與讀取都會壞掉(參見 5.1 的程式碼範例)。 - Guid:以字串儲存,所以比對是字串完全一致。如果另一個工具或函式庫用不同的表示法(大小寫、BLOB 格式)寫入,比對就會失敗,因此若有多種語言、工具會存取同一份資料,請把表示法寫成明確的規格文件。
- decimal:最大的陷阱。因為用 REAL 會損失精度,所以改用 TEXT 儲存4,但對 TEXT 欄位做
WHERE amount > 1000這類比較,會因為 SQLite 型別排序中 TEXT 永遠大於數值,而無法按預期運作。SUM之類的彙總也會在內部轉成 REAL,讓選用 decimal 的意義——精度——白白流失。
decimal 在實務上的準則很單純:金額用最小貨幣單位的整數(INTEGER)儲存。日幣的話就用「圓」為單位的 long 儲存,顯示時再轉換。整數的話比較與彙總都準確又快速,型別陷阱也隨之消失。如果因為既有結構的關係,非得以 decimal 處理,那就乾脆規定比較、彙總不在 SQL 端做,讀出來之後在 .NET 端處理。
另外,寫在 CREATE TABLE 裡的型別名稱只是「親和性(affinity)」的提示,像 STRING 這種自訂型別名稱,是隱含轉換事故的根源。官方建議欄位型別名稱也只用 INTEGER / REAL / TEXT / BLOB 這 4 種。4
7. 運用 ── 不弄壞・弄壞了也能救回來
7.1 啟動時的 quick_check
雖然 SQLite 有交易保護,但磁碟故障或誤操作造成的損毀無法完全避免。為了不讓應用程式在損毀的資料庫上繼續運作、擴大損害,要在啟動時加入一致性檢查。完整的 integrity_check 在大型資料庫上會很花時間,日常使用輕量版的 quick_check 就足夠了。
using var cmd = conn.CreateCommand();
cmd.CommandText = "PRAGMA quick_check";
var result = (string)cmd.ExecuteScalar()!;
if (result != "ok")
{
// 不再寫入已損毀的資料庫,縮減為唯讀模式,促使進行復原
logger.LogError("偵測到資料庫損毀:{Detail}", result);
EnterReadOnlyMode(result);
}
偵測到損毀時不自動修復、不自動回滾(改由使用者操作介入)的方針,和上一篇文章 6.2 節寫的一樣。
7.2 備份 ── 為什麼不能用檔案複製
對執行中的資料庫檔案做單純複製,可能會混入交易進行中途的狀態,SQLite 官方也明確把它列為損毀原因。5 若是 WAL 模式,更嚴重的是只複製本體、丟下 -wal(含有尚未寫回本體的提交,見第 4 章)的話,會遺漏最近的資料。
正確做法有 2 種,兩者都能在執行期間取得一致的快照。
VACUUM INTO:一行 SQL,就能建立消除了片段化的最小體積複本。8 程式碼範例刊登在上一篇文章的 6.3 節,請參考那邊。SqliteConnection.BackupDatabase:SQLite Backup API 的包裝,用連線物件之間互相複製。
using var source = new SqliteConnection($"Data Source={dbPath}");
using var target = new SqliteConnection($"Data Source={backupPath}");
source.Open();
target.Open();
source.BackupDatabase(target); // 即使在執行中,也會得到一致的快照
不過 BackupDatabase 有需要注意的地方。Microsoft.Data.Sqlite 目前的實作會盡可能快速複製,並在完成前封鎖其他連線的寫入。9 如果在量測或使用者操作持續進行時對大型資料庫做備份,這段時間內的寫入會表現為 SQLITE_BUSY 或畫面凍結。日常的世代備份用 VACUUM INTO,BackupDatabase 則用在與記憶體內資料庫互相複製,或是在停止寫入的時段、維護處理時做複製,這樣區分使用比較安全。如果要用工作排程器做定期執行,請不要從外部做檔案複製,而是讓應用程式自己(或正確開啟 SQLite 的小工具)執行上述的備份。定期執行本身的設計,和「用工作排程器安全地執行定期作業」裡寫的一樣。
7.3 存放位置與遷移
資料庫檔案的存放位置基本上是 %LOCALAPPDATA%\公司名\應用程式名,結構版本管理最小配置則是用 PRAGMA user_version 搭配啟動時執行的遷移。這兩點都在上一篇文章(第 3 章與 6.1 節)附程式碼寫過了,這裡不再重複。補充一點:只要在執行遷移之前,先用 7.2 的方法留一份備份,就算遇到「遷移失敗導致無法啟動」這種最壞情況,復原也只是單純換回檔案而已。
8. 該在什麼時候使用 EF Core ── ORM 的損益平衡點
到目前為止都是用純 Microsoft.Data.Sqlite 來說明,但確實有些場合明確該用 EF Core 的 SQLite 提供者。判斷的關鍵在於應用程式的性質。
| 應用程式性質 | 建議 | 理由 |
|---|---|---|
| 畫面數量多、以實體為中心的 CRUD 為主(訂單、主檔管理等) | EF Core + 遷移 | 減少重複轉換程式碼與手寫 SQL 的總量,結構變更可用 dotnet ef migrations 追蹤 |
| 專注於寫入、結構小(量測記錄、稽核記錄、快取) | 純 Microsoft.Data.Sqlite(+ 必要時搭配 Dapper) |
變更追蹤的額外負擔是浪費,第 5 章的寫入佇列+批次可以直接搭建 |
| 兩種性質混合 | 併用 | 對同一個資料庫檔案,CRUD 畫面用 EF Core、記錄寫入用純 ADO.NET 也沒問題 |
選擇 EF Core 的話,需要先了解 SQLite 提供者特有的限制。10
- 因 ALTER TABLE 限制而需要重建表格:SQLite 不直接支援欄位型別變更或刪除,所以包含
AlterColumn或DropColumn的遷移,會以「建立新表格 → 複製資料 → 刪除舊表格 → 重新命名」的方式重建執行。在資料量大的環境裡,這會影響套用時間與磁碟使用量,大型表格的結構變更要有計畫地進行。 - 無法建立冪等腳本:無法生成像 SQL Server 那種帶 if-then 判斷的遷移腳本。套用遷移比較現實的做法,是在應用程式啟動時呼叫
dbContext.Database.Migrate()。 - decimal / DateTimeOffset 的運算會變成客戶端評估:第 6 章提到的型別問題,用 EF Core 也不會消失。非相等比較或排序都會變成客戶端評估,因此金額用最小整數單位儲存的準則,在 EF Core 下依然適用(可以用值轉換器轉成
long再儲存)。 - WAL 預設就是啟用的:EF Core 建立的資料庫從一開始就是 WAL 模式7,不需要第 4 章的設定。但仍然需要理解它的行為。
另外,即使採用 EF Core,在儲存庫層的單元測試中使用 SQLite 記憶體內資料庫也是很有效的做法。因為和正式環境用的是同一個提供者,可以縮小「Mock 測得過、實際資料庫卻失敗」的落差。測試該寫在哪一層的思路,可參考「單元測試與整合測試的界線」。
9. 總結
SQLite 是一個「單純導入只要 30 分鐘,但要正確維運就需要設計」的函式庫。話雖如此,需要的設計是固定的,把本文內容整理成檢查清單,就是以下 6 點。
- 函式庫用
Microsoft.Data.Sqlite(或建立在其上的 EF Core),不要和以System.Data.SQLite為前提的資訊混用 - 從第一個版本就啟用 WAL 模式,並理解
-wal/-shm的作用 - 寫入收斂成一條(用 Channels 的寫入佇列),細碎的 INSERT 用交易包起來
- DateTime 統一用 UTC,金額用最小貨幣單位的整數。不要對 TEXT 狀態的 decimal 直接比較、彙總
- 啟動時做
quick_check,備份用VACUUM INTO或BackupDatabase,不做執行中的檔案複製 - 刪除、替換資料庫檔案的處理,別忘了呼叫
SqliteConnection.ClearPool
如果你的系統目前是靠不斷重試撐過 database is locked,或是用檔案複製做備份,建議在出事之前,用本文的項目盤點一次。每一項要修正的成本其實都不大。
相關文章
- Windows 應用程式的資料儲存位置選擇 ── SQLite / JSON / 登錄檔 / Access 判斷表
- Windows 應用程式的機密資訊儲存 - 用 DPAPI 避免明文設定
- 用工作排程器安全地執行定期作業
- C# async/await 實務判斷表 ── 不卡住 UI、不死鎖的寫法
相關諮詢領域
合同會社小村軟體提供整合了 SQLite 的業務應用程式設計審查(排他控制、備份、遷移設計),以及 database is locked、資料損毀、效能劣化等執行中應用程式的問題調查,還有從 Access 等既有資料儲存體遷移的支援。
參考連結
-
Microsoft Learn,Microsoft.Data.Sqlite overview。說明它是 Microsoft 維護的輕量 ADO.NET 提供者,也是 EF Core SQLite 提供者的基礎。 ↩ ↩2
-
SQLite,Write-Ahead Logging。說明
-wal/-shm檔案的作用、檢查點(預設 1,000 頁)、讀寫並行性、模式的永久記錄,以及無法在網路檔案系統上運作。 ↩ ↩2 ↩3 ↩4 ↩5 -
Microsoft Learn,Database errors (Microsoft.Data.Sqlite)。說明對 busy / locked 錯誤會自動重試到指令逾時(預設 30 秒)為止,以及連線、指令等物件不具執行緒安全性。 ↩ ↩2
-
Microsoft Learn,Data types (Microsoft.Data.Sqlite)。說明 SQLite 的 4 種基本型別,DateTime / Guid / decimal 會對應到 TEXT,以及欄位型別名稱也應限定在這 4 種基本型別名稱內。 ↩ ↩2 ↩3 ↩4
-
SQLite,How To Corrupt An SQLite Database File。說明對執行中(交易中)的資料庫檔案做複製,或刪除、分離熱日誌・WAL 檔案,會成為損毀原因。 ↩ ↩2 ↩3
-
Microsoft Learn,Connection strings (Microsoft.Data.Sqlite)。列出連線字串關鍵字,說明
Pooling預設為啟用、Password在原生函式庫不支援加密時不會生效、Cache=Shared與 WAL 併用不被建議。 ↩ ↩2 ↩3 ↩4 ↩5 ↩6 -
Microsoft Learn,Async limitations (Microsoft.Data.Sqlite)。說明 SQLite 不支援非同步 I/O、async 方法會同步執行,以及 EF Core 建立的資料庫預設就會啟用 WAL。 ↩ ↩2
-
SQLite,VACUUM。說明透過
VACUUM INTO子句,可以在不變更原始檔案的情況下,建立一致且最小體積的複本到另一個檔案。 ↩ -
Microsoft Learn,Backup (Microsoft.Data.Sqlite)。說明
BackupDatabase目前的實作會盡可能快速備份,並在完成前封鎖其他連線的寫入。 ↩ -
Microsoft Learn,SQLite EF Core Database Provider Limitations。說明許多遷移操作會以重建表格的方式執行、無法生成冪等腳本,以及 decimal / DateTimeOffset 的運算會變成客戶端評估。 ↩
相關文章
共用相同標籤的最新文章。能以相近的主題延伸理解。
Windows應用程式資料儲存位置怎麼選 ── SQLite / JSON / 登錄檔 / Access 判斷表
Windows桌面應用程式的資料該存在哪裡、用什麼格式儲存?本文整理AppData/ProgramData的使用區分,以及SQLite、JSON檔案、登錄檔、Access(.accdb)各自的優勢與陷阱,並附判斷表,從實務角度說明防止資料損毀與位元數問題等注意事項。
業務應用程式的日期時間與時區 ── 從 DateTime 的陷阱到 UTC 儲存原則、測試設計
伺服器搬遷後時間整整差了 9 個小時、只有海外據點的日期會變成前一天──本文從 DateTime 的 Kind 與隱式轉換整理日期時間事故的根本原因。內容涵蓋與 DateTimeOffset 的取捨、UTC 儲存與 ISO 8601 原則、TimeZoneInfo 與夏令時...
Windows 服務的建立與維運 ── 從工作排程器的取捨到 BackgroundService 服務化
常駐處理該做成 Windows 服務,還是工作排程器就足夠?本文整理判斷表,以及用 .NET Worker Service(UseWindowsService)建立服務的方法、執行帳戶、復原選項、事件記錄、安全停止處理,從實務角度梳理上線維運所需的設計。
如何理解 Windows 的工作階段隔離 ── Session 0・RDP・多使用者同時執行
整理 Windows 應用程式開發者容易混淆的「工作階段」概念。說明服務無法顯示 UI 的 Session 0 隔離原因、RDP 連線時工作階段的行為、具名物件的工作階段隔離,以及共用 PC・RDS 環境常見的設計失誤,從實務角度解說。
防止 Windows 應用程式重複啟動 ── 具名 Mutex 與重複執行時的視窗前置
整理業務用 Windows 應用程式的常見需求「不讓同一個應用程式啟動兩次」,如何用具名 Mutex 實作。涵蓋 Global\ 與 Local\ 命名空間差異在 RDP 環境中的陷阱、擁有執行緒限制與 AbandonedMutexException、考量 SetForeg...
相關主題
與本文相近的主題頁面。以本文為起點,可進一步連到相關服務與其他文章。
Windows 技術主題
彙整 KomuraSoft LLC 關於 Windows 開發、故障調查與既有資產活用文章的主題中心。
與本主題相關的服務
本文連結到以下服務頁面,歡迎從最接近的入口查看。
Windows 應用程式開發
支援包含常駐處理、設備連動、運作日誌與可維護結構的 Windows 桌面應用程式。
技術諮詢 & 設計審查
協助釐清設計方向、架構邊界、生命週期責任,以及既有 Windows 資產的處理方式。
常見問題
整理諮詢這個主題時常見的問題。
- SQLite 為什麼會出現「database is locked」?
- SQLITE_BUSY 會在其他連線持有寫入鎖時發生。Microsoft.Data.Sqlite 會針對 busy/locked 錯誤自動重試到指令逾時(預設 30 秒),所以通常不需要自己實作重試。如果還是逾時丟出例外,原因通常是:其他連線(或行程)握著超過 30 秒的長交易;從讀取交易中途升級為寫入時與其他寫入衝突(等待也無法解決,會立即失敗);或是多個執行緒的細碎寫入互相搶鎖。增加重試次數都解決不了,根本對策是縮短交易長度,並用 System.Threading.Channels 之類的佇列把寫入路徑收斂成一條。
- 在 C# 中使用 SQLite,該選哪個函式庫?
- 新專案的基本選擇是 Microsoft 維護的 ADO.NET 提供者 Microsoft.Data.Sqlite(或建立在其上的 EF Core SQLite 提供者)。NuGet 套件已內含原生的 SQLite 本體,不需要額外部署。它和老牌的 System.Data.SQLite 是連線字串與型別處理都不相容的兩個東西,例如 Guid 在 System.Data.SQLite 預設以 BLOB 儲存,在 Microsoft.Data.Sqlite 則是 TEXT。使用網路上的範例時,要隨時留意它是以哪一個函式庫為前提。
- SQLite 的備份可以用檔案複製嗎?
- 執行中對資料庫檔案做單純複製是禁止的,可能會混入交易進行中的狀態,SQLite 官方也明確把它列為導致損毀的原因。在 WAL 模式下,-wal 檔案裡還有尚未寫回本體的已提交內容,只複製本體會遺漏最近的資料。正確做法是用 VACUUM INTO(一行 SQL 就能建立一致且最小體積的複本)或 SqliteConnection.BackupDatabase。不過 BackupDatabase 在完成前會封鎖其他連線的寫入,日常的世代備份用 VACUUM INTO 比較安全。
- SQLite 大量 INSERT 為什麼會很慢?
- 多半是提交單位的問題。SQLite 每次提交都會對儲存裝置做同步寫入(fsync),如果一筆一筆用隱含提交執行 INSERT,就算是 SSD 也會在每秒數百到數千筆時碰到瓶頸。只要把同樣的 INSERT 用 1,000 筆為單位包進明確的交易(BeginTransaction 包住、最後 Commit),就能達到每秒數萬到數十萬筆的量級,速度差了 2、3 個位數。反過來說交易拉得太長又會讓其他寫入等待,實務上把數百到數千筆、或數百毫秒的量包成一次提交是比較合適的折衷點。
作者檔案
本文作者的個人檔案頁面。
Go Komura
小村軟體有限公司 代表
以 Windows 軟體開發、技術諮詢與故障調查為中心,在難以重現的故障調查與既有資產仍在運作的專案上具有優勢。