WaterPunch
← ブログ一覧

【Firebase】バグ修正でFirestore読み取りが急増!?固定QRスタッフ画面のキャッシュファーストUIでコストを抑える

FirebaseFirestoreMasterOrderパフォーマンス個人開発

はじめに:バグは直った。請求は増えた

飲食店向けモバイルオーダー MasterOrder を個人開発しています。本記事は、第1本の設計思想の話から一歩踏み込み、実際に火を吹いたトラブルシューティング の記録です。

舞台は 固定QR(KITEI_QR)モード で、スタッフ画面が Firestore を直読する構成です。先に前提をはっきりさせます。

ここで卓カードの合計金額が一瞬「¥0」と表示される UI バグを直したら、Firebase コンソールの読み取り数が目に見えて増えました。バグ駆除と API 節約の狭間で、かなり苦戦した話を書きます。

1. 直面した課題:注文がある卓が「0円」になる

スタッフ管理画面(固定QR / 卓グリッド表示)で、注文済みの卓なのに合計が一時的に 「¥0」 と出る不具合がありました。現場から見ると「売上が消えた」ように見える。これは許容できません。

原因を追うと、データの 到着順序信頼できる情報源 がズレていました。

Firestore 直読だけでは totalAmount が 0 のまま

固定QR では、ブラウザが active_sessions を Firestore リスナーで購読し、卓カードをリアルタイム更新します。ところがセッション doc 上の totalAmount は、注文のたびに即座に揃うとは限りません。

実装側にも、はっきりコメントを残しています。

/**
 * 卓カードの合計・経過時間は GET /sessions/active?includeTotals=true 必須。
 * Firestore 直読だけでは totalAmount が 0 のままのことがある。
 */

サーバーには、注文作成・更新後に active_sessions へ合計を書き戻す FirestoreSessionTotalsSync があります。しかし UI はスナップショットを受け取った瞬間に描画するため、書き戻しより先に 0 円が画面に出る タイミングが存在します。空席マージ時のデフォルト値も totalAmount: 0 なので、余計に紛らわしい。

「正確さ優先」で入れた修正が、次の爆弾になった

0 円表示を潰すため、確実に正しい合計を得る REST API を積極的に叩く方針に切り替えました。

GET /sessions/active?shopId={id}&includeTotals=true

発火タイミングは、画面のセッション読込、手動更新に加え、タブ復帰visibilitychange / pageshow)経由の restoreStaffRuntimeAfterReturn()scheduleLoadSessions({ includeTotals: true }) など、かなり広めです。

UI バグは解消しました。 スタッフの信頼は戻った。ところが Firebase コンソールを開くと、同じような操作負荷なのに 読み取りがおおよそ 2〜3 倍 に増えているのが観測されました(※環境・店舗数・卓数で変動する観測値です。リポジトリに「70→200」のような固定記録はありません)。

「直したはずのバグの代償が、インフラ請求に回ってきた」――個人開発あるあるを、まさにやらかしていました。

2. なぜ読み取りが跳ね上がったのか:二重取得の正体

Firestore の読み取り課金は、クエリで返ってきたドキュメント数 に比例します。ドキュメント数 × 回数。シンプルで残酷です。

サーバー側:includeTotals=true の N+1

includeTotals=true を付けると、サーバー TableSessionController はアクティブセッション一覧を返す前に、セッションごとに注文サブコレクション(sessionOrders)を .get() して合計を再計算します。アクティブ卓が N 卓あれば、概ね N 回のサブコレクション読み取りが乗る構造です。

フロントはすでに active_sessions をリスナー購読しているのに、表示のたびにサーバー経由で注文一式を読み直す。リアルタイム doc と集計 API の役割が重なった「二重取得」 が起きていました。

フロント側:タブ復帰のたびに高コスト API

特に効いてきたのが、スタッフがスマホで別タブを見て戻ってくるパターンです。restoreStaffRuntimeAfterReturn()includeTotals: true 付きの読込をスケジュールするため、「画面に戻っただけ」で高コスト API が走りやすい 状態でした。

バグ修正の意図は「正しい合計を見せること」。結果として「正しい合計のために毎回フル再計算」に寄りすぎていた、というのが分析の結論です。

3. 解決策:キャッシュファーストのハイブリッド UI

方針を 「API 待ちでから描画」から「キャッシュで即描画 → 必要なときだけ enrich」 に変えました。ここが第2段の核心です。

① 信頼ソースの優先順位を変える

staff-firestore-runtime-sdk.jshandleFirestoreSessionsSnapshot では、次の順序にしています。

  1. Firestore スナップショット(ローカルキャッシュ含む)を 一次情報として即反映
  2. renderViewsNow で先に画面を更新
  3. 背景で bootstrapSessionTotals() → API enrich
  4. enrich 完了後に再度 renderViewsNow で差分を上書き
function handleFirestoreSessionsSnapshot(sessions, activeShopId, kiteiQrMode) {
    applySessionCache(sessions, activeShopId);
    if (kiteiQrMode) {
        notifyTableSeatsReconciled(sessions);
        callOpt(opts.renderViewsNow);  // まず描画
        var bootstrap = typeof opts.bootstrapSessionTotals === 'function'
            ? Promise.resolve(opts.bootstrapSessionTotals())
            : Promise.resolve();
        bootstrap.finally(function () {
            callOpt(opts.renderViewsNow);  // enrich 後に再描画
        });
    }
}

API の結果は enrichSessionsFromApiList() でキャッシュにマージし、detailsEnriched: true を付与。固定QR では卓メタ(REST)とセッション(Firestore)を mergeTableSeatsWithSessions() で結合し、合計だけ後から API で上書きするハイブリッド にしています。

これで「0 円の一瞬」は、キャッシュ上の前回値やセッション doc の暫定値で先に埋め、正確な合計は追いついたタイミングで更新、という UX に寄せられます。

② API 呼び出しの間引き(※「5秒デバウンス」ではない)

よくある誤解として「5秒間は同じセッションへの再リクエストを無視」といった話がありますが、この実装には存在しません。実際に効いているのは次のレイヤーです。

進行中リクエストの合流(enrichInflight

if (enrichInflight && options.force !== true) {
    return enrichInflight;
}
enrichInflight = staffSdk.getActiveSessions(shopId, { includeTotals: true })

同時に複数の enrich が走らないよう、進行中の Promise を返すだけ。force: true のときだけ再発行します。

仕組み 値・挙動 役割
LOAD_SESSIONS_DEBOUNCE_MS 1500ms scheduleLoadSessions の連打抑制
renderViewsDebounced 350〜400ms 描画の間引き
SSE 後ダッシュボード再読込 60秒 別経路だが連打抑制の好例
enrichInflight 進行中合流 並列 enrich の暴発防止

「5秒ガードを入れた」というより、レイヤーごとに適切な粒度で間引く 設計です。

4. エンジニアとしての学び

UI バグ修正は、インフラコストの設計変更になりうる

「正確さのために毎回サーバー再計算」は、Firestore 課金と相性が悪い。バグチケットを閉じた瞬間に、別の意味で 技術的負債 が増えることがあります。個人開発ほど、このトレードオフが直撃します。

リアルタイム doc と集計 API は、責務を分ける

両方「正しい」とは限らないタイミングがある以上、先にキャッシュで描画 → 必要時だけ enrich が現実解です。UX とコストの両立は、きれいな一本道ではなくハイブリッドになりがちです。

Firebase コンソールだけでは、店舗別の内訳が見えない

本番運用では、読み取り数に shopId タグを付けた自前メトリクスが欲しくなります。「どの店舗の、どの画面操作が doc を食っているか」が見えないと、最適化の効果検証も難しい。今回のように「2〜3 倍に増えた」は観測できても、「この変更で何 % 減ったか」は、計測を入れるまで断定できない ことも学びました。「コスト半分」といった数字は、単体修正の未計測値としては書けません。

5. まとめ:苦戦のあとに残った設計原則

固定QR スタッフ画面での一連の対応を振り返ると、次の原則に収束しました。

  1. 通常モード(REST + SSE)と Firestore 直読モードを混同しない ― 記事・設計・計測すべてで前提を分ける。
  2. 0 円バグは「データ未到着」問題 ― 毎回フル再計算ではなく、キャッシュファースト + 背景 enrich で解く。
  3. includeTotals=true は高コスト ― サーバー側 N+1 を意識し、発火タイミングを間引く。
  4. デバウンスは秒数の伝言ゲームではない ― inflight 合流・1500ms・350ms など、レイヤーごとに選ぶ。

バグ駆除と API 節約は、どちらか一方を選ぶ話ではありませんでした。「現場が信頼できる表示」と「読み取り課金を抑える取得戦略」 を、同じ画面の中で両立させる作業だったと思います。

MasterOrder では引き続き、在庫まわりの読み取り最適化(別文脈で 1000 回/時 → 300 回/時のような改善)など、Firestore コスト全体の最適化も進めています。次の記事では、そのあたりも実装ベースで書ければと思います。