マガジン

オンライン対戦のルーム寿命とデータ削除:TTLの設計

遊びをつくる

執筆にClaudeを使用しています。

二つの小さなゲーム盤を糸でつないだ、オンライン対戦のイメージ。
記事のテーマをもとに制作したAI生成イメージ。実際のゲーム画面ではありません。

オンライン対戦機能を実装したとき、多くの開発者が最初にぶつかる「目に見えない問題」が、誰も使っていないルームがサーバーやデータベースに残り続けることです。プレイヤーが途中でブラウザを閉じる、通信が切れる、マッチングが成立しないまま放置される——こうした状況は必ず起き、対処しないとストレージのコストが膨らみ、次のマッチングにも悪影響を与えます。 TTL(Time To Live)とは「このデータは○秒後に自動削除する」という有効期限の仕組みです。データベースやキャッシュ層でよく使われる概念で、ルーム管理に適用すると、コードで明示的に削除処理を書かなくても古いデータが自動的に消えます。この記事では、TTLをどの値に設定すべきか、どのフェーズごとに設計すべきか、そして削除タイミングを誤ったときに起きる典型的な障害について整理します。

この記事の目次
  1. ルームには「フェーズ」があり、フェーズごとに寿命が違う
  2. 「最終アクティビティ時刻」でTTLをスライドさせる
  3. 削除タイミングのズレが引き起こす典型的な障害
  4. 実装に使うデータストアとTTL機能の選び方
  5. TTL値を決める判断フロー
  6. 次の行動
  7. 参考資料

ルームには「フェーズ」があり、フェーズごとに寿命が違う

TTLの設計で最初にやるべきことは、ルームの状態遷移を書き出すことです。多くのブラウザゲームでは、ルームは次のようなフェーズをたどります。

  1. 作成済み・待機中(ホストが入室し、対戦相手を待っている)
  2. マッチング成立・ゲーム開始待ち(両者入室済み、ゲームロード中)
  3. 対戦中(双方向の通信が活発に発生している)
  4. ゲーム終了・結果表示中(対戦は終わったが結果を表示するため短時間保持)
  5. 廃棄済み(削除してよい状態)

それぞれのフェーズで「人間が正常に操作するとどのくらい時間がかかるか」を見積もり、その1.5〜2倍をTTLの初期値とするのが現実的な出発点です。たとえば待機中のルームは、ホストが対戦相手を募集している時間として10〜15分を見込む場合、TTLは20〜30分あたりが候補になります。対戦中のフェーズはゲームの種類によって大きく異なります。チェスのような長手数ゲームなら1時間以上確保する必要があり、短いアクションゲームなら5〜10分で十分です。

フェーズ移行時にTTLをリセットする

よくある設計ミスは、ルーム作成時に一つのTTLだけを設定してすべてを賄おうとすることです。待機中のルームが長く存在できるようにTTLを長く設定すると、対戦終了後の廃棄フェーズでも同じ長いTTLが残ってしまいます。

正しいアプローチは、フェーズが変わるたびにTTLを上書きすることです。マッチング成立時にTTLを対戦中フェーズの値へ更新し、対戦終了時には「結果表示用の短いTTL(例:5分)」へ再設定します。RedisのEXPIREコマンドやFirestoreのexpireAt フィールドはいずれも上書きに対応しているため、フェーズ移行のロジックと同じ場所にTTL更新を置くことで管理が楽になります。

「最終アクティビティ時刻」でTTLをスライドさせる

固定TTLだけでは対応できないシナリオがあります。対戦中にプレイヤーが長考している場合です。チェスや将棋などのターン制ゲームでは、一手に10分かかることも珍しくありません。もし対戦フェーズのTTLを「平均的な対局時間」で固定すると、合法的にプレイ中のルームが削除されてしまいます。

これを防ぐのがスライディングTTL(またはローリングTTL)です。ルームへの書き込みや、プレイヤーからのping(生存確認リクエスト)が届くたびにTTLをリセットします。実装上は、最後のアクティビティ時刻をルームのデータに記録しておき、定期的なバックグラウンド処理(またはデータベース側のTTL機能)で「現在時刻 − 最終アクティビティ時刻 > 閾値」なら削除する、という構造になります。

スライディングTTLで注意すべき点は、最大寿命(ハードTTL)を別途設けることです。スライディングのみで管理すると、バグや不正なクライアントが断続的にpingを送り続けてルームが永遠に残るリスクがあります。「最終アクティビティから30分でスライド削除」と「作成から4時間で強制削除」を組み合わせるのが安全です。

削除タイミングのズレが引き起こす典型的な障害

TTL設計で失敗したときの症状を知っておくと、デバッグの時間が短縮できます。

ゴーストルーム問題は、削除されるべきルームがマッチングの候補として表示され続ける状態です。マッチング一覧を取得するクエリが「存在するルームを全件返す」仕様になっていると、TTLで削除されるまでの間、実際には誰もいないルームに参加しようとしたプレイヤーがエラーを受け取ります。対策は、一覧取得時に「最終アクティビティから○分以内」というフィルタをクエリに加えることです。TTLによる物理削除とクエリフィルタの二重防御にしておくと、削除のタイムラグによる影響を最小化できます。

早期削除によるデータロストは、対戦中にTTLが切れてしまう問題です。これはTTLを短く見積もりすぎたか、フェーズ移行でのTTL更新が漏れているケースで起きます。対戦結果のスコアやランキング反映に使うデータが削除されると、サーバー側では「対局が存在しない」という扱いになります。対戦開始と同時に、最低限の結果記録(対戦IDと参加者IDだけでも)を寿命の長い別のコレクションや永続ストレージに書いておくと、ルーム削除後でも事後処理が可能になります。

孤立したプレイヤーセッションは、ルームは削除されたがプレイヤー側のクライアントがそれを知らないまま動き続ける状態です。クライアントはルームに対して定期的に存在確認リクエストを送る設計にしておき、404またはルーム廃棄のレスポンスが返ってきたときはゲームを終了してロビーに戻す処理を用意します。TTL側の設計だけでなく、「削除後にアクセスしてきたクライアントへの応答」もセットで設計することが重要です。

実装に使うデータストアとTTL機能の選び方

どのデータストアを使うかによって、TTL機能の挙動と制限が異なります。

Redis はキーに対してEXPIREコマンドで秒単位のTTLを設定でき、パフォーマンスが高いため対戦中のルーム状態(現在の盤面・手番など)の保持に向いています。ただしRedisは基本的にメモリ上に乗るため、大量のルームを長期間保持するコストには注意が必要です。永続化オプション(RDB/AOF)を使わない限り、再起動でデータが消えることも設計に折り込みます。

Firestore(Cloud Firestore) はドキュメントに expireAt フィールドを設定するTTL機能を持っています。ただし削除はリアルタイムではなく、指定時刻を過ぎてから最大数日かかる場合があると公式ドキュメントに記載されています。そのため「削除されたかどうか」をクエリフィルタで判断するロジックは必須です。料金体系はドキュメントの読み書き回数で課金されるため、公式サイトで最新の条件を確認してください。

Supabase(PostgreSQL) はTTLネイティブの自動削除機能を持たないため、pg_cronなどの拡張機能を使った定期ジョブか、アプリケーション側でのポーリング削除が必要です。設定の自由度は高い一方、削除処理の管理がすべてアプリケーションコードに委ねられます。

TTL値を決める判断フロー

設計時に迷わないよう、値を決める手順を整理します。

  1. ゲームの最長正常プレイ時間を見積もる。チュートリアルを含む最長ケースを想定します。
  2. フェーズごとに上記の1.5〜2倍を仮のTTLとする。
  3. スライディングTTLが必要かを判断する。ターン制で長考があるなら必要。アクション系で常にリアルタイム通信があるなら固定TTLでも機能します。
  4. ハードTTL(最大寿命)を別途設定する。スライディングを使う場合でも必ず設けます。
  5. ステージング環境でTTLを意図的に短縮してシナリオテストする。「途中離脱→TTL切れ→再参加試行」の動作を確認します。
  6. 本番投入後にルーム数の推移をモニタリングする。長期間削除されないルームが増えていれば、TTL設定かフェーズ移行の漏れを疑います。

次の行動

まずゲームのルームが通る状態遷移を図か箇条書きで書き出し、フェーズごとに「人間が正常に操作する最長時間」を見積もることから始めてください。その数字を持ってRedisやFirestoreのドキュメントを読むと、どの機能を使うべきかが明確になります。実装後は本番と同じデータストアを使ったステージング環境でTTL切れの挙動を必ず確認し、クライアント側の「ルーム消滅時の応答」も合わせてテストしてください。

参考資料

  • Redis公式ドキュメント(EXPIRE、TTLコマンドのリファレンス)
  • Firebase公式ドキュメント(Firestore TTL ポリシー)
  • Supabase公式ドキュメント(pg_cron 拡張機能)