業務アプリのDBスキーマをバージョン管理する ── 「客先ごとにDBが違う」を防ぐマイグレーションの実践

· 更新日: · · データベース, SQLite, SQL Server, マイグレーション, スキーマ管理, C#, .NET, 保守, 判断表, Windows開発

更新履歴(8件・最終更新 2026年08月02日)

この記事に加えた変更の記録です。アーカイブした更新前のバージョンは、DOI付きの固定URLから読めます。

記事の冒頭に「この記事の知識マップ」節を追加しました。本文で扱っている概念とその関係を、要約・図・詳細ページへのリンクにまとめたものです。本文の主張は変えていません。
起動時の適用フローと expand-contract の時間軸を、テキストの図から作図し直しました。内容は変えていません。
外部レビュー(1283件)への対応として本文を更新しました。個々の変更内容は、この下の履歴を参照してください。
前回追加した`.tmp`の掃除が、同時起動と競合し得たのを直しました。この処理は6.2の排他の内側で動かす前提ですが、囲い忘れると2つのプロセスが同じ判定に同時に到達し、片方の作業ファイルをもう片方が消して`File.Move`の直前で`FileNotFoundException`になります。バックアップは正しく取れているのに起動だけが失敗する形です。排他の内側で動かすべきことを本文に明記し、掃除の対象も「十分に古いもの」に限定しました(囲い忘れたときの保険であって、排他の代わりではない旨も添えています)。
適用前バックアップの例で、コメントだけ書いてあった`.tmp`の後始末を実装しました。`VACUUM INTO`は電源断やプロセス強制終了で中断されると中途半端な出力ファイルを残します。一時名で作っているので完成品と誤認されることはありませんが、消さない限り失敗のたびにDB1個ぶんのゴミが利用者のPCに積み上がります。さらに`VACUUM INTO`は出力先が存在しない(または空である)ことを要求するため、マイグレーションで落ちたアプリを同じ秒に起動し直すと名前が一致して止まります。バックアップを取る前に残骸を削除するようにし、公式の該当記述も脚注に補いました。
`schema_meta`を作るSQLをマイグレーションの列の外に置いていたため、実際には誰も実行しない状態でした。`GetMinCompatibleVersion`は常に0を返して締め出しが効かず、最初のcontractで`UPDATE schema_meta`が「表が無い」で倒れます。1番のマイグレーションの中で作るように直し、既存DBへ後付けする場合の手順も添えました。
旧アプリの締め出しを`user_version`で判定していたため、5.1節で説明している共存期間が成立していませんでした。新アプリがexpandを適用した時点で`user_version`が上がり、「自分の知る最大値より大きければ拒否」という判定だと旧アプリはその瞬間からDBを開けなくなります。共存期間中は旧アプリが旧列に書き続ける前提なので、設計そのものが動きません。スキーマ番号とは別に「このDBを開いてよいアプリの下限」を持ち、contractのときにだけ上げる形に分けました。段階ごとの値の動きを表にし、互換だと宣言された未知のスキーマでは知らない列に触らない前提も明記しています。
expand-contract の意味を初出の位置で定義し、起動時の適用フローと段階移行の時間軸を図にしました。あわせてコード例で未定義だった変数の補完、現場での確認手順と切り戻し、列のリネーム手順の展開を追加しています。
初版公開
この記事を引用する(DOI: 10.5281/zenodo.21590044)

この記事はZenodoにアーカイブされています。常に最新版へ解決されるDOIと、いま表示している版に固定されたDOIの両方を下に示します。

小村 豪(2026)「業務アプリのDBスキーマをバージョン管理する ── 「客先ごとにDBが違う」を防ぐマイグレーションの実践」合同会社小村ソフト. https://doi.org/10.5281/zenodo.21590044 https://staging.comcomponent.com/blog/db-schema-migration-versioning-business-apps/

DOI(最新版)
10.5281/zenodo.21590044
DOI(この版)
10.5281/zenodo.21733018

「A社に入れたDBにはこの列があるのに、B社のDBには無いんですよ。どのバージョンで足した列なのかも、もう分からなくて」──客先ごとにインストールされる業務アプリの保守を引き継ぐと、かなりの確率でこの状態に出会います。

アップデート手順書には「このSQLをDBに流してください」と書いてある。しかし実際に流したかどうかは現地の作業者しか知らず、流し忘れた客先、途中でエラーになったまま放置された客先、バージョンを飛ばして更新したせいで中間のALTER TABLEが抜けた客先が混ざっていく。数年後、「特定の客先だけで起きるエラー」の調査に工数が延々と吸い込まれていきます。

当ブログでは「Windowsアプリのデータ保存先の選び方」でスキーマにバージョン番号を付ける最小形のコードを、「C#でSQLiteを業務アプリに使う」でSQLiteの運用設計を解説しました。この記事はその続きとして、DBスキーマの変更をどうバージョン管理し、客先に分散した多数のDBへどう安全に適用するかに踏み込みます。SQLiteを主な題材にしつつ、SQL Server (Express) にも共通する設計として整理します。

1. まず結論

  • スキーマ変更はSQL手順書ではなく、コード(番号付きマイグレーション)としてアプリ本体に同梱し、起動時に自動適用します。人が手順書を実行する運用は、DBが客先に分散した時点で破綻します。
  • DB自身に現在のスキーマバージョンを記録します。SQLiteなら PRAGMA user_version がまさにこの用途のための領域です。1 SQL Serverなら専用テーブルに適用履歴を残します。
  • マイグレーションは前進のみ・追記のみ。一度出荷した番号のSQLは書き換えず、修正も新しい番号で行います。これでv1.2→v1.5の飛ばしアップデートも「未適用分を順に流すだけ」になります。
  • 破壊的変更(列削除・リネーム)は expand-contract の2段階リリースで行います。expand-contract とは、既存の構造を残したまま新しい構造を追加し(expand)、アプリ側の移行が済んでから旧構造を削る(contract)、という2段階リリースの手法です。追加だけのリリースを先に出し、旧形式への参照が消えてから削るリリースを出します(5.1節)。
  • 旧バージョンのアプリが新しいDBを開く事故には、最低バージョンチェックで防御します。このとき、「いまのスキーマ番号」と「締め出しの下限」は別の値として持ちます。同じ値で兼ねると、expand を適用した瞬間に旧アプリが締め出され、上の共存期間が成立しません(5.2節)。
  • 適用前に自動バックアップを取ります。SQLiteなら VACUUM INTO 1文で一貫したコピーが取れ2、失敗時の復旧がファイル差し替えになります。
  • 1マイグレーション=1トランザクション、バージョン番号更新も同一トランザクションに含めます。SQLiteはDDLもトランザクションで巻き戻せます。3 SQL Serverには例外DDLがあるため、例外操作は単独マイグレーションに分離します。4

この記事の知識マップ

客先ごとに分散する業務アプリのDBは、手作業のSQL手順書運用では適用漏れや途中失敗の放置によってスキーマがバラつきます。この記事は、DB自身にスキーマバージョン番号(SQLiteならPRAGMA user_version)を持たせ、番号付きの前進マイグレーションを起動時にトランザクション単位で自動適用する設計を軸に整理します。列の削除やリネームのような破壊的変更はexpand-contractの2段階リリースで行い、旧バージョンのアプリを締め出す判定にはスキーマ番号とは別の最低互換バージョンを使う必要があります。適用前のVACUUM INTOによる自動バックアップ、複数プロセス同時起動の直列化、最古のDBからの一気適用リハーサルまで含め、EF Core Migrations・DbUp・自前実装のどれを選ぶかの判断基準も示します。

DBスキーマのバージョン管理とマイグレーションの知識マップ業務アプリのDBスキーマをPRAGMA user_versionなどのスキーマバージョンと前進マイグレーションで管理し、破壊的変更をexpand-contractの2段階リリースと最低互換バージョンによる旧アプリの締め出しで安全に進め、EF Core Migrations・DbUp・自前実装を使い分ける関係を示す図原因になり得る防止する前提とするに保存されるに保存されるに保存される前提とする前提とする前提とする利用する前提とする両立しない利用する前提とする推奨される対応推奨される対応用いるのは非推奨推奨される対応前提とする自動化する前提とするに保存される前進マイグレーション客先ごとのDBスキーマの分散手作業のSQL手順書運用スキーマバージョン番号PRAGMA user_versionSQL Serverの専用バージョンテーブルSQLiteマイグレーションのトランザクション化DDLのトランザクション対応可否適用前バックアップVACUUM INTO旧バージョンアプリの締め出し最低互換バージョン(min_compatible_version)expand-contract(2段階リリース)EF Core MigrationsDbUp最古DBからの一気適用リハーサルマイグレーション適用の直列化SQL Server

図の実線は常に成り立つ関係、破線は条件付きの関係です(成立条件は詳細ページの各関係の説明に記載)。関係すべての一覧(全22件、根拠・確度つき)と主要概念の定義は知識マップ詳細ページにまとめています。データ: JSON-LD / Turtle

2. 「客先ごとにDBが違う」問題はなぜ起きるか

原因を分解すると、どれも「人がやる前提の運用」に行き着きます。

  • 手作業ALTERの適用漏れ。手順書のSQLを流したかどうかの記録はDBのどこにも残らず、確認手段が「テーブル定義の目視」しかない時点で漏れは必ず発生します。
  • 途中失敗の放置。手順書の5本のSQLのうち3本目でエラーになったとき、作業者には続行も巻き戻しも判断できず、「アプリは動いたのでそのまま」になります。そのDBはもう、どのバージョンとも一致しない世界に1つだけのスキーマです。
  • バージョン飛ばしアップデート。v1.2の次にv1.5を入れる客先では、v1.3とv1.4のスキーマ変更をまとめて正しく辿る必要がありますが、手順書運用では困難です。
  • 緊急対応の現地パッチ。「この客先だけ先に列を足した」が発生し、後日の正式アップデートで二重適用エラーになります。

サーバー1台のWebシステムならDBは1つで、状態は常に把握できます。デスクトップ業務アプリの本質的な難しさは、同じアプリのDBが客先・拠点のPCに数十、数百と分散し、しかも全部が同じバージョンとは限らないことです。1台ずつ人が対処する運用は台数に比例して破綻するので、結論は1つ。アプリ自身に、自分のDBを検査して最新スキーマまで引き上げる能力を持たせることです。

3. 基本パターン: スキーマバージョン+前進マイグレーション

仕組みの骨格は3つの要素だけです。

  1. DB自身がスキーマバージョン番号を持つ(アプリの製品バージョンとは別の、スキーマ専用の整数)。
  2. スキーマ変更は番号付きマイグレーションの列として、アプリのコードに追記していく。
  3. アプリは起動時(DB接続直後)に、現在バージョンより大きい番号のマイグレーションを順にトランザクションで適用する。

起動時の流れを図にすると次のようになります。第5章・第6章で足していく防御は、すべてこの流れのどこかに入ります。

大きいそうでない大きいそうでないないある途中で失敗したときアプリ起動・DB に接続PRAGMA user_version と最低互換番号を読む最低互換番号がアプリの知る最大値より大きいか起動を中断する(5.2節)現在の番号がアプリの知る最大値より大きいか互換の未来スキーマ。適用せずそのまま通常起動(5.2節)未適用のマイグレーションがあるかそのまま通常起動適用前バックアップを取る(VACUUM INTO・5.3節)未適用の番号を昇順に1件ずつ適用する(6.1節)BEGIN TRANSACTION → スキーマ変更・データ変換 →PRAGMA user_version = その番号 → COMMITその1件だけが巻き戻り、直前の番号で止まる最新まで到達したら通常起動

図1: 起動時の適用フロー。第5章・第6章で足す防御は、すべてこの流れのどこかに入る

SQLiteの場合、バージョン番号の置き場所には PRAGMA user_version が使えます。データベースヘッダー(オフセット60)に格納される整数で、公式ドキュメントに「アプリケーションが自由に使ってよく、SQLite自身はこの値を使わない」と明記されています。1 専用テーブルを作らずとも、DBファイル単体で自分のバージョンを名乗れます。

C#での自前実装は、次の数十行で実用になります。

using Microsoft.Data.Sqlite;

public static class SchemaMigrator
{
    // 追記専用のリスト。一度出荷した番号のSQLは絶対に書き換えない
    private static readonly (int Version, string Sql)[] Migrations =
    {
        (1, """
            CREATE TABLE customer (id INTEGER PRIMARY KEY, name TEXT NOT NULL);
            -- 締め出しの下限を置く表。1番の中で作って初期値を入れておく。
            -- ここを別立てにすると誰も作らないまま GetMinCompatibleVersion 
            -- 常に0を返し、締め出しが効かないうえ、最初の contract 
            -- UPDATE schema_meta が「表が無い」で倒れる(5.2)
            CREATE TABLE schema_meta (key TEXT PRIMARY KEY, value INTEGER NOT NULL);
            INSERT INTO schema_meta (key, value) VALUES ('min_compatible_version', 0);
            """),
        (2, "ALTER TABLE customer ADD COLUMN phone TEXT"),
        (3, """
            CREATE TABLE invoice (
                id          INTEGER PRIMARY KEY,
                customer_id INTEGER NOT NULL REFERENCES customer(id),
                issued_at   TEXT    NOT NULL,  -- UTC・書式固定で保存する
                amount      INTEGER NOT NULL   -- 金額は最小通貨単位の整数
            )
            """),
    };

    public static void Migrate(SqliteConnection conn)
    {
        // 追記ミス(番号の重複・逆順)は静かな二重適用・適用漏れになるため、
        // 何かを適用する前に検出して止める
        for (int i = 1; i < Migrations.Length; i++)
            if (Migrations[i].Version <= Migrations[i - 1].Version)
                throw new InvalidOperationException(
                    "マイグレーション番号は昇順・一意でなければなりません。");

        int current = GetUserVersion(conn);
        int latest = Migrations[^1].Version;
        int minCompatible = GetMinCompatibleVersion(conn);

        // 締め出しは「スキーマ番号」ではなく「最低互換番号」で判定する。
        // ここを current > latest で判定すると、新アプリが expand を
        // 適用した瞬間に user_version が上がり、旧アプリはその場でDBを
        // 開けなくなる ── 5.1 で「共存期間は新旧の両方が書く」と決めた
        // 設計がそもそも成立しない。最低互換番号は contract のときにだけ上げる
        if (minCompatible > latest)
            throw new InvalidOperationException(
                $"このデータベースはスキーマ v{minCompatible} 以降を理解するアプリを" +
                $"必要とします(このアプリが知るのは v{latest} まで)。" +
                "アプリを更新してください。");

        if (current > latest)
            // 自分の知らない未来のスキーマだが、互換だと宣言されている。
            // 適用すべきものは無い(すべて current 以下)ので、
            // 知らない列には触れないまま通常起動へ進む(詳しくは5.2節)
            return;

        foreach (var (version, sql) in Migrations)
        {
            if (version <= current) continue;

            using var tx = conn.BeginTransaction();
            using var cmd = conn.CreateCommand();
            cmd.Transaction = tx;
            cmd.CommandText = sql;
            cmd.ExecuteNonQuery();

            // バージョン更新も同じトランザクションで確定させる。
            // これで「変更は入ったが番号が古いまま」という状態が消える
            cmd.CommandText = $"PRAGMA user_version = {version}";
            cmd.ExecuteNonQuery();
            tx.Commit();
        }
    }

    private static int GetUserVersion(SqliteConnection conn)
    {
        using var cmd = conn.CreateCommand();
        cmd.CommandText = "PRAGMA user_version";
        return Convert.ToInt32(cmd.ExecuteScalar());
    }

    // 「このDBを開いてよいアプリの下限」。user_version とは別に持つのが要点で、
    // 同じ値で兼ねると、expand を適用した瞬間に旧アプリを締め出してしまう。
    // 上げるのは contract のマイグレーションのときだけ(5.1・5.2節)
    private static int GetMinCompatibleVersion(SqliteConnection conn)
    {
        using var exists = conn.CreateCommand();
        exists.CommandText =
            "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'schema_meta'";
        if (exists.ExecuteScalar() is null) return 0;   // 表が無い古いDB。下限なし

        using var cmd = conn.CreateCommand();
        cmd.CommandText =
            "SELECT value FROM schema_meta WHERE key = 'min_compatible_version'";
        var value = cmd.ExecuteScalar();
        return value is null or DBNull ? 0 : Convert.ToInt32(value);
    }
}

schema_meta は、上のとおり1番のマイグレーションの中で作ります。別のスクリプトに切り出すと、誰も実行しないまま GetMinCompatibleVersion が常に0を返し、締め出しがまったく効かない状態になります。しかも最初の contract で UPDATE schema_meta が「表が無い」で倒れます。

この仕組みを既存DBへ後付けするときは、1番が適用済みのDBには schema_meta がありません。導入回のマイグレーションで CREATE TABLE IF NOT EXISTSINSERT OR IGNORE を使って作り直してください(番号を1つ足すだけです)。GetMinCompatibleVersion が表の有無を先に確認しているのは、この移行期間のためです。

contract のマイグレーションでだけ、この値を引き上げます。

-- 旧列を削除する回のマイグレーションで、同じトランザクションの中で上げる
UPDATE schema_meta SET value = 7 WHERE key = 'min_compatible_version';
ALTER TABLE customer DROP COLUMN old_name;

これで第2章の問題は構造的に解決します。適用漏れは起きない(起動のたびに検査される)、途中失敗は巻き戻る(第6章)、バージョン飛ばしも問題ない(v1.2のDBがスキーマv2なら、v1.5のアプリが3・4・5を順に適用するだけ)。「この客先のDBはどの状態か」も、PRAGMA user_version を1回読めば答えられます。

運用ルールは2つだけ厳守します。

  • 出荷済みの番号は書き換えない。v3のSQLにバグがあってもv4で修正します。書き換えると「古いv3を適用済み」「新しいv3を適用済み」という新たなバラつきが生まれます。
  • データ変換もマイグレーションに含める。列追加だけでなく既存データの移し替え(UPDATE)も同じ番号の中で行います。日時列の書式・タイムゾーンは「業務アプリの日時とタイムゾーン」のとおり最初にUTC・固定書式で統一しておくと、後のマイグレーションが単純になります。

もう1つ、この仕組みを既存システムに後付けするときだけの作業があります。手作業パッチで運用してきたDBは、「user_version は0のまま、実スキーマだけ部分的に進んでいる」状態がありえます(第2章の緊急パッチがまさにそれです)。そのままこのチェーンに乗せると、適用済みの変更に対する ALTER TABLE が「列が既に存在する」エラーで倒れます。導入の最初のリリースでは、一度だけのベースライン処理として実スキーマを検査し(SQLiteなら PRAGMA table_info で列の有無を確認)、既知の手パッチが当たっているDBには対応するバージョン番号を焼き込んでから、以降を前進マイグレーションに任せます。ここを飛ばせるのは、初期リリースから本仕組みを入れた場合だけです。

SQL Serverには user_version に相当する仕組みがないので、専用テーブル(例: schema_version)に「バージョン番号・適用日時・適用時のアプリバージョン」を1行ずつINSERTします。履歴が行として残るぶん、後からの調査に強くなります。

4. ツールを使うか自前か ── 判断表

同じことを実現する手段は、EF Core Migrations、マイグレーションライブラリ(DbUpなど)、前章の自前実装の3系統があります。名前だけでは何をする道具か分かりにくいので、先に一言ずつ添えておきます。

  • EF Core(Entity Framework Core) ── Microsoft製の.NET向けO/Rマッパー(オブジェクトとテーブルを対応付けてSQLを自動生成するライブラリ)。その付属機能であるEF Core Migrationsは、C#で書いたモデル(クラス定義)の変更を検出して、スキーマ変更のコードを自動生成します。
  • DbUp ── SQLスクリプトの適用管理に特化したオープンソースの.NETライブラリ。SQLは自分で書き、「どのスクリプトを適用済みか」の記録と未適用分の実行だけを引き受けます。5
比較軸 EF Core Migrations マイグレーションライブラリ(DbUp等) 自前実装
変更の記述 C#のモデル変更から自動生成 SQLスクリプトをそのまま資産化 SQL文字列またはC#コード
学習コスト 高(モデル・ツール・制約の理解が必要) 低〜中 最小(数十行を理解するだけ)
SQLiteとの相性 △ 列変更・削除がテーブル再構築になる。冪等スクリプト生成不可6 ○ SQL Server中心だがSQLite等にも対応5 ◎ 制約を知った上で直接書ける
既存の生SQL資産 流用しにくい(モデル定義への置き換えが必要) ◎ 手順書のSQLをほぼそのまま移せる ◎ 同左
適用済み管理 履歴テーブル(自動) ジャーナルテーブル(自動)5 user_version / 自作テーブル
配布形態との相性 アプリ同梱で起動時 Migrate()(注意点あり、後述) アプリ同梱で起動時実行 アプリ同梱で起動時実行

状況別の推奨はこうなります。

状況 推奨 理由
すでにEF Coreでデータアクセスしている EF Core Migrations モデルとスキーマの二重管理を避けられる。別ツールを足す理由がない
生SQL(ADO.NET / Dapper)中心+SQLite 自前実装 依存ゼロで足りる。SQLiteのALTER TABLE制約は結局自分で意識することになる
生SQL中心+SQL Server、手順書SQLの蓄積が多い DbUp等のライブラリ 既存SQLをスクリプトとして資産化でき、適用管理を自作しなくて済む
ストアドプロシージャ・ビューが多い DbUp等のライブラリ モデルから生成できないオブジェクトはSQLスクリプト管理が素直
DBが小さく変更頻度も低い 自前実装 仕組みの維持コストを最小にする

DbUpは「SQL Serverデータベースへの変更をデプロイするのを助ける.NETライブラリ」で、実行済みスクリプトをジャーナルテーブルに記録し、未実行分だけを実行します。SQLite・PostgreSQL・MySQLなどにも対応します。5 「手順書のSQLを、実行記録付きの自動適用に変える」移行パスとしては最短です。

4.1 EF Core Migrations を配布アプリで使うときの注意

EF Coreの開発中の適用は dotnet ef database update ですが、客先PCにはSDKもソースもありません。現実的な適用方法はアプリ起動時の context.Database.Migrate() です。

ここで知っておくべきなのは、Microsoftのドキュメントが、本番データベースの管理手段として起動時適用に明確な注意喚起をしていることです。理由は、(1)複数インスタンスの同時適用による失敗や破損(EF Core 9より前)、(2)適用中のDBへ他のアプリがアクセスすると深刻な問題が起きうる、(3)アプリにスキーマ変更の昇格権限が必要になる、(4)ロールバック手段が乏しい、(5)実行されるSQLを事前に確認・修正できない、の5点で、推奨はSQLスクリプトを生成してデプロイ工程で適用する方法です。7

ただしこの推奨は「DBが1つで、デプロイ工程が存在する」サーバーシステムの想定です。客先PCごとにローカルDBがあるデスクトップアプリでは、スクリプトを持って現地を回る運用こそが第2章の問題そのものなので、起動時 Migrate() が事実上の標準解になります。残る懸念には手当てをします。

  • 同時実行: EF Core 9以降は Migrate() がロックを自動取得し、複数プロセスが同時にマイグレーションを実行することを防ぎます。7 それ以前のバージョンでは第6章のとおり自前で直列化します。ただしこのロックが直列化するのはマイグレーションの実行同士だけで、適用中に旧バージョンのアプリが普通に読み書きすることは止められません。共有DBでは最低バージョンチェック(5.2節)やメンテナンス時間帯との併用が前提です。
  • SQLの事前確認: 生成されたマイグレーションを必ずレビューし、リリース前に実データ相当のDBでリハーサルします(6.3節)。
  • EnsureCreated() と混ぜない: マイグレーション履歴なしでスキーマが構築され、後から Migrate() が失敗します。最初から Migrate() に統一します。7

SQLiteプロバイダーでは、列の型変更や削除を含むマイグレーションは「新テーブル作成→データコピー→旧テーブル削除→リネーム」というテーブル再構築として実行され、冪等スクリプトの生成もできません。6 EF Core自体を使うかどうかの判断は「C#でSQLiteを業務アプリに使う」の第8章で整理したとおりです。

5. 壊れないマイグレーションの書き方

個々のマイグレーションの原則は「後方互換を壊す変更を、1回のリリースでやらない」です。

5.1 破壊的変更は expand-contract(2段階リリース)で

列の追加は安全ですが、削除・リネーム・型変更は「旧形式を前提にした何か」を壊します。ローカルSQLiteでアプリとDBが1対1でも、(a)不具合時にアプリを旧バージョンへ戻す可能性、(b)DBを直接読む別ツール(帳票ツール、CSVエクスポーター、Access連携)、(c)SQL Serverを新旧クライアントが同時参照する構成、のどれかがたいてい存在します。そこで破壊的変更は expand(広げる)→contract(縮める)の2段階に分けます。

時間軸で見ると、両者の間に「新旧どちらの形でも動く共存期間」を挟むのが要点です。

リリースA(expand: 広げる)新しい構造を追加し、旧構造はそのまま残すアプリは新旧の両方に書き、読み取りは旧構造を正とする共存期間旧アプリ・旧ツールもそのまま動く(どちらの構造も生きている)この間に全クライアントを新バージョンへ入れ替える最低バージョンチェック(5.2節)で旧アプリを締め出せる状態になるリリースB(contract: 縮める)旧構造の最新値を新構造へ最終コピー / 最終変換する読み取りを新構造へ切り替え、旧構造を削除する

図2: expand と contract のあいだに共存期間を挟む。旧アプリを締め出せるようになるまで contract を出さないのが要点

変更内容 1回でやると起きること 安全な2段階
列のリネーム 旧名を参照する旧アプリ・帳票が即死 手順が長いので下に分けて示します
列の削除 旧アプリのINSERT/SELECTがエラー expand: アプリが参照をやめるだけ(列は残す) → contract: 数リリース後に削除
型・意味の変更(例: ローカル時刻→UTC) 新旧の値が1列に混在し、静かに壊れる expand: 新列を追加して変換済みの値を入れる。共存期間はリネームと同じ扱い(新アプリは両方に書き、読み取りは旧列を正とする) → contract: 旧アプリを締め出した後、旧列から最終変換をしてから読み取りを切り替え、旧列を削除
NOT NULL制約の追加 既存NULL行で適用が失敗。旧アプリのNULL書き込みも制約違反で即死 expand: 既定値を用意し、全クライアントが非NULLを書くバージョンへ更新 → contract: 旧アプリの締め出し後に残ったNULLをUPDATEで埋め、それから制約を追加

列のリネームは条件分岐が多く、表の1セルに収まりません。手順を分解すると次のようになります。

  1. expand: 新列を追加し、旧列の値をコピーする。同じマイグレーション番号の中で ALTER TABLE ... ADD COLUMNUPDATE を行います。
  2. 共存期間: 新アプリは新旧の両方に書き、読み取りは旧列を正とする。読み取り側を旧列のままにするのは、旧アプリが新旧同時に稼働する共有DBでは旧アプリが旧列にしか書かないためです。新列を読んでいると、旧アプリが入れた更新を見落とします。DB側のトリガーで旧列→新列を同期する手もあります。
  3. 旧アプリを締め出す。最低バージョンチェック(5.2節)で、旧アプリがそのDBを開けない状態にします。ここまでは締め出しません。手順1で user_version は上がりますが、最低互換番号は据え置きなので、旧アプリはDBを開いて旧列に書き続けられます。この2つを同じ値で兼ねると、手順1を適用した瞬間に手順2の共存期間が消滅します。
  4. contract: 旧列の最新値を新列へ最終コピーしてから、読み取りを新列へ切り替え、旧列を削除する。この順序が肝心で、締め出す前に読み取りを新列へ切り替えると、旧アプリが旧列にだけ書いた更新を取りこぼします。

contract(削る)側のリリースは、最低バージョンチェック(次節)で旧アプリを締め出せるようになってから出すのが安全です。

SQLite固有の事情として、ALTER TABLEはテーブル名変更・列名変更・列追加・列削除のみをサポートし、列削除にも「PRIMARY KEYやUNIQUE制約の列は不可、インデックス・CHECK制約・外部キー・ビューから参照される列は不可」など多くの制限があります。それ以外の変更は、公式ドキュメントが定める「トランザクション内で新テーブルを作り、INSERT INTO new_X SELECT ... FROM X でデータを移し、旧テーブルを削除してリネームする」手順で行います。3 大きなテーブルでは全件コピーになるため、適用時間とディスク空き容量を見込んでおきます。

5.2 ダウングレードへの防御 ── 最低バージョンチェック

前進マイグレーションだけの設計では、ダウン方向のスクリプトは書きません(客先で使う機会がなく、テストされないコードは危険なだけです)。代わりに必要なのが、旧バージョンのアプリが新しいDBを開いてしまったときに止まる仕組みです。

ここで「スキーマ番号」と「締め出しの下限」を分けるのが要点です。第3章のコードでは、

  • user_version ── いまのスキーマ番号。マイグレーションを適用するたびに上がる
  • schema_metamin_compatible_version ── このDBを開いてよいアプリの下限。contract のときにだけ上げる

の2つを持ち、締め出しは後者だけで判定します。user_version で締め出すと、5.1節の共存期間が成立しません。新アプリが expand を適用した時点で user_version は上がるので、「自分の知る最大値より大きければ拒否」という判定だと、旧アプリはその瞬間からDBを開けなくなります。それでは「共存期間は新旧の両方が書く」という設計そのものが動きません。

分けておけば、次のように進みます。

段階 user_version min_compatible_version 旧アプリ
リリースA(expand)適用後 上がる 据え置き 開ける。旧列に書き続ける
旧アプリの更新が行き渡る 変わらず 据え置き ──
リリースB(contract)適用後 上がる 上げる 開けない。更新を促して停止

旧アプリ側から見ると、「自分の知らない番号のスキーマだが、互換だと宣言されている」という状態で動くことになります。このとき知らない列には触らないのが前提です。だからこそ、expand 側で入れる変更は列の追加に限り、既存列の意味を変えないようにします。

なお、「切り戻しの可能性があるリリースでは破壊的変更を入れない(expandだけにする)」と決めておけば、新DBを旧アプリで読むこと自体は安全です。締め出しを「警告して読み取り専用で起動」に緩める設計も選べます。どちらにするかは業務の止められなさで決めます。

5.3 適用前の自動バックアップ

マイグレーションは「他人のPCにある本番データ」への手術です。バックアップを取ってから実行する、を機械化します。SQLiteなら VACUUM INTO が最適で、稼働中のDBからでも一貫性のあるスナップショットを別ファイルに1文で作れます。2

// conn      … 開いている SqliteConnection(第3章の Migrate に渡すのと同じもの)
// latest    … Migrations の最後の番号(第3章の latest と同じ)
// backupDir … バックアップの置き場所。DB本体と同じフォルダーに置くと
//             ディスク故障で同時に失うため、別ドライブや共有フォルダーを推奨
var backupDir = Path.Combine(
    Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
    "MyApp", "db-backup");

// 適用が必要なときだけ、直前に1世代バックアップを取る
if (GetUserVersion(conn) < latest)
{
    Directory.CreateDirectory(backupDir);

    // 前回の失敗で残った作業ファイルを、ここで実際に片づける。
    // 残っていても普通は次回の邪魔をしない(名前に時刻が入るため)が、
    // 消さない限りDB1個ぶんのゴミが失敗のたびに積み上がる。
    // そして同じ秒に再実行されると名前が衝突し、VACUUM INTO は
    // 「出力先が存在しない(または空)」を要求するのでそこで止まる。
    //
    // 消すのは「十分に古いもの」だけにする。このブロックは 6.2 のミューテックスの
    // 内側で動かす前提だが、それでも別の版や別のツールが同じフォルダーを
    // 使うことはあり、いま走っている実行の作業ファイルを消すと、
    // その実行が File.Move の直前で FileNotFoundException になる
    DateTime staleBefore = DateTime.UtcNow - TimeSpan.FromHours(1);
    foreach (var stale in Directory.EnumerateFiles(backupDir, "*.db.tmp"))
    {
        try
        {
            if (File.GetLastWriteTimeUtc(stale) < staleBefore) { File.Delete(stale); }
        }
        catch (IOException) { }                  // 他プロセスが掴んでいる。次回に回す
        catch (UnauthorizedAccessException) { }
    }

    var backupPath = Path.Combine(backupDir,
        $"app_schema_v{GetUserVersion(conn)}_{DateTime.Now:yyyyMMdd_HHmmss}.db");
    // 実行途中で電源断・プロセス強制終了が起きた不完全なファイルを
    // 「完成したバックアップ」に見せないため、一時名で作り成功後にリネームする
    var tempPath = backupPath + ".tmp";
    using var cmd = conn.CreateCommand();
    cmd.CommandText = "VACUUM INTO $path";
    cmd.Parameters.AddWithValue("$path", tempPath);
    cmd.ExecuteNonQuery();  // VACUUMはトランザクション外で実行する
    File.Move(tempPath, backupPath);
}

このブロックは、6.2 で用意する排他の内側で動かしてください。バックアップはマイグレーションの一部であって、別工程ではありません。「バージョンを見る → バックアップを取る → 適用する」を一続きで囲わないと、朝いちばんに全員が同時にアプリを起動するという業務アプリの日常で、2つのプロセスが同じ判定に同時に到達します。片方の作業ファイルをもう片方が掃除して、File.Move の直前で FileNotFoundException ── バックアップは正しく取れているのに起動だけが失敗する、という説明しにくい形になります。上のコードで「十分に古いものだけ」を消しているのは、囲い忘れたときの被害を小さくするための保険です。保険であって、排他の代わりではありません。

.tmp の掃除は「あとで書く」ではなく、この処理の一部として書いてください。VACUUM INTO は電源断やプロセス強制終了で中断されると、中途半端で壊れた出力ファイルを残します。2 一時名で作っているので「完成したバックアップ」に見えることはありませんが、消さない限り失敗のたびにDB1個ぶんのゴミが利用者のPCに積み上がります。バックアップ先の容量を静かに食い、復旧のときには「どれが本物か」の判断を1つ増やします。

そして VACUUM INTO は、出力先のファイルが存在しない(または空である)ことを要求します。2 上の命名には時刻が入っているので通常は衝突しませんが、マイグレーションで落ちたアプリを利用者がすぐ起動し直す(あるいは監視サービスが再起動する)と、同じ秒に入って名前が一致し、そこで止まります。「バックアップに失敗して起動できない」という、いちばん説明しにくい形の障害です。

ファイル名にスキーマバージョンを入れておくと、復旧時に「どこまで戻るのか」が一目で分かります。稼働中DBの単純ファイルコピーが破損の温床であることなどバックアップの詳細は「C#でSQLiteを業務アプリに使う」の第7章を参照してください。SQL Serverなら BACKUP DATABASE を適用前に実行する形で、考え方は同じです。

6. 運用の落とし穴

6.1 途中失敗とトランザクション ── DBMS差を知っておく

第3章のコードは1マイグレーションを1トランザクションで包み、user_version の更新も同じトランザクションに含めています。これが成立するのは、SQLiteがDDL(CREATE TABLE、ALTER TABLEなど)をトランザクション内で実行でき、失敗時に巻き戻せるからです。公式のテーブル再構築手順自体が「トランザクションを開始し、CREATE/INSERT/DROP/RENAMEを行い、コミットする」構成です。3 途中で電源が落ちても、次回起動時のDBは「そのマイグレーションの直前」の一貫した状態です。

SQL Serverも多くのDDLをトランザクション内で実行できますが、例外があります。たとえば ALTER DATABASE は明示的トランザクション内では使えず、CREATE FULLTEXT INDEX もユーザートランザクション内に置けません。4 EF Coreも、可能な場合は各マイグレーションを自動的にトランザクションで包む一方、「一部の操作はデータベースによってはトランザクション内で実行できない」と明記しています。8 実務のルールは、トランザクションに入らない操作を通常のスキーマ変更と同じマイグレーションに混ぜない、これに尽きます。DBMSを乗り換える際は「DDLがトランザクションに参加するか」を必ず確認してください。

定番事故が「バージョン更新だけ別トランザクション」です。変更本体が成功し番号更新の前に落ちると、次回起動時に同じマイグレーションが再実行され「テーブルは既に存在します」で永久に起動失敗します。番号更新を同一トランザクションに含めていれば原理的に発生しません。

6.2 複数プロセスの同時起動 ── 適用の直列化

業務アプリは「朝、全員が一斉に起動する」ソフトウェアです。共有DB(SQL Server)を見る複数クライアントや同一PCでの多重起動が、同時にマイグレーションを走らせる可能性があります。

  • EF Core 9以降Migrate() が自動的にデータベース全体のロックを取得して同時適用を防ぎます(それより前にはこの保護がありません)。なおSQLiteプロバイダーのロックはロック用テーブルで実装されており、適用中のプロセスが異常終了するとテーブルが残る可能性が公式に注記されています。7 もしロック待ちのまま起動できなくなったら、マイグレーション実行中のプロセスが他にいないことを確認したうえで、残ったロック用テーブル(__EFMigrationsLock)をDROPすれば復旧できます。
  • 自前実装では、ローカルDBなら名前付きMutexでの直列化が簡単です。
// using System.Threading;   (Mutex / AbandonedMutexException)
// conn … 開いている SqliteConnection。マイグレーションを走らせる前に
//        接続だけ済ませておき、Mutex を取ってから Migrate を呼ぶ
using var conn = new SqliteConnection(connectionString);
conn.Open();

// Global\ を付け、RDPやユーザー切り替えで複数のログオンセッションから
// 起動されてもPC全体で直列化されるようにする(Local\ は同一セッション内のみ)
using var mutex = new Mutex(false, @"Global\MyApp.SchemaMigration");
try
{
    mutex.WaitOne();
}
catch (AbandonedMutexException)
{
    // 前の所有プロセスがReleaseせずに異常終了したケース。
    // 例外が出ても所有権自体は取得できているので、そのまま続行してよい。
    // 適用が中途半端に終わった可能性には、この後のバージョン再確認と
    // マイグレーションごとのトランザクションで備える
}
try
{
    SchemaMigrator.Migrate(conn);
}
finally
{
    mutex.ReleaseMutex();
}

待たされた側は、ロック獲得後にもう一度バージョンを確認するので(第3章のコードは適用前に毎回 version <= current を見ます)、二重適用にはなりません。なお Global\ の名前付きオブジェクトは、既定では作成したユーザー由来のACLを持つため、別のWindowsアカウントのセッションから同じMutexを開くと UnauthorizedAccessException になることがあります。複数アカウントでの利用が前提なら、System.Threading.AccessControlMutexAcl で利用ユーザーに同期・変更のアクセス権を与えて作成するか、次に述べるDB側のロックに寄せます。共有DBではMutexがマシンをまたげないため、「アップデート配布前にサーバー側で適用を済ませる」「適用開始時にDB側のロック(SQLiteなら BEGIN IMMEDIATE、SQL Serverならアプリロック)を取る」など、DB側での直列化に寄せます。

6.3 リハーサル ── 「最古のDB」からの一気適用をテストする

マイグレーションのバグは開発機ではまず見つかりません。開発機のDBは常に最新スキーマで、データが綺麗だからです。壊れるのは客先の、古くて、大きくて、想定外のデータが入っているDBです。リリース前に最低限やるべきことは3つです。

  • 各スキーマバージョンのDBファイルをテストフィクスチャとして保存し、それぞれから最新まで一気に適用するテストを自動化する。「v1からv5」「v3からv5」のような飛ばしパターンこそが客先の現実です。SQLiteならDBファイルをリポジトリに置くだけなので、テストは書きやすい部類です。
  • 実データ相当の量と質で試す。NULLだらけの列、想定外の重複、巨大テーブルでの再構築時間(5.1節)は、データが本物に近くないと露見しません。可能なら匿名化した客先DBでリハーサルします。
  • 失敗系を試す。適用途中でプロセスを殺し、次回起動で正しく復帰する(巻き戻ったバージョンから再適用される)ことまで確認します。

6.4 現場での確認手順と切り戻し

仕組みを入れたら、「うまくいったか」「駄目だったとき何をするか」を手順として書き出しておきます。電話口で作業者に指示することになる場面が必ず来るので、コマンドの形にしておくのが実用的です。

適用結果を確認する。SQLiteの公式コマンドラインシェル(sqlite3)があれば、1行で現在のスキーマバージョンを読めます。返るのは整数1つです。

sqlite3 "C:\ProgramData\MyApp\app.db" "PRAGMA user_version;"

客先PCにsqlite3.exeを置けないことも多いので、アプリのバージョン情報画面に製品バージョンとスキーマバージョンの両方を表示しておくと、電話1本で状態を確認できます。第3章のGetUserVersionをそのまま呼ぶだけです。SQL Serverの場合はSELECT MAX(version) FROM schema_version;が同じ役割になります。

失敗したときに切り戻す。5.3節のバックアップは、次の手順で戻します。

  1. アプリを完全に終了する。多重起動や、同じDBを見る他の端末も含めて全部です。
  2. 現物を退避する。現在のDBファイルを、WALモードなら同名の-wal / -shmファイルごと、別のフォルダーへ移します。消さずに取っておくこと。原因調査に必要です。
  3. バックアップファイルを元のファイル名でコピーする。5.3節でファイル名にスキーマバージョンを入れておいたので、どの時点に戻るのかがファイル名で分かります。
  4. アプリを起動し、PRAGMA user_versionが戻したい番号になっていることを確認する。そのうえで、原因が直った版のアプリを配布するまでは、旧バージョンのアプリで運用を継続します。

この手順が書いてあるかどうかで、障害当日の復旧時間が変わります。マイグレーションの実装と同じリリースで、運用手順書にも1ページ足しておいてください。

7. まとめ

  • 「客先ごとにDBが違う」は担当者の注意力ではなく、人がSQL手順書を実行する運用の構造的な帰結です。DBが分散するデスクトップ業務アプリでは、アプリ自身に自分のDBを最新化させるしかありません。
  • 骨格は、DB自身が持つスキーマバージョン番号(SQLiteなら PRAGMA user_version1)と、番号付き前進マイグレーションの起動時適用。C#なら数十行の自前実装で成立します。
  • 手段はEF Core Migrations・DbUp等のライブラリ・自前実装の3系統。既にEF Coreを使っているか、生SQL資産がどれだけあるかで選びます(第4章の判断表)。EF Coreの起動時 Migrate() は公式に注意点が列挙されているので7、同時実行対策とリハーサルを添えて使います。
  • 破壊的変更は expand-contract の2段階リリースで行い、旧アプリが新DBを開く事故は最低バージョンチェックで止めます。SQLiteのALTER TABLEの制約と再構築手順は公式ドキュメントに従います。3
  • 1マイグレーション=1トランザクション、バージョン更新も同一トランザクションが原則です。SQL Serverにはトランザクションに入らないDDLがあるため4、例外操作は分離します。適用前の VACUUM INTO バックアップ2と、最古バージョンからの一気適用リハーサルまで含めて、初めて「客先に出せる」マイグレーションです。

手順書ALTERの運用に心当たりがあるなら、次のリリースで「バージョン番号の記録」と「起動時適用」だけでも入れてみてください。土台さえあれば、2段階リリースやバックアップは後から少しずつ足していけます。

関連記事

関連する相談領域

合同会社小村ソフトでは、客先ごとにインストールされる業務アプリのDB設計・マイグレーション基盤の導入、手順書運用でバラついたスキーマの調査と正常化、EF Core / 生SQL構成それぞれでのアップデート配布設計を扱っています。

参考リンク

  1. SQLite, Pragma statements supported by SQLite - user_version. user_versionはデータベースヘッダー(オフセット60)に格納される整数で、アプリケーションが自由に使うために用意されており、SQLite自身はこの値を利用しないことについて。  2 3

  2. SQLite, VACUUM. VACUUM INTOが元のDBを変更せず、稼働中のデータベースの一貫したスナップショットを別ファイルへ作成でき、バックアップAPIの代替として使えることについて。あわせて、「INTO句で指定したファイルは、事前に存在していないか、空のファイルでなければならない。さもなければ VACUUM INTO コマンドはエラーで失敗する」という要件と、「ただし、VACUUM INTO コマンドが計画外のシャットダウンや電源喪失で中断された場合、生成された出力データベースは不完全で壊れている可能性がある」という記述について。  2 3 4 5

  3. SQLite, ALTER TABLE. SQLiteのALTER TABLEがテーブル名変更・列名変更・列追加・列削除に限定されること、列削除に多くの制限があること、その他のスキーマ変更はトランザクション内で新テーブル作成→データコピー→旧テーブル削除→リネームを行う公式手順で実施することについて。  2 3 4

  4. Microsoft Learn, ALTER DATABASE (Transact-SQL) および CREATE FULLTEXT INDEX (Transact-SQL). ALTER DATABASEは自動コミットモードで実行する必要があり明示的・暗黙的トランザクション内では許可されないこと、CREATE FULLTEXT INDEXがユーザートランザクション内に置けないことについて。  2 3

  5. DbUp, DbUp Documentation および Supported Databases. SQL Serverデータベースへの変更のデプロイを支援する.NETライブラリであり、実行済みSQLスクリプトを記録して未実行分のみを実行すること、SQLite・PostgreSQL・MySQL等にも対応することについて。  2 3 4

  6. Microsoft Learn, SQLite EF Core Database Provider Limitations. SQLiteプロバイダーでは多くのマイグレーション操作がテーブル再構築として実行されること、冪等スクリプトが生成できないことについて。  2

  7. Microsoft Learn, Applying Migrations (EF Core). 実行時(起動時)のマイグレーション適用が本番データベースの管理には不適切とされる5つの理由、SQLスクリプト生成が推奨されること、EnsureCreated()とMigrate()を併用してはならないこと、EF Core 9以降のMigrate()がデータベース全体のロックを自動取得すること、SQLiteプロバイダーのロックがテーブルで実装され異常終了時に残留しうることについて。  2 3 4 5

  8. Microsoft Learn, Managing Migrations (EF Core). EF Coreが適用時に各マイグレーションを可能な場合は自動的にトランザクションで包むこと、一部の操作はデータベースによってはトランザクション内で実行できないことについて。 

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

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

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

よくある質問

この記事のテーマについて、相談時によくある質問をまとめています。

業務アプリのDBスキーマ変更はどう管理すべきですか?
SQL手順書を人が実行する運用ではなく、番号付きのマイグレーション(スキーマ変更のコード)をアプリ本体に同梱し、起動時に自動適用する形にすべきです。DB自身に現在のスキーマバージョンを記録しておき(SQLiteならPRAGMA user_version、SQL Serverなら専用テーブル)、アプリは未適用の番号だけを順にトランザクションで適用します。この形ならv1.2からv1.5への飛ばしアップデートでも途中のスキーマ変更がすべて適用され、「客先ごとにDBの形が違う」状態が構造的に起きなくなります。
EF CoreのMigrate()をアプリ起動時に呼んでもよいですか?
条件付きで現実的な選択です。Microsoftのドキュメントは、複数インスタンスの同時適用、アプリにスキーマ変更権限を持たせること、SQLを事前確認できないことなどを理由に、本番環境での起動時適用に注意喚起しており、サーバーアプリではSQLスクリプト生成による適用が推奨されています。一方、クライアントPCごとにローカルDBを持つデスクトップ業務アプリでは、現地でスクリプトを実行する運用が成立しないため、起動時のMigrate()が事実上の標準解になります。その場合も同時起動対策(EF Core 9以降の自動ロックまたは自前のMutex)と適用前バックアップは必ず組み合わせてください。
マイグレーションが途中で失敗したらDBはどうなりますか?
1つのマイグレーションを1つのトランザクションで包み、バージョン番号の更新も同じトランザクションに含めていれば、失敗時はそのマイグレーションの開始前の状態にロールバックされ、中途半端なスキーマは残りません。SQLiteはCREATE TABLEやALTER TABLEなどのDDLもトランザクション内で実行でき、公式のテーブル再構築手順自体がトランザクション前提で書かれています。SQL Serverも多くのDDLをトランザクション内で実行できますが、ALTER DATABASEやフルテキストインデックス関連など例外があるため、例外操作は単独のマイグレーションに分離します。さらに適用前の自動バックアップがあれば、最悪の場合もファイル差し替えで復旧できます。
すでに客先ごとにDBスキーマがバラバラになっている場合、どう正常化すればよいですか?
まず「あるべきスキーマの正」を1つ決め、各客先のDBを調査して現状との差分を洗い出します。次にバージョン番号のないDBに対して、実在する各パターンを検出して正規形に揃える初期マイグレーションを書き、その完了時点でバージョン番号を刻みます。SQLiteならsqlite_masterやPRAGMA table_infoで列の有無を機械的に判定でき、「列がなければ追加する」形の防御的なSQLで吸収できます。以後の変更をすべて番号付きマイグレーションに載せれば、バラつきは再発しません。

著者プロフィール

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

小村 豪

合同会社小村ソフト 代表

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

ブログ一覧に戻る