ファイル連携の排他制御の基礎知識 - ファイルロックと原子的 claim のベストプラクティス

· · ファイル連携, 排他制御, 設計, Windows開発

ファイル連携の排他制御は、共有フォルダや夜間バッチ、別プロセス連携でほぼ必ず問題になります。 特に検索で多いのは、ファイルロックだけで十分なのか、複数ワーカーが同じファイルを拾わない方法は何か、途中書き込みのファイルをどう避けるか、といった悩みです。

この記事では、ファイルロック、原子的 claim、temp -> rename、idempotency を軸に、ファイル連携の排他制御を見ていきます。

目次

  1. まず結論(ひとことで)
  2. ファイル連携で起きる競合パターン(図)
    • 2.1. 書き込み途中のファイルを読んでしまう
    • 2.2. 複数ワーカーが同じファイルを同時に拾う
    • 2.3. stale lock で全員が止まる
  3. アンチパターン
    • 3.1. Exists -> Create の二段階チェック
    • 3.2. 最終ファイル名へ直接書く
    • 3.3. ファイルサイズが止まったら完了扱い
    • 3.4. 共有ファイルをみんなで更新する
    • 3.5. ロックAPIを万能と思う
  4. ベストプラクティス
    • 4.1. temp -> close -> rename / replace で公開する
    • 4.2. done / manifest で完全性を明示する
    • 4.3. 受信側は claim を原子的に取る
    • 4.4. lock file に頼るなら lease にする
    • 4.5. idempotency を前提にする
  5. 擬似コード(抜粋)
  6. ざっくり使い分け
  7. まとめ
  8. 参考資料

ファイル連携は、コードそのものより「受け渡しの約束」のほうが壊れやすい分野です。 単体試験では通るのに、本番の共有フォルダや夜間バッチでだけたまに壊れる。しかも再現しづらい。わりと普通にあります。

原因の多くは、ファイルI/OのAPIそのものより、次の3つが曖昧なことです。

  • いつ読んでよいのか
  • 誰が処理権を持つのか
  • 失敗したときにどう回復するのか

この記事では、ファイル連携の排他制御を OS ロックの話だけで終わらせず、受け渡しプロトコルとして整理します。

なお、この記事に登場するコードは、ビルド・実行できるサンプル一式(ライブラリ、2 ワーカーの claim 競合や lease 引き取りを実演するデモ、競合・破損・stale lock を再現するユニットテスト)として GitHub で公開しています。

file-integration-locking-best-practices-komurasoft-style - komurasoft-blog-samples (GitHub)

1. まず結論(ひとことで)

  • ファイル連携で一番大事なのは、最終ファイル名が見えた時点で「もう読んでよい」状態を作ること
  • 生成中 / 公開済み / 処理中 / 処理済み を、ファイル名やディレクトリで分けて表現すること
  • 複数ワーカーがいるなら、読む前に claim を原子的に取ること
  • lock file や OS ロックは補助として使い、最後は idempotency で受け止めること

要するに、ファイル連携では 排他制御 というより 受け渡しプロトコル の設計が本体です。 ロック関数を1つ呼べば終わり、とはなりません。

2. ファイル連携で起きる競合パターン(図)

2.1. 書き込み途中のファイルを読んでしまう

最終ファイル名に直接書き始めると、この事故が起きます。 JSON なら閉じ括弧がなく、CSV なら行数が足りず、ZIP なら普通に壊れます。

受信側共有フォルダ送信側受信側共有フォルダ送信側まだ途中行数不足 / 解析失敗 / 一部だけ処理orders.csv を最終名で作成1行目〜5000行目を書き込み中orders.csv を検知そのまま読み始める残りを書き込む

2.2. 複数ワーカーが同じファイルを同時に拾う

「一覧を見て、未処理なら開く」という流れだと、同じファイルを2つのワーカーが掴めます。 二重計上や二重送信の始まりです。

incomingワーカー2ワーカー1incomingワーカー2ワーカー1同じ入力を二重処理a.csv を見つけるa.csv を見つける読み込み開始読み込み開始

2.3. stale lock で全員が止まる

lock file を置くだけの設計は、異常終了時に詰まりやすいです。 誰の lock か、まだ生きているのか、いつまで有効かが分からないと、後続が永遠に待つことになります。

ワーカーBlock ファイルワーカーAワーカーBlock ファイルワーカーAここで異常終了stale か判定できず全員停止lock を作成lock の存在を確認処理開始を見送るさらに待つ

3. アンチパターン

3.1. Exists -> Create の二段階チェック

これは、「確認」と「確保」が別操作 になっているのが問題です。 間に他プロセスが割り込めるので、排他になりません。

ファイルシステムプロセスBプロセスAファイルシステムプロセスBプロセスA両方が進めてしまうlock が無いか確認lock が無いか確認無い無いlock を作成lock を作成

典型的な悪い例は、こういう形です。

if (!File.Exists(lockPath))
{
    File.WriteAllText(lockPath, Environment.ProcessId.ToString());
    ProcessFile();
}

必要なのは、「無ければ作る」を 1操作にすること です。 .NET なら FileMode.CreateNew 系、POSIX 系なら O_CREAT | O_EXCL のような原子的作成を使います。

3.2. 最終ファイル名へ直接書く

受信側が「その名前が見えたら読んでよい」と解釈しているなら、最終ファイル名に直接書き始めた時点で負けです。 見えること と 読んでよいこと を同じにしないのが基本です。

final 名が見える受信側が検知送信側はまだ書き込み中不完全なデータを読む
using var writer = OpenForWrite(finalPath); // ここで finalPath が見えてしまう
foreach (var row in rows)
{
    writer.WriteLine(row);
}

このやり方は、2.1 の事故を自分から呼び込みます。

3.3. ファイルサイズが止まったら完了扱い

これは便利そうに見えますが、かなり危ういです。 ネットワーク越しのコピー、送信側の一時停止、バッファリング、リトライで普通に揺れます。

受信側共有フォルダ送信側受信側共有フォルダ送信側完了と誤判定data.zip をコピー開始途中で一時停止サイズが10秒変わらない読み込み開始コピー再開
if (currentLength == lastLength && stableSeconds >= 10)
{
    return Ready;
}

完了を 推測 で決めると、共有フォルダや大きなファイルで足をすくわれます。 完了は manifest や done file で 明示 した方が安定します。

3.4. 共有ファイルをみんなで更新する

1つの status.csvcounter.json をみんなで読んで更新する設計は、だいたい最後に書いた人が勝ちます。 ファイル連携を簡易DBとして使い始めると、ここで苦しくなります。

status.csvバッチBバッチAstatus.csvバッチBバッチAA の更新が消えるv1 を読むv1 を読むv2-A を書くv2-B を書く

append-only に逃げる案もありますが、ファイルシステムや配置形態で意味が揺れます。 共有更新が必要なら、ここはファイル連携で無理をしない方がよいです。

3.5. ロックAPIを万能と思う

ロックAPIは重要ですが、全参加者が同じ約束で動く ときだけ効きます。 異種システム連携では、ここを過信しない方が安全です。

補足:

  • Linux の flock は advisory lock なので、約束を無視する相手は普通に書けます
  • Windows の byte-range lock は、メモリマップファイルでは無視されます
  • つまり、OS ロック 単体で完了通知や所有権の設計まで背負わせない方がよいです

4. ベストプラクティス

4.1. temp -> close -> rename / replace で公開する

王道です。 生成中のファイルは temp 名に閉じ込め、close したあとで final 名に切り替えます。 受信側は final 名だけを見るようにします。

一意な temp 名を作るtemp に全内容を書き込むflush / close する同一ディレクトリで final 名へ rename / replace受信側は final 名だけを監視

ポイント:

  • temp と final は 同じディレクトリ、少なくとも同じボリューム / ファイルシステム に置く
  • Windows / .NET なら File.Replace 系を検討できる
  • final 名が見えた時点で、内容は完成済み という約束にする

temp を別ドライブに置くと、rename が単なるコピー相当になったり、Replace が失敗したりします。 この前提は地味ですが、とても大事です。

4.2. done / manifest で完全性を明示する

データ本体だけでなく、「何が完成したか」を別ファイルで明示すると、受信側が安定します。 特に異種システム連携では有効です。

data.tmp を生成data.csv に公開data.done / manifest.json を作成受信側が done / manifest を検知ファイル名・サイズ・ハッシュを検証

manifest に入れておきたいのは、このあたりの項目です。

  • 対象ファイル名
  • サイズ
  • ハッシュ
  • レコード数
  • 連携ID / idempotency key
  • 生成時刻

順序も大事です。 本体の公開より先に done を置くと、それは完了通知ではなく 事故予告 になります。

4.3. 受信側は claim を原子的に取る

複数ワーカーが同じ incoming を見るなら、「読む前に自分のものへ移す」のが分かりやすいです。 incoming から processing/<worker>/ への rename が成功したワーカーだけが処理します。

processingincomingワーカー2ワーカー1processingincomingワーカー2ワーカー1先に成功した方だけが所有権を取るa.csv を見つけるa.csv を見つけるa.csv を renamea.csv を rename

運用上は、ディレクトリも分けておくと追跡しやすいです。

publishclaim成功失敗tempincomingprocessingarchiveerror

claim 用の rename も、同じファイルシステム上で行うのが前提です。

4.4. lock file に頼るなら lease にする

lock file を使うなら、単なる空ファイルではなく 有効期限付きの所有情報 にします。 誰が取ったのか分からない lock は、後で必ず揉めます。

lock.jsonownerIdhostpidacquiredAtexpiresAtheartbeatAt

ポイント:

  • 作成は原子的に行う
  • 更新停止を stale 判定の材料にする
  • 削除は 原則として作成者だけ が行う
  • 解除漏れを前提に、回復手順を決めておく

lock file はあくまで 協調のための札 です。 これ1枚で完全な整合性まで保証しようとすると、だいたい厳しくなります。

4.5. idempotency を前提にする

排他制御は大事ですが、実運用では「たまに二重で来る」「途中で再実行する」をゼロにはできません。 最後は、同じ入力をもう一度食べても壊れない 設計が効きます。

はいいいえ入力 + idempotency key既処理か二重実行せず成功扱い処理を実行処理済み台帳に記録

たとえば、受信ファイルごとに連携IDを持たせ、処理済み台帳に記録します。 排他が一度破れても、結果が二重計上されない形にしておくと運用がかなり楽です。

5. 擬似コード(抜粋)

5.1. 典型的な失敗パターン

var lockPath = finalPath + ".lock";

if (!File.Exists(lockPath))
{
    File.WriteAllText(lockPath, "");
    using var writer = OpenForWrite(finalPath); // 最終名に直接書く
    WritePayload(writer);

    File.Delete(lockPath);
}

問題点は3つあります。

  • ExistsWriteAllText が別操作
  • finalPath が書き込み途中から見えてしまう
  • 異常終了時に lock が残る

5.2. 正しい方向の例(雑に書くとこう)

var tempPath = MakeTempPathSameDirectory(finalPath);
WritePayload(tempPath);
FlushAndClose(tempPath);

PublishByRenameOrReplace(tempPath, finalPath); // 同一FS / 同一volume 前提
PublishDoneFile(finalPath + ".done", new
{
    FileName = Path.GetFileName(finalPath),
    Size = GetFileSize(finalPath),
    Hash = ComputeHash(finalPath),
    IdempotencyKey = integrationId
});
if (!TryClaimBundleByRename(baseName, incomingDir, processingDir))
{
    return; // 他ワーカーが先に取得
}

var manifest = ReadDoneFile(Path.Combine(processingDir, baseName + ".done"));
VerifyPayload(Path.Combine(processingDir, baseName), manifest);

if (AlreadyProcessed(manifest.IdempotencyKey))
{
    MoveBundle(processingDir, archiveDir, baseName);
    return;
}

Process(Path.Combine(processingDir, baseName));
RecordProcessed(manifest.IdempotencyKey);
MoveBundle(processingDir, archiveDir, baseName);

このあたりは 実装の細部より順序 が大事です。 「書く」「公開する」「所有権を取る」「処理済みを記録する」を混ぜない方が壊れにくくなります。

6. ざっくり使い分け

  • 単一 writer / 単一 reader / 同一ホストなら、まずは temp -> rename だけでもかなり安定する
  • 複数 consumer がいるなら、incoming -> processing の claim rename を入れる
  • 異種システム連携、NAS、共有フォルダなら、manifest / done と idempotency まで入れた方が安全
  • 複数 writer が同じ論理状態を更新したいなら、ファイル連携で頑張りすぎず DB やキューも検討する
  • OS ロックは、同一アプリ群・同一前提の中では有効だが、受け渡しプロトコルの代わりにはならない

最後の1項目は撤退判断でもあります。 ファイルでやるとつらい問題は、本当にあります。

7. まとめ

ファイル連携の排他制御は、ロック関数を呼ぶことではなく状態遷移を決めること。これがこの記事の骨子です。生成中 / 公開済み / 処理中 / 処理済み を名前やディレクトリで表現し、Exists -> Create の二段階チェックや最終ファイル名への直接書き込み、サイズ安定待ち、共有ファイルの相互更新、ロックAPIへの過信を避ける。そのうえで temp -> close -> rename / replacedone / manifest、claim rename、lease と idempotency を組み合わせれば、共有フォルダ連携の事故はかなり防げます。

ファイル連携では「読めること」と「読んでよいこと」を同じにしないのがコツです。ここを分けるだけで、夜中にだけ出るタイプの事故がぐっと減ります。

8. 参考資料

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

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

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

著者プロフィール

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

小村 豪

合同会社小村ソフト 代表

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

ブログ一覧に戻る