C#で複数アプリからXML採番を安全に行う方法|ファイル排他とNamed Mutexで注文番号の重複を防ぐ

複数のバッチやサービス、UIアプリから同じXMLファイルに対して採番したい──そういうとき、つい「とりあえず lock で守れば大丈夫だろう」と考えがちですが、プロセスをまたいだ瞬間に一意性は簡単に壊れます。本記事では、C# で共通XMLファイル OrderCount.xml を安全に更新し、複数プロセスからでも重複しない注文番号を払い出すための設計と実装、さらにテスト方法までを一気に整理します。

目次

複数アプリが同じXMLを更新するときに起こる問題

今回の前提は次のようなシナリオです。

  • 同一PC上で、同じC#プログラムが最大10プロセス程度動作する
  • すべてのプロセスが、共通の OrderCount.xml を読み書きして注文番号を発行する
  • XMLには <CURRENTVALUE value="..." /> のような要素があり、ここをインクリメントしていく
  • 絶対条件:番号が重複しない(一意である)こと

単純に考えると、

  • ファイルを開く
  • XMLを読み取る
  • value を+1する
  • XMLを書き戻す

という4ステップですが、ここに「同時に複数プロセスが動く」という条件が加わることで、次のような競合が起こります。

  • プロセスAとBがほぼ同時に現在値「100」を読み取る
  • AもBも「+1して101」を書き戻す
  • 結果として「101」が2回払い出される(重複)

この悲劇を防ぐために必要なのが、

  • プロセス内の排他(lock)
  • プロセス間の排他(Mutex)
  • ファイルレベルの排他(FileShare.None など)

です。ここをきちんと整理してから設計すると、トラブルの大半を事前に潰せます。

lock と Named Mutex とファイル排他の違い

まずは、よく混同される3つの仕組みを表で整理します。

機能対象範囲使いどころメリット注意点
lock(Monitor)同一プロセス内のスレッド間同一アプリ内でのスレッド安全性確保軽量・高速・書きやすい別プロセスにはまったく効かない
Named Mutex同一PC上の複数プロセス(+セッションを跨ぐことも可)同じマシン上で動く複数アプリ間の同期OSカーネルオブジェクトで管理されるため確実別PCとは共有されない。ネットワーク越しには効かない
ファイル排他(FileShare)ファイルを開くすべてのプロセス(ローカル・共有フォルダ)同じファイルを触る処理の同期SMB共有越しでも有効。ファイルを「ロック」として使える実装を誤ると「読み取った値」と「書き込んだ値」がズレる

結論として、この記事の要件(同じXMLファイルに対して複数プロセスで採番)は、

  • ファイル排他をベースにする
  • 必要に応じて Named Mutex で「待ち時間の制御」を追加する

という構成が最もシンプルかつ堅牢です。

結論:最小構成で確実に番号を一意にするには

本記事でおすすめする基本パターンは次のとおりです。

  1. ファイルを排他的に開く(FileShare.None)
  2. そのファイルハンドルの中で、
    • XML読み取り
    • 最新値を確認
    • インクリメント
    • 同じハンドルで上書き保存+Flush
  3. ファイルを開けなかったら IOException をキャッチしてリトライ
  4. より細かく制御したい場合は、OSの名前付きミューテックスを併用して
    • Mutexで待機+タイムアウトを実装
    • Mutexを取れたプロセスだけがファイルI/Oを行う

イメージとしては、

  • ファイルを開いた瞬間に「カギ」を占有
  • 採番処理が終わるまでカギを手放さない
  • カギが空いていなかったら、しばらくしてからまた来る

という流れです。ファイルを開いている間そのものがミューテックスとして機能していると考えると分かりやすいでしょう。

C#実装例:ファイル排他+リトライ(基本形)

まずは、ファイル排他だけで完結する基本クラスの例です。XMLファイルから次の番号を取得し、成功すれば long を返します。


using System;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using System.Xml.Linq;

public static class OrderNumberStore
{
    private static readonly string FilePath = 
        @"C:\Test\CommonFolder\OrderCount.xml";

    private const int MaxRetryAttempts = 5;
    private static readonly TimeSpan RetryDelay = TimeSpan.FromMilliseconds(500);

    // 非同期:採番して返す。失敗時は null を返す。
    public static async Task&lt;long?&gt; TryGetNextAsync(
        CancellationToken ct = default)
    {
        for (int attempt = 1; attempt &lt;= MaxRetryAttempts; attempt++)
        {
            ct.ThrowIfCancellationRequested();

            try
            {
                // 完全排他(他プロセスに共有させない)
                using var fs = new FileStream(
                    FilePath,
                    FileMode.Open,
                    FileAccess.ReadWrite,
                    FileShare.None,
                    4096,
                    FileOptions.WriteThrough);

                var doc = XDocument.Load(fs);
                var currentAttr = doc.Root?
                    .Element("COUNTER")?
                    .Element("CURRENTVALUE")?
                    .Attribute("value")
                    ?? throw new InvalidOperationException(
                        "CURRENTVALUE が見つかりません。");

                var next = checked(long.Parse(currentAttr.Value) + 1);
                currentAttr.Value = next.ToString();

                // 同一ハンドルで上書き
                fs.Position = 0;
                fs.SetLength(0);
                doc.Save(fs);
                fs.Flush(true);

                return next;
            }
            catch (IOException)
            {
                if (attempt == MaxRetryAttempts)
                {
                    // タイムアウト扱い
                    return null;
                }

                await Task.Delay(RetryDelay, ct);
            }
        }

        return null;
    }

    // 同期コードから呼ぶ場合のラッパー
    public static long GetNextOrThrow()
    {
        var n = TryGetNextAsync().GetAwaiter().GetResult();
        if (n is null)
        {
            throw new TimeoutException(
                "採番に失敗しました(ファイル占有・タイムアウト)。");
        }

        return n.Value;
    }
}

この実装で押さえているポイント

  • FileShare.None で完全排他
    • 他プロセスが同じファイルを開こうとすると IOException が発生
    • その例外を捕捉してリトライすることで、「順番待ち」を実現
  • 読み取り~書き戻しを同じハンドルで行う
    • XMLを読み取る → インクリメント → Position = 0 → SetLength(0) → Save → Flush(true)
    • この間はずっと同じ FileStream がファイルを握り続けるので、他プロセスは割り込めない
  • long 型で桁あふれを回避
    • 将来10億件などを発行する可能性を考えると、int では心許ない
    • checked を使ってオーバーフローを検出
  • リトライ回数と間隔で負荷を調整
    • MaxRetryAttempts と RetryDelay を設定値として外出しすれば、本番環境での調整も容易
    • 高負荷時のスパイクを抑えるために、指数バックオフ(500ms → 1s → 2s…)にするのも一案

一時ファイル+アトミック置換でさらに堅くする

ファイルシステムによっては、書き込み途中の状態が見えることを極端に嫌う場合があります。そのときは、

  1. doc.Save(tempFile) で一時ファイルに書く
  2. File.Replace(tempFile, FilePath, backupFile) で置換

という「アトミックリネーム」パターンが使えます。このときも、呼び出し元ではファイル排他を取得した状態で行うことが重要です。

Named Mutex+ファイル排他で「待ち時間」をコントロールする

上の実装だけでも一意性は守れますが、

  • UIから見て「どのくらい待たされているか」を制御したい
  • 一定時間以上待つくらいなら「混雑中エラー」を返したい

といった要件がある場合は、Named Mutex を併用すると設計がきれいになります。

Mutex は「ロビーの入場制限」のような役割を担い、その中に入れたプロセスだけが前述の OrderNumberStore の処理(ファイル排他を伴う採番)を実行するイメージです。


using System;
using System.Threading;

public static class OrderNumberWithMutex
{
    // 共有名は不変・一意に(GUID やフルパスのハッシュなど推奨)
    // セッションを跨ぎたい場合は Global\ プレフィックスを付ける
    private static readonly Mutex Mtx =
        new Mutex(false, @"Global\OrderCounterMutex");

    public static long GetNextWithMutex(TimeSpan wait)
    {
        var acquired = false;

        try
        {
            try
            {
                // 指定時間だけ待機。false が返ったらタイムアウト。
                acquired = Mtx.WaitOne(wait);
            }
            catch (AbandonedMutexException)
            {
                // Mutex を握っていたプロセスが異常終了した場合でも
                // 所有権は取得できたものとみなして処理を続行する
                acquired = true;
            }

            if (!acquired)
            {
                throw new TimeoutException(
                    "採番ロックを取得できませんでした。");
            }

            // Mutex を取れたプロセスだけがファイル排他付きの採番を行う
            return OrderNumberStore.GetNextOrThrow();
        }
        finally
        {
            if (acquired)
            {
                Mtx.ReleaseMutex();
            }
        }
    }
}

Mutex併用パターンの設計ポイント

  • UIへ返すエラーを明確にできる
    • Mutexの WaitOne がタイムアウトしたら「現在混雑中です。再度お試しください」といったメッセージを返せる
    • 「I/O例外が起きたので失敗」よりもユーザーにとって分かりやすい
  • Named Mutex はPC内限定
    • 同じPC上のプロセス間では有効
    • 別PCから共有フォルダ越しに同じXMLを更新する構成では、Mutexは効かない
    • その場合はあくまでファイル排他が真の同期原語となる
  • AbandonedMutexException の扱い
    • 異常終了したプロセスがMutexを握ったままだったケース
    • 例外を検知した上で、XMLファイルが壊れていないか後段の処理でチェックするのが望ましい

読み取り専用のときの考え方

採番処理以外に、「現在のカウンタ値を画面に表示したい」といった読み取り専用アクセスも考えられます。このときの方針は次の2パターンです。

方針共有モードメリット想定用途
可用性優先FileShare.Read or ReadWrite書き込み中でも読み取りエラーになりにくいダッシュボード表示など「多少古くてもよい」用途
整合性優先書き込み側が「一時ファイル→置換」を採用常に完全なXMLスナップショットのみ見える監査ログ、帳票の元データなど

採番のように一意性が絶対条件な処理では、更新側の実装を厳密にすることが最も重要です。その上で、読み取り側は要件に応じて「可用性優先」か「整合性優先」かを選びます。

番号の重複が本当に出ないかを確認するテストシナリオ

設計が良さそうに見えても、実際に負荷をかけてみると想定外の条件で不具合が出ることがあります。ここでは、最低限やっておきたいテストパターンを紹介します。

負荷テスト:複数プロセスから一気に採番する

次のようなテスト用コンソールアプリを用意します。

  • 同じ実行ファイルを3~10プロセス同時起動
  • 各プロセスで GetNext... を N回(例:5000回)呼ぶ
  • 取得した番号を orders_<pid>.log に追記していく

番号を書き出す部分は例えば次のようになります。


using System;
using System.Diagnostics;
using System.IO;

class Program
{
    static void Main()
    {
        var pid = Process.GetCurrentProcess().Id;
        var logPath = $@"C:\Test\Logs\orders_{pid}.log";

        const int count = 5000;

        using var writer = new StreamWriter(logPath, append: false);

        for (int i = 0; i &lt; count; i++)
        {
            var no = OrderNumberWithMutex.GetNextWithMutex(
                TimeSpan.FromSeconds(5));

            writer.WriteLine(no);
        }
    }
}

結果の検証:Distinct件数と総件数を比較する

全プロセスのログファイルを1つにまとめてから、重複がないかをチェックします。PowerShell なら次のように確認できます。


Get-Content .\orders_*.log |
    Tee-Object -FilePath all_orders.log |
    Group-Object |
    Where-Object { $_.Count -gt 1 }

このコマンドで何も表示されなければ、重複番号は発生していません。さらに、

  • all_orders.log の行数(総発行数)
  • XMLの最終 <CURRENTVALUE value="..." />

を比較し、「初期値 + 総発行数 = 最終値」になっていることも確認します。

障害注入テスト:異常終了に耐えられるか

現場で起こりがちなトラブルを再現するために、意図的に障害を注入するテストも重要です。

  • 採番直後にプロセスを強制終了する(タスクマネージャで Kill)
  • 電源断を模して、I/Oが集中しているタイミングでVMを強制停止する

その後、次の観点で確認します。

  • 次回以降の OrderNumberWithMutex 呼び出しが正常に動作するか
  • AbandonedMutexException を適切に処理できているか
  • XMLが壊れておらず、読み込みエラーにならないか
  • (一時ファイル方式の場合)中途半端なファイルが残っていないか

ここまで確認しておくと、本番運用中に「まれに番号が飛ぶ」「たまにXMLが壊れる」といった気持ち悪い障害に悩まされる可能性が大きく下がります。

よくある落とし穴とアンチパターン

実際のプロジェクトで見かけがちな「やってはいけない実装」と、その対策をまとめます。

落とし穴内容なぜ危険か対策
lock だけで採番クラスのメソッドに lock をかけて安心してしまう別プロセスから同時に呼ばれた場合はまったく同期されない必ず Named Mutex かファイル排他を併用する
読み取りと書き込みで別ハンドル読み取り用 FileStream と書き込み用 FileStream を別々に開く読み取り~書き込みの間に他プロセスが割り込めてしまう同じ FileStream の中で読み取り~書き込みまで完結させる
SetLength(0) 後に Position を戻さないfs.SetLength(0) のみで Position=0 を忘れる保存位置が意図せずズレる可能性がある必ず fs.Position = 0; をセットしてから保存
例外時にリトライしないIOException が出たら即エラーとして扱う一瞬誰かがファイルを掴んでいただけでも処理が落ちるリトライ回数と待機時間を決めて再試行する
型の選択ミスint や short を使ってしまう長期運用でオーバーフローし、番号が負の値になるなどの問題long を使用し、checked でオーバーフローを検出
多拠点更新をXMLで頑張る複数拠点・複数サーバから同じ共有フォルダのXMLを更新ネットワーク障害・レイテンシ・キャッシュなどで設計がかなり複雑に素直にDBの自動採番(IDENTITY / SEQUENCE)へ移行を検討

将来スケールさせたい場合の選択肢:DB採番へ移行

XMLファイルを使った採番は、「1台のPCで小規模運用」「拠点内だけで完結」といった条件では手軽で有効です。しかし、

  • アプリケーションサーバが複数台になる
  • クラウド上のコンテナやVMにスケールアウトしたい
  • 拠点間で番号を共通化したい

といった要件が出てきた時点で、XMLによる採番は限界が近づきます。このフェーズでは、次のような手段への移行を真剣に検討すべきです。

  • SQL Server の IDENTITY 列
  • SEQUENCE オブジェクトによる採番
  • SQLite や PostgreSQL などのオートインクリメント機能

データベースは、そもそも同時実行性とトランザクションのために設計されています。複数アプリからの採番が本格的に重要になる局面では、XMLからDBへの移行は「実装の健全化」と「将来の拡張性」を同時に満たしてくれる選択肢です。

まとめ:今日から見直せる採番実装チェックリスト

最後に、本記事の内容をチェックリストとして整理します。自分のプロジェクトの実装に照らし合わせてみてください。

  • 採番に使っている同期手段は、
    • 同一プロセス内のみなら lock
    • プロセス間なら Named Mutex or ファイル排他
    • ネットワーク越しならファイル排他 or DB採番
    • …と要件に合ったものを選べているか
  • XMLの読み取り→インクリメント→書き戻しを、
    • 同じ FileStream の中で完結させているか
    • FileShare.None で他プロセスの割り込みを防いでいるか
  • IOException に対して、
    • リトライ回数と待機時間(できれば指数バックオフ)を設けているか
    • リトライ上限に達したときのエラー応答を設計しているか
  • Mutexを使う場合、
    • 名前空間(Global\ 等)を正しく指定しているか
    • AbandonedMutexException を適切に処理しているか
  • 型と値域について、
    • 採番の型を long にしているか
    • オーバーフロー検出(checked)を行っているか
  • テストについて、
    • 複数プロセスからの負荷テストを実施したか
    • Distinct件数と総件数、XML最終値を突き合わせているか
    • 障害注入(強制終了・電源断)テストを行っているか
  • 将来のスケールについて、
    • サーバ台数や拠点数が増えたときの採番戦略を考えているか
    • 必要に応じてDB採番への移行方針を用意しているか

複数アプリから共通XMLを更新する採番は、一見小さなテクニックに見えますが、やり方を誤ると数字が重複したり、ファイルが壊れたりと、ビジネスロジックの根幹に関わる問題を引き起こします。本記事で紹介した「ファイル排他+リトライ」をベースに、必要に応じて Named Mutex やDB採番を組み合わせ、シンプルかつ堅牢な番号払い出し基盤を設計してみてください。

この記事を書いた人

実務の現場で詰まりがちなポイントを地図にするITブログ「IT trip」を運営。Windows/Office(Teams・Excel)からSQL、サーバ運用、ガジェットまで、再現性のある手順と“なぜそうなるか”を丁寧に解説します。読んだらすぐ試せること、そして迷った人の次の一歩が見えることを大切にしています。

コメント

コメントする

目次