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

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

「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段階リリースで行います。追加だけのリリースを先に出し、旧形式への参照が消えてから削るリリースを出します。
  • 旧バージョンのアプリが新しいDBを開く事故には、最低バージョンチェックで防御します。自分の知らない未来のスキーマに書き込ませないのが原則です。
  • 適用前に自動バックアップを取ります。SQLiteなら VACUUM INTO 1文で一貫したコピーが取れ2、失敗時の復旧がファイル差し替えになります。
  • 1マイグレーション=1トランザクション、バージョン番号更新も同一トランザクションに含めます。SQLiteはDDLもトランザクションで巻き戻せます。3 SQL Serverには例外DDLがあるため、例外操作は単独マイグレーションに分離します。4

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接続直後)に、現在バージョンより大きい番号のマイグレーションを順にトランザクションで適用する。

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)"),
        (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;

        if (current > latest)
            // 新しいアプリが作ったDBを旧アプリで開いたケース(5.2節)。
            // 知らないスキーマに触らず、ここで止めるのが安全
            throw new InvalidOperationException(
                $"このデータベース(スキーマ v{current})はより新しいアプリで" +
                "作成されています。アプリを更新してください。");

        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());
    }
}

これで第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 Migrations マイグレーションライブラリ(DbUp等) 自前実装
変更の記述 C#のモデル変更から自動生成 SQLスクリプトをそのまま資産化 SQL文字列またはC#コード
学習コスト 高(モデル・ツール・制約の理解が必要) 低〜中 最小(数十行を理解するだけ)
SQLiteとの相性 △ 列変更・削除がテーブル再構築になる。冪等スクリプト生成不可5 ○ SQL Server中心だがSQLite等にも対応6 ◎ 制約を知った上で直接書ける
既存の生SQL資産 流用しにくい(モデル定義への置き換えが必要) ◎ 手順書のSQLをほぼそのまま移せる ◎ 同左
適用済み管理 履歴テーブル(自動) ジャーナルテーブル(自動)6 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などにも対応します。6 「手順書の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プロバイダーでは、列の型変更や削除を含むマイグレーションは「新テーブル作成→データコピー→旧テーブル削除→リネーム」というテーブル再構築として実行され、冪等スクリプトの生成もできません。5 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段階に分けます。

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

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

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

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

前進マイグレーションだけの設計では、ダウン方向のスクリプトは書きません(客先で使う機会がなく、テストされないコードは危険なだけです)。代わりに必要なのが、旧バージョンのアプリが新しいDBを開いてしまったときに止まる仕組みです。第3章のコードの冒頭がそれで、user_version が自分の知る最大値より大きければ例外にして起動を中断します。

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

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

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

// 適用が必要なときだけ、直前に1世代バックアップを取る
if (GetUserVersion(conn) < latest)
{
    Directory.CreateDirectory(backupDir);
    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);
    // 起動時に *.tmp が残っていたら前回の失敗の痕跡なので削除する
}

ファイル名にスキーマバージョンを入れておくと、復旧時に「どこまで戻るのか」が一目で分かります。稼働中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での直列化が簡単です。
// 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でリハーサルします。
  • 失敗系を試す。適用途中でプロセスを殺し、次回起動で正しく復帰する(巻き戻ったバージョンから再適用される)ことまで確認します。

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の代替として使えることについて。  2 3

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

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

  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が適用時に各マイグレーションを可能な場合は自動的にトランザクションで包むこと、一部の操作はデータベースによってはトランザクション内で実行できないことについて。 

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

テストのないレガシー業務アプリに安全に手を入れる ── 特性化テストとリファクタリングの実践

テストのない業務アプリを「触ると壊れそうで直せない」と感じている方向けに、現在の挙動を固定する特性化テスト(ゴールデンマスター法)の具体手順、テストを差し込む継ぎ目(seam)の作り方、どこまで整備するかの判断表、リファクタリングと機能追加を混ぜない運用ルールをC#の例とと...

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

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

よくある質問

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

業務アプリの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 ソフト開発、技術相談、不具合調査を中心に、既存資産が残る案件や原因が見えにくい障害調査に強みがあります。

ブログ一覧に戻る