モバイルゲームのローカルキャッシュが壊れた!アトミック書き込みと3層防御で解決した話

目次

はじめに

モバイルゲームでは、サーバーから取得したマスタデータ(キャラクターのパラメータ、イベント情報、スキル定義など)を端末上のローカルストレージにキャッシュするのが一般的です。毎回サーバーからダウンロードするよりも、起動速度と通信コストの面で大きなメリットがあるためです。

しかし、ある日を境にリリース済みのアプリ上で Null 参照が原因となり、一部のユーザーから特定の画面に進めないという報告が徐々に増えていきました。

調査を進めると、原因はローカルに保存されたマスタデータの破損でした。

幸い、今回の影響範囲は限定的でした。破損していたのはイベントを管理するマスタデータのみで、ゲーム全体が起動不能になるような事態には至りませんでした。ユーザーにキャッシュクリアと再ダウンロードをお願いすることで復旧でき、永続的な進行不能にはなりませんでした。

しかし、この「たまたま軽傷で済んだ」という事実がかえって危機感を強めました。改めて状況を整理すると、以下の問題が浮き彫りになりました。

  • ユーザーに手動操作を強いている ― キャッシュクリアの手順を案内すること自体がUXの低下である
  • 今回はたまたま軽傷で済んだ ― タイトル画面から進行に必要なデータが破損していたら、問い合わせ対応のコストは桁違い
  • 再発防止の仕組みがない ― 同じ問題をいつ繰り返してもおかしくない

そこで、マスタデータの破損防止・検知・リカバリを体系的にカバーする恒久対応を実装しました。

本記事では、その設計と実装、そして開発過程で踏んだ落とし穴について紹介します。

なぜマスタデータは壊れるのか

ローカルに保存されたファイルが破損する主な原因は以下の3つです。

書き込み途中のクラッシュ

ファイルの書き込みは原子的(atomic)な操作ではありません。File.WriteAllBytes で書き込み中にアプリがキルされたり、OSが強制終了したりすると、ファイルの内容が中途半端な状態で残ります。

正常: [ヘッダ][データ本体][フッタ] → 完全なファイル
異常: [ヘッダ][データの一部...       → 途中で切れたファイル

スレッド競合

マスタデータのダウンロードを並列で行っている場合、複数スレッドから同じキャッシュ辞書へ同時にアクセスすると、データ構造の整合性を損なう可能性があります。Dictionary<TKey, TValue> はスレッドセーフではないため、並行書き込みにより内部状態が破損し得ます。

データとハッシュの不整合

暗号化されたマスタデータと、その検証用ハッシュファイルをペアで管理している場合、片方だけ更新された状態でクラッシュすると、データ自体は正常でもハッシュ検証に失敗します。

設計方針:3層防御

これらの問題に対して、破損防止・検知・リカバリの3層で防御する設計を採用しました。

3層防御のアーキテクチャ図

では、なぜ3層すべてが必要なのでしょうか。それは、どの1層だけでも対策として不十分だからです。

  • 破損防止だけ → どんなに防いでも、OSレベルのクラッシュや電源断は防げません
  • 検知だけ → 壊れたことに気づいても、復旧手段がなければユーザーは詰みます
  • リカバリだけ → 破損に気づかずに壊れたデータを読み続ける可能性があります

3層をセットにすることで、「壊さない」「壊れたら気づく」「気づいたら直す」の全段階をカバーできます。

第1層:破損防止

アトミック書き込み

ファイル破損の最大の原因は「書き込み途中のクラッシュ」です。

担当プロジェクトでは、ローカルのマスタデータを更新する際に、既存のファイルを直接上書きする処理になっていました。この方式では、書き込みの途中でクラッシュが発生すると、元のデータも新しいデータも失われてしまいます。

これに対して、一時ファイルに書き込んでからリネームで置換するパターンを採用しました。

// 保存処理の概要
public void SaveEncrypted(byte[] data, string filePath)
{
    var tempPath = filePath + ".tmp";

    // 1. 一時ファイルに書き込む
    File.WriteAllBytes(tempPath, data);

    // 2. リネームで置換する
    AtomicReplace(tempPath, filePath);
}

AtomicReplace の実装は以下の通りです。

/// <summary>
/// 一時ファイルを本番ファイルに置換する(Delete + Move 方式)
/// </summary>
/// <remarks>
/// Delete と Move の間でクラッシュした場合はファイルが消失しますが、
/// 起動時のクリーンアップおよび整合性検証で検知・再ダウンロードされます。
/// </remarks>
public void AtomicReplace(string tempPath, string destPath)
{
    if (File.Exists(destPath))
    {
        File.Delete(destPath);
    }
    File.Move(tempPath, destPath);
}

補足: Delete + Move は厳密にはアトミックな操作ではなく、2ステップの間でクラッシュするとファイルが消失する可能性があります。この点については後述の「落とし穴と学び:File.Replace の代わりに Delete + Move を選んだ理由」で詳しく触れます。

このパターンにより、書き込み途中でクラッシュしても .tmp ファイルが壊れるだけで、本番ファイルは無傷です。

ハッシュファイルも同様に .tmp 経由で書き込みます。

// データファイルのアトミック書き込み
File.WriteAllBytes(tempPath, encryptedData);
AtomicReplace(tempPath, filePath);

// ハッシュファイルも同じパターン
var hashTempPath = filePath + ".hash.tmp";
File.WriteAllText(hashTempPath, computedHash);
AtomicReplace(hashTempPath, filePath + ".hash");

補足:非同期ストリーム書き込みの場合

FileStream を使った非同期書き込みでは、using ブロックでストリームを確実に閉じてからリネームする必要があります。これについては後述の「落とし穴」セクションで詳しく触れます。

スレッドセーフ化

マスタデータのキャッシュを管理する辞書を Dictionary から ConcurrentDictionary に変更しました。

Dictionary はスレッドセーフではないため、複数スレッドから同時に読み書きが行われると辞書自体の内部データ構造(バケットやハッシュテーブル)が破損し、予期しない例外やデータの消失が発生し得ます。ConcurrentDictionary は公式ドキュメントで「複数のスレッドが同時にアクセスできるキーと値のペアのスレッドセーフなコレクション」と説明されています。内部的には書き込み操作に細粒度のロックを使用し、辞書の構造を保護します。

// Before: スレッドセーフではない
private Dictionary<string, CacheEntry> _cache;

// After: 並行アクセスに対応
private ConcurrentDictionary<string, CacheEntry> _cache;

注意: ConcurrentDictionary は辞書の内部構造の安全性は保証しますが、AddOrUpdateGetOrAdd に渡すデリゲートはロック外で実行されるため、デリゲート内の処理はアトミックではありません。今回の用途ではデリゲート内でキャッシュオブジェクトの生成・更新のみを行っているため問題になりませんが、デリゲート内で外部リソースへの書き込みなど副作用のある処理を行う場合は注意が必要です。

キャッシュの更新には AddOrUpdate を使用します。

_cache.AddOrUpdate(
    key,
    _ => new CacheEntry { Data = newData },
    (_, existing) =>
    {
        existing.Data = newData;
        return existing;
    });

AddOrUpdateupdateValueFactory はロック外で複数回呼び出される可能性がある点は、後述の「落とし穴」セクションで補足します。

起動時クリーンアップ

アプリ起動時に、前回クラッシュで残った .tmp ファイルを検知・削除し、対応するデータファイルの整合性を検証します。

public void CleanupOnStartup(string directory)
{
    // 1. 残存する .tmp ファイルを削除
    foreach (var tmpFile in Directory.GetFiles(directory, "*.tmp"))
    {
        var originalPath = tmpFile.Replace(".tmp", "");
        File.Delete(tmpFile);

        // 2. 対応するデータファイルの整合性を検証
        if (File.Exists(originalPath))
        {
            var result = VerifyIntegrity(originalPath);
            if (result != CacheIntegrity.Valid)
            {
                Debug.LogWarning(
                    $"前回クラッシュの影響でデータが不整合の可能性: "
                    + $"{originalPath}{result}");
            }
        }
    }

    // 3. 孤立したハッシュファイルも検知・削除
    foreach (var hashFile in Directory.GetFiles(directory, "*.hash"))
    {
        var dataPath = hashFile.Replace(".hash", "");
        if (!File.Exists(dataPath))
        {
            Debug.LogWarning($"孤立ハッシュファイルを削除: {hashFile}");
            File.Delete(hashFile);
        }
    }
}

.tmp が残っているということは「前回の保存が正常に完了しなかった」ことを意味します。その場合、対応するデータファイルが Delete → Move の間で消失している可能性があるため、整合性検証を走らせます。

第2層:検知

整合性状態の定義

マスタデータの整合性を7つの状態で明示的に分類する enum を定義しました。

/// <summary>
/// ローカルキャッシュの整合性チェック結果
/// </summary>
public enum CacheIntegrity
{
    Valid,             // 正常
    FileNotFound,      // ファイルが存在しない
    HashFileNotFound,  // ハッシュファイルが存在しない
    EmptyFile,         // ファイルサイズが0
    ReadFailed,        // ファイル読み込みに失敗(I/Oエラー)
    DecryptionFailed,  // 復号に失敗
    HashMismatch,      // ハッシュ値が不一致
}

これにより、単なる「正常/異常」の2値ではなく、なぜ異常なのかを判別でき、障害調査やリカバリ方針の判断に活かせます。

Verifyフローチャート

VerifyAndRead:検証と読み込みの一体化

初期実装では、検証メソッドでファイルを読み込み・復号してハッシュ検証を行い、その後ロードメソッドで再度同じファイルを読み込み・復号していました。これには2つの問題がありました。

  1. 2重I/O ― 同じファイルの読み込みと復号を2回行う無駄
  2. TOCTOU(Time of Check to Time of Use) ― 検証とロードの間にファイルが変更される理論上のリスク

これを解決するため、検証と読み込みを1回のI/Oで完結するメソッドを導入しました。

/// <summary>
/// 整合性検証とデータ読み込みを1回のI/Oで完結する
/// </summary>
public (CacheIntegrity integrity, string json) VerifyAndRead(
    string filePath, ICryptoProvider crypto)
{
    if (!File.Exists(filePath))
        return (CacheIntegrity.FileNotFound, null);

    // ハッシュファイルの存在チェック
    var hashPath = filePath + ".hash";
    if (!File.Exists(hashPath))
        return (CacheIntegrity.HashFileNotFound, null);

    // 1回の ReadAllBytes で読み込み
    byte[] fileBytes;
    try
    {
        fileBytes = File.ReadAllBytes(filePath);
    }
    catch (Exception e)
    {
        Debug.LogWarning($"ファイル読み込み失敗: {e.GetType().Name}: {e.Message}");
        return (CacheIntegrity.ReadFailed, null);
    }

    if (fileBytes.Length == 0)
        return (CacheIntegrity.EmptyFile, null);

    // 復号(1回だけ)
    string json;
    try
    {
        json = crypto.Decrypt(fileBytes);
    }
    catch
    {
        return (CacheIntegrity.DecryptionFailed, null);
    }

    if (string.IsNullOrEmpty(json))
        return (CacheIntegrity.EmptyFile, null);

    // ハッシュ照合
    var savedHash = File.ReadAllText(hashPath).Trim();
    var computedHash = ComputeHash(json);
    if (savedHash != computedHash)
        return (CacheIntegrity.HashMismatch, null);

    return (CacheIntegrity.Valid, json);
}

検証のみを行うメソッドは、このメソッドのラッパーとして実装します。検証ロジックが1箇所に集約されるため、変更時に修正漏れが起きません。

public CacheIntegrity VerifyIntegrity(string filePath, ICryptoProvider crypto)
{
    return VerifyAndRead(filePath, crypto).integrity;
}

第3層:リカバリ

Load 失敗時の再ダウンロード誘導

ロードメソッドを強化し、整合性検証に失敗した場合は破損ファイルを削除して default を返すようにしました。呼び出し元は null を受け取ると再ダウンロードフローに遷移します。

public T Load<T>(string filePath, ICryptoProvider crypto) where T : class
{
    var (integrity, json) = VerifyAndRead(filePath, crypto);

    if (integrity != CacheIntegrity.Valid)
    {
        // 診断情報をログ出力
        LogDiagnostics(filePath, integrity);

        // 破損ファイルを安全に削除(データ + ハッシュをペアで)
        SafeDelete(filePath);
        SafeDelete(filePath + ".hash");

        return default; // null → 呼び出し元が再ダウンロードを開始
    }

    return JsonUtility.FromJson<T>(json);
}

以前は、破損に気づかず壊れたデータをデシリアライズしようとして NullReferenceException が発生していました。この改修により、破損検知→ファイル削除→再ダウンロードが自動で行われるようになり、ユーザーへ手動のキャッシュクリアをお願いする必要がなくなりました。

破損ファイルの安全な削除

破損ファイルの削除では、データファイルとハッシュファイルをペアで処理します。File.Delete が例外を投げてもアプリが落ちないよう、try-catch で囲みます。

private void SafeDelete(string path)
{
    try
    {
        if (File.Exists(path)) File.Delete(path);
    }
    catch (Exception e)
    {
        Debug.LogWarning($"ファイル削除失敗: {path} - {e.Message}");
    }
}

保存リトライ

一時的なI/Oエラー(ストレージのビジー状態など)に対しては、100ms後に1回だけリトライを行います。リトライしても失敗した場合は、次回のデータ読み込み時に整合性検証で検知され、再ダウンロードで復旧するため、過剰なリトライは行いません。

private const int RetryDelayMs = 100;

try
{
    await SaveToStorageAsync();
}
catch (Exception e)
{
    Debug.LogWarning($"保存失敗、{RetryDelayMs}ms後にリトライ: {e.Message}");
    await UniTask.Delay(RetryDelayMs);
    await SaveToStorageAsync();
}

落とし穴と学び

開発過程で遭遇した、ドキュメントだけでは気づきにくい落とし穴を紹介します。

File.Replace の代わりに Delete + Move を選んだ理由

アトミック書き込みの初期実装では File.Replace() を使用していました。

// 最初の実装
File.Replace(tempPath, destPath, backupPath);

File.Replace はバックアップファイルの作成を含むOSレベルの原子的置換をサポートするメソッドです。

しかし、File.Replace の内部では「既存ファイルをバックアップにハードリンク → ソースをリネームで置換」という2ステップが走る実装になっています(参考:.NET Runtime - FileSystem.Unix.cs ReplaceFile)。今回のケースではバックアップファイルの管理は不要だったため、よりシンプルな Delete + Move を採用しました。

// 最終的な実装
if (File.Exists(destPath))
{
    File.Delete(destPath);
}
File.Move(tempPath, destPath);

DeleteMove の間でクラッシュした場合はファイルが消失しますが、この状態は整合性検証で FileNotFound として検知され、再ダウンロードで復旧します。バックアップ管理の複雑さよりも、シンプルな実装+検知で確実にリカバリできることを優先した判断です。

FileStream の二重 Dispose

非同期ストリーム書き込みで、await using 宣言と手動の DisposeAsync() を併用すると二重Disposeが発生します。

// NG: 二重 Dispose
await using var stream = new FileStream(tempPath, FileMode.Create);
await stream.WriteAsync(data);
await stream.DisposeAsync(); // using がスコープ終了時にもう一度呼ぶ
AtomicReplace(tempPath, filePath);

.NETの Dispose は冪等(複数回呼んでも安全)が前提なので、これ自体が例外や実害を引き起こすことはありません。しかし、ストリームの寿命が using 宣言と手動呼び出しの2箇所に分散し、コードの意図が不明瞭になります。明示的な using ブロックでスコープを明確にし、ストリームが閉じた後にリネームする形へ統一しました。

// OK: 明示的ブロック
await using (var stream = new FileStream(tempPath, FileMode.Create))
{
    await stream.WriteAsync(data);
} // ここで確実にストリームが閉じる

AtomicReplace(tempPath, filePath); // ストリームが閉じた後にリネーム

ConcurrentDictionary.AddOrUpdate の罠

ConcurrentDictionaryAddOrUpdate メソッドには、直感に反する挙動があります。

AddOrUpdate は「キーが存在しなければ追加、存在すれば更新」を行うメソッドです。追加時・更新時にそれぞれファクトリ(値を生成する関数)を渡します。

_cache.AddOrUpdate(
    key,
    // キーが存在しない場合 → この関数で値を生成して追加
    _ => new CacheEntry { Data = newData },
    // キーが既に存在する場合 → この関数で新しい値を生成して更新
    (_, existing) => new CacheEntry { Data = newData }
);

ここで注意すべきは、これらのファクトリは複数回呼ばれうるという点です。

複数スレッドが同じキーに対して同時に AddOrUpdate を呼ぶと、あるスレッドがファクトリで値を生成しても、別スレッドが先に更新を完了していた場合、生成した値は使われずに再試行されます。この仕様は Microsoft公式ドキュメント にも明記されています。

addValueFactory デリゲートと updateValueFactory デリゲートは、値が期待どおりに追加または更新されたことを確認するために複数回実行できます。 ただし、ロックの下で不明なコードを実行することによって発生する可能性のある問題を回避するために、ロックの外部で呼び出されます。」

ファクトリが複数回呼ばれても最終的に採用されるのは1つだけで、残りは破棄されます。機能的には問題ありませんが、上記のコード例のようにファクトリ内で毎回 new していると、使われないオブジェクトが無駄に生成されてGC負荷が増えることになります。

そこで、更新時は新しいオブジェクトを作らず、既存インスタンスのプロパティを書き換えて返すパターンに変更しました。

// 既存インスタンスを再利用
_cache.AddOrUpdate(
    key,
    _ => new CacheEntry { Data = newData },
    (_, existing) =>
    {
        existing.Data = newData;
        return existing;
    });

孤立ハッシュファイル

破損ファイルの削除処理で、データファイルを削除した後、ハッシュファイルを削除する前にクラッシュすると、ハッシュファイルだけが残る状態が発生します。

正常: cache.dat + cache.dat.hash (ペアで存在)
異常: cache.dat.hash のみ         (データが消失、ハッシュだけ残存)

この状態は次回の整合性検証で FileNotFound になるため実害はありませんが、ゴミファイルとして残り続けます。起動時のクリーンアップで孤立ハッシュファイルも検知・削除するようにしました。

破損検知ログに診断情報を入れる

破損を検知したとき、「ハッシュが不一致でした」だけではサーバーサイドから見たときに原因の切り分けが困難です。

private void LogDiagnostics(string filePath, CacheIntegrity result)
{
    var info = new FileInfo(filePath);
    var size = info.Exists ? info.Length : -1;
    var lastWrite = info.Exists ? info.LastWriteTimeUtc.ToString("o") : "N/A";

    Debug.LogError(
        $"キャッシュ破損検知: {Path.GetFileName(filePath)}"
        + $" | 状態={result} | サイズ={size} | 更新日時={lastWrite}");
}

ファイルサイズと最終更新日時を含めることで、「0バイト=書き込み途中」「サイズは正常だがハッシュ不一致=部分書き換え」といった推測が可能になります。

検証を支えるツール

3層防御を実装しても、「本当に正しく動作するのか」を確認する手段がなければ信頼できません。QAチームやエンジニアが破損シナリオを再現してテストできるよう、2種類のツールを開発しました。

実機用デバッグコマンド

実機上で破損シナリオを再現するための5つのデバッグコマンドを追加しました。

コマンド 目的 テスト対象の層
整合性チェック 現在のデータが正常かを確認 検知
データ破損 ファイル内容を意図的に壊す 検知 → リカバリ
ハッシュファイル削除 ハッシュファイルのみ削除 検知 → リカバリ
ハッシュ値改竄 ハッシュ値を不正な値に書き換え 検知 → リカバリ
.tmp ファイル作成 クラッシュ後の残存 .tmp を再現 破損防止(起動時クリーンアップ)

これにより、QAチームは以下のシナリオテストを実機で実行できます。

  1. デバッグコマンドで「データ破損」を実行
  2. 該当画面に遷移してデータ読み込みを発生させる
  3. 破損が検知され、再ダウンロードが自動で走ることを確認

従来は「再現が難しい」とされていた破損系のバグを、意図的かつ繰り返し、安全な形で再現できるようになります。

Editor 診断ウィンドウ

Unity Editor 上で動作する診断ツールも開発しました。

Editor 診断ウィンドウ

主な機能は以下の通りです。

  • ファイル一覧表示 ― OK/NG ステータス、ファイルサイズ、更新日時を一覧で確認
  • 個別/全ファイル整合性チェック ― プログレスバー付きで検証する
  • 破損ファイル削除 ― NGのファイルだけを選択して削除
  • 診断レポート出力 ― タイムスタンプ付きのレポートファイルを生成

開発中に「データが壊れているかも?」と思ったときや、破損シナリオチェックをしたいときに、メニューからワンクリックで確認できる環境を整えました。

まとめ

本記事では、モバイルゲームにおけるマスタデータ破損に対して、破損防止・検知・リカバリの3層防御を実装した事例を紹介しました。

対策 効果
破損防止 アトミック書き込み、ConcurrentDictionary、起動時クリーンアップ 破損の発生確率を大幅に低減
検知 整合性ステータス enum、Verify/VerifyAndRead 破損を確実に検知し、原因を分類
リカバリ null 返却→再DL、破損ファイル削除、保存リトライ 破損時に自動で復旧

今回はイベントマスタの破損で済みましたが、タイトル画面から進行に必要なデータが破損していたら、問い合わせ対応のコストは桁違いだったはずです。

完全に壊れないシステムを作ることはできません。OSレベルのクラッシュや電源断は、アプリケーション層からは制御できないからです。しかし、壊れても自動で検知し、自動で復旧するシステムは作れます。

「うちのアプリ、ローカルキャッシュの破損対策してたっけ…?」と思った方は、まず整合性検証の仕組みの導入から始めてみてはいかがでしょうか。


福田 晃久

株式会社サムザップ Unityエンジニア
2022年新卒でサイバーエージェント入社、サムザップ配属
『この素晴らしい世界に祝福を!ファンタスティックデイズ』
『呪術廻戦 ファントムパレード』の開発に従事