C#でSQLiteを業務アプリに使う ── WALモード・排他制御・破損対策・EF Coreとの使い分け

· · SQLite, C#, .NET, Microsoft.Data.Sqlite, EF Core, データ保存, Windows, 運用, 技術相談

前回の記事「Windowsアプリのデータ保存先の選び方」で、増えていく業務データ・履歴の保存先は SQLite が第一候補だと書きました。判断はそれで決まりなのですが、実際に組み込む段になると別の迷いが出てきます。相談でよく聞くのは、「NuGet で SQLite と検索するとパッケージがいくつも出てきて、どれを入れればいいのか分からない」「動いてはいるが、たまに database is locked が出る。再試行を入れてしのいでいるがこれで正しいのか」「バックアップは DB ファイルをコピーするだけでいいのか」といった声です。

どれも業務アプリで SQLite を数年運用するなら必ず通る論点で、最初の設計で押さえておけば後から苦労しない類のものです。この記事では、Microsoft.Data.Sqlite を前提に、ライブラリの選択、接続文字列とプーリング、WAL モードの仕組み、SQLITE_BUSY への向き合い方、型マッピングの罠、破損対策とバックアップ、そして EF Core との使い分けまで、設計レビューで毎回確認している項目を一通り整理します。

1. まず結論

  • 新規開発で使うライブラリは Microsoft.Data.Sqlite(またはその上に載る EF Core の SQLite プロバイダー) が基本です。System.Data.SQLite とは接続文字列も細部の挙動も互換性がない別物なので、Web 上のサンプルがどちらの前提かを常に意識してください。1
  • WAL モードを最初のリリースから有効にします。 読み取りと書き込みの並行性が上がり、database is locked の大半が消えます。設定は DB ファイル自体に永続化されますが、ネットワーク共有上では使えません。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、少量の機密情報なら DB 暗号化より DPAPI での保護を先に検討してください。

2. ライブラリの選択 ── 名前が似ていて中身が違う

.NET から SQLite を使うパッケージは複数あり、名前が紛らわしいのが最初のつまずきどころです。整理すると次のようになります。

パッケージ 位置づけ 新規採用の目安
Microsoft.Data.Sqlite Microsoft がメンテナンスする ADO.NET プロバイダー。軽量で、ネイティブの SQLite 本体も NuGet に同梱 ◎ 第一候補
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 向けの情報がそのまま使えないことです。長く使われてきたぶん Web 上のサンプルは 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 で保存されます。両方のライブラリで同じ DB ファイルを読み書きする移行期には、この差がデータ不整合として現れます。

Microsoft.Data.Sqlite は意図的に薄く作られており、独自の型変換や便利機能を持たないぶん、SQLite 公式ドキュメントの記述がそのまま通用します。既存アプリが System.Data.SQLite で安定稼働しているなら無理に移行する必要はありませんが、新規のコードは Microsoft.Data.Sqlite に寄せ、移行するなら上記の非互換を移行項目として洗い出してから、が原則です。

3. 接続と接続文字列 ── プーリングがファイルを掴む

接続文字列の基本形はファイルパスだけです。実務で意識するのは ModePooling です。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(無ければ作成)。参照専用のマスタ DB を配布するようなケースでは ReadOnly を指定しておくと、バグや誤操作による書き込みを防げます。
  • Cache: 通常は既定のままにします。Cache=Shared は WAL モードとの併用が非推奨とドキュメントに明記されているので、WAL を使うこの記事の方針では出番がありません。6
  • Password: 指定すると接続直後に PRAGMA key が送られますが、標準のネイティブライブラリは暗号化非対応なので何も起きません6 DB 全体の暗号化が要件なら 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 してもネイティブの接続はプールに残り、DB ファイルのハンドルを掴み続けます。この結果、次のような操作が「ファイルが使用中です」で失敗します。

  • 「データを初期化する」機能で DB ファイルを削除して作り直す
  • バックアップからの復元で DB ファイルを差し替える
  • アンインストールや退避処理で DB ファイルを移動する

対処は、ファイル操作の直前にプールを空にすることです。

// この接続文字列に対応するプールを破棄し、ファイルハンドルを解放する
SqliteConnection.ClearPool(new SqliteConnection(connectionString));
File.Delete(dbPath);

WAL モード(次章)なら -wal / -shm ファイルが残っていることがあるので、併せて始末します。アプリ終了時にすべて解放したい場合は SqliteConnection.ClearAllPools()、そもそもプールが不要な単発ツールなら接続文字列で Pooling=False にする手もあります。「Close したのに消せない」は SQLite 移行後の問い合わせでよく見るので、DB ファイルを操作するユーティリティ処理には最初から ClearPool を入れておいてください。

4. WALモードの仕組み ── 何が起きているかを知って使う

前回の記事で「WAL モードを有効にする」とだけ書いたので、今回は仕組みまで踏み込みます。既定のロールバックジャーナル方式では、書き込み中は読み取りがブロックされ、database is locked の主要因になります。WAL(Write-Ahead Logging)方式では変更を DB 本体ではなく追記専用のログファイルに書くため、読み取りは書き込みをブロックせず、書き込みも読み取りをブロックしません2 UI スレッドが履歴を表示しながらバックグラウンドで計測値を書き込む、という業務アプリの典型構成がそのまま成立します。

WAL モードにすると、DB 本体(チェックポイントで確定した内容)の隣に 2 つのファイルが現れます。

ファイル 役割
app.db-wal 追記される変更ログ。コミット済みだが本体未反映の変更を含む
app.db-shm wal-index と呼ばれる共有メモリ。プロセス間で WAL の読み取り位置を調整する

押さえておくべき性質は 4 つです。

  • チェックポイント: -wal の内容を本体へ転写する処理で、既定では WAL が 1,000 ページ(約 4MB)に達すると自動実行されます。2 長時間の読み取りトランザクションが居座るとチェックポイントが進めず -wal が肥大するので、「読み取り接続を開きっぱなしで持ち回す」設計は避け、使うときに開いて閉じます(プーリングにより開き直しは高速です)。
  • 設定は DB に永続化される: PRAGMA journal_mode=WAL は一度実行すれば DB ファイル自体に記録され、以後どの接続で開いても WAL のままです。2 接続のたびに発行する必要はありません。
  • 書き込みは相変わらず同時に 1 つ: WAL が上げるのは読み書きの並行性で、書き込み同士は依然として排他です。ここを誤解して「WAL にしたからマルチスレッドで書き放題」と思うと、第 5 章の SQLITE_BUSY に出会います。
  • ネットワーク共有では使えない: wal-index が共有メモリを前提とするため、異なるマシンのプロセス間では動作しません。2 そもそも SQLite をネットワーク共有に置かないのは前回の記事のとおりです。

運用面の注意として、-wal ファイルにはコミット済みでまだ本体に反映されていないトランザクションが入っています。「本体の .db だけコピーすれば良い」「-wal は一時ファイルだから消して良い」はどちらも誤りで、直近のコミットが消えるか、最悪 DB が壊れます。5 このことが、第 7 章の「バックアップはファイルコピーではだめ」という話に直結します。

5. 排他と SQLITE_BUSY ── 書き込みを一本に寄せる

SQLITE_BUSY(例外メッセージでは database is locked)は、他の接続が書き込みロックを持っているときに発生します。まず知っておきたいのは、Microsoft.Data.Sqlite は busy/locked エラーに対してコマンドタイムアウト(既定 30 秒)まで自動でリトライすることです。3 つまりアプリ側で catch してスリープして再実行、という自前リトライは通常不要です。それでもタイムアウトに達して例外が飛んでくるとしたら、次のどれかです。

  • 別の接続(または別プロセス)が 30 秒を超える長いトランザクションを握っている
  • 読み取りで始めたトランザクションの中で書き込みに昇格しようとして、他の書き込みと衝突した ── 待っても解決しないため即時に失敗します。「読んでから書く」処理は最初から書き込みトランザクションとして設計するのが定石です
  • 大量の細かい書き込みが複数スレッドから殴り込んでいて、ロックの奪い合いになっている

いずれも「リトライ回数を増やす」では解決しません。対策はトランザクションを短くすることと、書き込み経路を 1 本に寄せることです。

5.1 System.Threading.Channels で書き込みキューを作る

複数スレッドが発生源になるデータ(計測値、操作ログなど)は、各スレッドが直接 DB に書くのではなく、キューに投げて専任の書き込みループが処理する形にします。.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);
    }

    // どのスレッドから呼んでもよい。DBには触らない。書き込みループが
    // 死んでいる場合は ChannelClosedException で投入側にもすぐ分かる
    public ValueTask EnqueueAsync(Measurement m) => _channel.Writer.WriteAsync(m);

    private async Task WriteLoop()
    {
        try
        {
            await WriteLoopCore();
        }
        catch (Exception ex)
        {
            // ディスクフル等で書き込み役が死んだことをチャネル経由で投入側へ
            // 伝える。これを怠ると、キューが満杯になった時点で 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()
    {
        // 書き込みループが失敗で先にチャネルを閉じていても例外にしない
        // (Complete() だと二重クローズで投げ、元の例外を隠してしまう)
        _channel.Writer.TryComplete();
        await _loop;  // 残りを書き切る。ループが死んでいた場合は元の例外がここで出る
    }
}

この形にすると、書き込みの衝突が構造的に起きなくなるうえ、キューに溜まった分を自然にバッチ化できるので次節の高速化も同時に手に入ります。終了時に DisposeAsync でキューを書き切ってから閉じる点、FullMode で満杯時の挙動を明示的に決めている点、そして書き込みループの失敗を TryComplete(ex) で投入側へ伝播させている点(一本化した書き込み役が黙って死ぬと、障害がキュー満杯時の永久待ちとして現れます)も、業務アプリでは効いてきます。同じ「経路を一本化する」発想は、ファイルで連携する場合の排他設計(「ファイル連携とロックのベストプラクティス」)とも共通です。

5.2 トランザクションでまとめると桁が変わる

SQLite はコミットのたびにストレージへの同期書き込み(fsync)を行うため、1 件ずつ暗黙コミットで INSERT すると、SSD でも毎秒数百〜数千件、HDD なら毎秒数十件で頭打ちになります。同じ INSERT を 1,000 件単位の明示的トランザクション(BeginTransaction で囲んで最後に Commit)にまとめるだけで、毎秒数万〜数十万件のオーダーに届きます。チューニングと呼ぶのも大げさな 3 行の変更で、2〜3 桁変わります

「CSV 取り込みに 20 分かかる」「起動時のデータ移行が終わらない」という相談の多くはこれが原因で、トランザクション化とパラメーターの使い回し(前節のコードの形)だけで解決します。逆に、トランザクションを長くしすぎると今度は他の書き込みを待たせるので、「数百〜数千件、または数百ミリ秒ぶん」を 1 コミットにする程度が実務的な落としどころです。

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 です。もうひとつ、自前で文字列化する場合は ToStringCultureInfo.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 はトランザクションで守られているとはいえ、ディスク障害や誤ったファイル操作による破損はゼロにはできません。壊れた DB で動き続けて被害を広げないよう、起動時に整合性チェックを入れておきます。完全な integrity_check は大きな DB では時間がかかるので、日常は軽量版の quick_check で十分です。

using var cmd = conn.CreateCommand();
cmd.CommandText = "PRAGMA quick_check";
var result = (string)cmd.ExecuteScalar()!;
if (result != "ok")
{
    // 壊れたDBに書き足さない。読み取り専用へ縮退して復元を促す
    logger.LogError("データベース破損を検出: {Detail}", result);
    EnterReadOnlyMode(result);
}

破損を検出したときに自動修復や自動巻き戻しをしない(ユーザー操作を挟む)方針は、前回の記事の 6.2 節で書いたとおりです。

7.2 バックアップ ── なぜファイルコピーではだめか

稼働中の DB ファイルの単純コピーは、トランザクション途中の状態を混ぜて拾う可能性があり、SQLite 公式が破損原因として明記しています。5 WAL モードならさらに、本体だけコピーして -wal(本体未反映のコミットを含む。第 4 章)を置き去りにすると、直近のデータが抜け落ちます。

正しい方法は 2 つで、どちらも稼働中に一貫性のあるスナップショットを取れます。

  • VACUUM INTO: SQL 1 文で、断片化を解消した最小サイズのコピーを作ります。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 大きな DB を計測やユーザー操作が続いている最中にバックアップすると、その間の書き込みが SQLITE_BUSY や画面の固まりとして現れます。日常の世代バックアップは VACUUM INTOBackupDatabase はインメモリ DB との相互コピーや、書き込みが止まっている時間帯・メンテナンス処理での複製に使う、という使い分けが安全です。定期実行にタスクスケジューラを使うなら、外部からのファイルコピーではなく、アプリ自身(または SQLite を正しく開く小さなツール)に上記のバックアップを実行させる形にしてください。定期実行そのものの設計は「タスクスケジューラで定期実行を安全に運用する」で書いたとおりです。

7.3 置き場所とマイグレーション

DB ファイルの置き場所は %LOCALAPPDATA%\会社名\アプリ名 が基本、スキーマの版管理は PRAGMA user_version による起動時マイグレーションが最小構成です。どちらも前回の記事(3 章と 6.1 節)でコードつきで書いたので、ここでは繰り返しません。一点だけ補足すると、マイグレーション実行前に 7.2 のバックアップを 1 世代取っておくと、「マイグレーションに失敗して起動不能」という最悪ケースからの復旧が単なるファイル差し替えになります。

8. EF Core を使うべき場面 ── ORM の損益分岐

ここまで素の Microsoft.Data.Sqlite で書いてきましたが、EF Core の SQLite プロバイダーを使うべき場面もはっきりあります。判断の軸はアプリの性格です。

アプリの性格 推奨 理由
画面数が多く、エンティティ中心の CRUD が主体(受発注、マスタ管理など) EF Core + マイグレーション 詰め替えコードと手書き SQL の総量が減り、スキーマ変更が dotnet ef migrations で追跡できる
書き込み特化でスキーマが小さい(計測ログ、監査ログ、キャッシュ) 素の Microsoft.Data.Sqlite(+ 必要なら Dapper) 変更追跡のオーバーヘッドが無駄。第 5 章の書き込みキュー+バッチが素直に組める
両方の性格が混在 併用 同じ DB ファイルに対して、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 が作成した DB は最初から WAL モードなので7、第 4 章の設定は不要です。挙動の理解は引き続き必要です。

なお EF Core を採用した場合も、リポジトリー層の単体テストに SQLite のインメモリ DB を使う手がよく効きます。本番と同じプロバイダーで動くので「モックでは通るが実 DB で落ちる」隙間が小さくなります。テストをどの層で書くかの考え方は「ユニットテストと結合テストの境界」を参照してください。

9. まとめ

SQLite は「組み込むだけなら 30 分、正しく運用するなら設計が必要」というライブラリです。とはいえ必要な設計は決まりきっていて、この記事の内容をチェックリストにすると次の 6 点になります。

  • ライブラリは Microsoft.Data.Sqlite(またはその上の EF Core)。System.Data.SQLite 前提の情報と混ぜない
  • WAL モードを最初のリリースから有効化し、-wal / -shm の役割を理解しておく
  • 書き込みは 1 本に寄せ(Channels の書き込みキュー)、細かい INSERT はトランザクションでまとめる
  • DateTime は UTC で統一、金額は最小通貨単位の整数。decimal を TEXT のまま比較・集計しない
  • 起動時に quick_check、バックアップは VACUUM INTOBackupDatabase。稼働中のファイルコピーはしない
  • DB ファイルを消す・差し替える処理には 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. 稼働中(トランザクション中)の DB ファイルコピーや、ホットジャーナル・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 が作成した DB では 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 の演算がクライアント評価になることについて。 

同じタグを共有する最新の記事です。さらに近い話題で知識を深められます。

このテーマと近いトピックページです。記事を起点に、関連するサービスや他の記事へ進めます。

この記事は次のサービスページにつながります。近い入口からご覧ください。

著者プロフィール

記事の著者プロフィールページです。

小村 豪

合同会社小村ソフト 代表

Windows ソフト開発、技術相談、不具合調査を中心に、既存資産が残る案件や原因が見えにくい障害調査に強みがあります。

ブログ一覧に戻る