はじめに
「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 doc — version 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 キャッシュ
ActiveSessionLocalCache—GET /sessions/activeの.get()回避ShopPendingOrdersNodeCache— SSE 開始時 warm-upMenuStockLocalCache— 在庫 signal で patch、売切れ TTL 内再問い合わせなし
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)のハイブリッドが、いまの現実解です。