在 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 lockedSQLITE_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=3UseUTF16Encoding,以及用來改變日期格式的 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. 連線與連線字串 ── 連線池握著檔案不放

連線字串的基本形式只有檔案路徑。實務上要留意的是 ModePooling6

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 的方針下用不到它。6
  • Password:指定後會在連線後立刻送出 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 INTOBackupDatabase 則用在與記憶體內資料庫互相複製,或是在停止寫入的時段、維護處理時做複製,這樣區分使用比較安全。如果要用工作排程器做定期執行,請不要從外部做檔案複製,而是讓應用程式自己(或正確開啟 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 不直接支援欄位型別變更或刪除,所以包含 AlterColumnDropColumn 的遷移,會以「建立新表格 → 複製資料 → 刪除舊表格 → 重新命名」的方式重建執行。在資料量大的環境裡,這會影響套用時間與磁碟使用量,大型表格的結構變更要有計畫地進行。
  • 無法建立冪等腳本:無法生成像 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 INTOBackupDatabase,不做執行中的檔案複製
  • 刪除、替換資料庫檔案的處理,別忘了呼叫 SqliteConnection.ClearPool

如果你的系統目前是靠不斷重試撐過 database is locked,或是用檔案複製做備份,建議在出事之前,用本文的項目盤點一次。每一項要修正的成本其實都不大。

相關文章

相關諮詢領域

合同會社小村軟體提供整合了 SQLite 的業務應用程式設計審查(排他控制、備份、遷移設計),以及 database is locked、資料損毀、效能劣化等執行中應用程式的問題調查,還有從 Access 等既有資料儲存體遷移的支援。

參考連結

  1. Microsoft Learn,Microsoft.Data.Sqlite overview。說明它是 Microsoft 維護的輕量 ADO.NET 提供者,也是 EF Core SQLite 提供者的基礎。  2

  2. SQLite,Write-Ahead Logging。說明 -wal / -shm 檔案的作用、檢查點(預設 1,000 頁)、讀寫並行性、模式的永久記錄,以及無法在網路檔案系統上運作。  2 3 4 5

  3. Microsoft Learn,Database errors (Microsoft.Data.Sqlite)。說明對 busy / locked 錯誤會自動重試到指令逾時(預設 30 秒)為止,以及連線、指令等物件不具執行緒安全性。  2

  4. Microsoft Learn,Data types (Microsoft.Data.Sqlite)。說明 SQLite 的 4 種基本型別,DateTime / Guid / decimal 會對應到 TEXT,以及欄位型別名稱也應限定在這 4 種基本型別名稱內。  2 3 4

  5. SQLite,How To Corrupt An SQLite Database File。說明對執行中(交易中)的資料庫檔案做複製,或刪除、分離熱日誌・WAL 檔案,會成為損毀原因。  2 3

  6. Microsoft Learn,Connection strings (Microsoft.Data.Sqlite)。列出連線字串關鍵字,說明 Pooling 預設為啟用、Password 在原生函式庫不支援加密時不會生效、Cache=Shared 與 WAL 併用不被建議。  2 3 4 5 6

  7. Microsoft Learn,Async limitations (Microsoft.Data.Sqlite)。說明 SQLite 不支援非同步 I/O、async 方法會同步執行,以及 EF Core 建立的資料庫預設就會啟用 WAL。  2

  8. SQLite,VACUUM。說明透過 VACUUM INTO 子句,可以在不變更原始檔案的情況下,建立一致且最小體積的複本到另一個檔案。 

  9. Microsoft Learn,Backup (Microsoft.Data.Sqlite)。說明 BackupDatabase 目前的實作會盡可能快速備份,並在完成前封鎖其他連線的寫入。 

  10. Microsoft Learn,SQLite EF Core Database Provider Limitations。說明許多遷移操作會以重建表格的方式執行、無法生成冪等腳本,以及 decimal / DateTimeOffset 的運算會變成客戶端評估。 

共用相同標籤的最新文章。能以相近的主題延伸理解。

與本文相近的主題頁面。以本文為起點,可進一步連到相關服務與其他文章。

本文連結到以下服務頁面,歡迎從最接近的入口查看。

常見問題

整理諮詢這個主題時常見的問題。

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 軟體開發、技術諮詢與故障調查為中心,在難以重現的故障調查與既有資產仍在運作的專案上具有優勢。

回到部落格一覽