WaterPunch
← ブログ一覧

Firestore Realtime を全部使わなかった理由 ― MasterOrder の更新通知設計

FirestoreSSE読み取り最適化MasterOrder個人開発

はじめに

「Firestore なら onSnapshot で全部リアルタイム」——最初はそう設計していました。MasterOrder を本番店舗で回すうち、読み取りがおおよそ 1,000 回/時 に達しました。Firebase コンソールには店舗別内訳も出ない。ここで方針を変え、Realtime は「更新があった」というベルだけ に絞り、正本データは Server REST + ノードキャッシュで取る形に寄せました。

同一条件で 約 300 回/時 まで下がった(2026-06 時点の運用観測)。この記事は 固定QR スタッフ画面のキャッシュファースト UI(卓グリッドの実装詳細)や 厨房フロー とは別角度で、なぜ Realtime 全面採用をやめたか を書きます。

Realtime 全面採用でぶつかった3つ

具体例
読み取り課金 セッション数 N × pending 取得、45 秒ごとの reconcile 全件 GET、order doc ごとの listener
マルチノード 同一 signals/order 更新に、SSE 接続がある 全ノード がリスナー課金
クライアント境界 ブラウザに Firestore SDK + Rules + Claims + 再接続。来客端末の品質は均一でない

決定打は読み取り課金でした。マルチノードとクライアント境界は、signals 方式に寄せる動機になっています。

通知と正本を分けた

[ 変更前のイメージ ]
  Client ──onSnapshot──► order / session / inventory 全部

[ 変更後 ]
  Client ──SSE──► Server(「何か変わった」)
  Client ──REST──► Server(正本スナップショット)
  Server ──listener──► Firestore(シグナル doc + 必要なクエリのみ)

Realtime を「データ同期」ではなく「イベント通知」として使う。UI が欲しいのは diff 1 件より、整合した一覧 です——厨房 pending、卓一覧など。

最初 Realtime に寄せていた部分

サーバー — 45 秒 reconcile

FirestoreSessionReconcileJob が購読中店舗の active_sessions を全件 .get() していました。

@Scheduled(fixedDelayString = "${masterorder.session.reconcile-interval-ms}")
public void reconcileSubscribedShops() {
    firestoreActiveSessionSync.reconcileAllRegisteredShops();
}

旧 Runbook では reconcile-interval-ms 既定 45 秒。営業中ずっと全店舗分の読み取りが積み上がる。

サーバー — pending の N+1

アクティブセッションごとに order を取りに行くパターン。セッション 20 卓 × 注文確認 = 読み取りが卓数に比例。

サーバー — 在庫のメニュー全件監視

FirestoreInventoryStockSignalSync のコメントより:

旧メニュー全件コレクション監視方式は P0–P2 で廃止済み。

在庫更新のたびにコレクション全体が listener に流れる構造は、メニュー数に比例して課金が膨らむ。

ブラウザ — データプレーン直結(目標は撤回方向)

初期の目標アーキテクチャでは「Client / Order はノード API のみ(Firestore 非接続)」とあり、ブラウザから Firestore を読まない を正としました。SDK でも enforce しています:

// api-routes.js — Firestore URL を apiBaseUrl に指定すると例外
function assertNodeApiBaseUrl(baseUrl) {
    var blocked = API_ROUTES.policy.browserMustNotUse;
    // firestore.googleapis.com 等 → throw
}

来客 — order の onSnapshot(オプション・既定 OFF)

guest-firestore-sdk.js は PIN 成功後 Custom Token で scoped 読取:

// session doc + orders サブコレクションの onSnapshot(2 本)
var sessionUnsub = ref.onSnapshot(...);
var ordersUnsub = ordersQuery.onSnapshot(...);

MASTERORDER_FIRESTORE_CLIENT_READ_GUEST_ENABLED=false が既定。有効時も REST ポーリングへフォールバックする設計です。

後から変えた部分

領域 Before After
セッション整合 45s reconcile 全件 GET reconcile-interval-ms=0(既定) — Bean 自体未登録
pending 注文 セッション N 回クエリ findPendingByShop(collection-group 1 回) + ShopPendingOrdersNodeCache
注文通知 order doc listener 想定 signals/order 1 docversion increment のみ
在庫同期 メニュー全件 listener signals/inventory_stock 1 doc — menuId + stock を patch
スタッフ Client 全面 Realtime 志向 SSE + REST。Firebase Auth のみが基本
固定QR 卓一覧 KITEI_QR のみ Client 直読(フラグ ON 時。例外)
来客 Order Firestore 履歴 watch 可 REST のみ + 在庫 30 秒ポーリング
ダッシュボード SSE 毎 API 連打 1.5s debounce + 2 分 interval 自動 refresh

読み取りは ~1,000 回/時 → ~300 回/時(約 70% 削減)。主因は reconcile 停止、pending N+1 廃止、Client 側 API 連打抑制です。

今 Realtime を使っている場所

Server(Firestore 接続は Server プロセスのみ)

SSE 接続がある shopId だけリスナー登録(hasLocalSubscribers)。接続 0 本で releaseShopListener

リスナー パス 用途
active_sessions where isActive==true 卓一覧キャッシュ → ピンポイント SSE
order signal signals/order 注文更新ベル
inventory signal signals/inventory_stock 在庫キャッシュ patch
archive signal signals/archive アーカイブ通知

個別 orders/{orderId} doc の snapshot listener は使いません。

// FirestoreOrderSignalPublisher — 中身は載せず version だけ bump
patch.put(FIELD_VERSION, FieldValue.increment(1));
patch.put(FIELD_EVENT_TYPE, "ORDER_UPDATED");
patch.put(FIELD_TARGET_SESSION_ID, sessionId);
patch.put(FIELD_TARGET_ORDER_ID, orderId);

リスナー attach は 300ms 遅延(瞬間切断での無駄登録防止)。publish 直後 250ms echo 抑制で SSE 二重配信を防ぎます。

ブラウザ(例外はフラグ付き)

Client Realtime 条件
来客 Order 原則なし REST + MENU_STOCK_POLL_MS = 30000
来客 Order guest-firestore guestSessionEnabled=true のみ
スタッフ SSE(Server push) 常時(厨房・卓更新のトリガー)
スタッフ Firestore 直読 KITEI_QR + staffSessionsEnabled のみ

固定QR 直読は、セッション doc が軽く席数が bounded な領域への限定例外です。厨房 pending や注文行は REST のまま——厨房フローの記事 と同じ切り分けです。

代替手段の使い分け

signals + SSE(主経路)

注文 POST → saveOrder → signals/order bump
         → Server listener → broadcastLocally → Staff SSE
         → Staff: getPendingOrders() // REST

数秒以内に厨房へ反映。更新 1 回 = シグナル doc 1 read × 接続ノード数、という bounded なコストです。

REST ポーリング(許容できる遅延)

用途 間隔 理由
来客メニュー在庫 30s soldOut 表示。秒単位 Realtime 不要
店舗ダッシュボード 2min + SSE debounce 1.5s 統計はリアルタイム必須ではない
来客注文履歴(Firestore OFF 時) connect API 再取得 listener より Simple

Server キャッシュ

Client 側 debounce / signature

// pending: 500ms debounce + buildPendingOrdersSignature で同一なら re-render スキップ
// dashboard: SHOP_DASHBOARD_SSE_DEBOUNCE_MS = 1500

SSE 1 イベント = Firestore 1 読み取り、ではありません。Server 内で束ね、Client は REST を debounce します。

UX を落とさず削ったやり方

「画面が自動更新される」体感は SSE で満たしています。ブラウザは Firestore に接続せず、スタッフは order-update イベントで pending を再取得する。

初回 SSE 接続では REFRESH_ALL で全件同期。listener attach 直後の取りこぼしより、一瞬古い一覧の方がマシ、という判断です。publish ノードは listener を待たず broadcastLocally するので、attach 遅延中の注文も SSE へ届きます。

KITEI だけ Client 直読——都度発行店舗は Server REST + SSE で足り、固定QR の卓グリッドだけ latency を確保する例外です。reconcile は既定 OFF に降格し、listener エラー時の保険としてコードだけ残しています。

運用面では、同一店舗の SSE を 1 ノードに寄せると、シグナル 1 write に対する listener read を N→1 にできます。Firebase コンソールは shopId 別 read が見えないので、次の一手は FirestoreOperationMetrics で listener / query を Server 側計測することだと思っています(未完了)。

現状マップ(2026-06)

                    ┌─────────────────────────────────────┐
                    │         Firestore(正本・営業中)      │
                    │  active_sessions / orders / signals  │
                    └──────────────┬──────────────────────┘
                                   │
              listener(SSE shop のみ)│ write(Server API)
                                   │
                    ┌──────────────▼──────────────────────┐
                    │        MasterOrder Server ノード      │
                    │  ActiveSessionLocalCache              │
                    │  ShopPendingOrdersNodeCache           │
                    │  OrderSseService ──SSE──►             │
                    └──────────────┬──────────────────────┘
                                   │ HTTPS + SSE
              ┌────────────────────┼────────────────────┐
              │                    │                    │
        来客 Order            スタッフ(基本)      スタッフ KITEI
        REST のみ             REST + SSE           + Firestore 直読
        30s 在庫 poll         pending debounce     (卓一覧のみ)

Firestore Realtime は無料ではない。listener 1 本が「常時接続 + 変更ごと read」で、全面採用は卓数・メニュー数・ノード数で乗算されます。signals doc は payload を載せず正本は REST——全面 Realtime 撤回 ≠ Realtime 禁止で、Server 内の最小 listener + 条件付き Client 直読(KITEI)のハイブリッドが、いまの現実解です。

シリーズ: ① 冪等性② 厨房フロー → 本記事(Realtime 方針)。