はじめに:QRオーダーで在庫が難しい理由
飲食店向け QR オーダーシステム MasterOrder では、「メニューを見せる」と「在庫を減らす」が 別の頻度・別の整合性要件 を持ちます。
- メニュー … 1日数回の更新。店名・価格・写真・トッピング構成。
- 在庫 … 注文のたびに変わる。売切れ・補充・ピーク時の同時減算。
これを単一ストアに全部入れると、メニューの CDN 的配信と在庫のリアルタイム性がトレードオフになります。最初から 「倉庫」と「フロア」の境界 を意識して設計しました。
三層アーキテクチャ:倉庫・フロア・事務所
MasterOrder の本番構成は、ざっくり次の三層です。
| ストア | 比喩 | 役割 | 在庫を含むか |
|---|---|---|---|
| Cloudflare KV | 倉庫 | shop 情報・メニュー・トッピングのマスタ | 含めない |
| Firestore | 営業フロア | active_sessions、orders、在庫の正本、signals | 在庫の正 |
| PostgreSQL | 事務所 | 認可、在庫操作ログ、課金、監査 | ログのみ |
来客がメニューを見る流れはこうです。
GET /menus/search?shopId=
→ MenuCacheService.getMenusByShop()
→ KV からメニュー取得(Caffeine 経由)
→ MenuStockEnricher.enrichAll() … InventoryPort で在庫を後付け合成
→ stockQuantity / soldOut を含めて返却
設計でこう決めた理由: ブラウザ(来客 UI)は Firestore に直接つながず、Server REST のみ。Firestore に触るのは Server プロセスだけ。在庫ストアは InventoryPort で差し替え可能(D1 / Firestore / noop)にし、移行コストを下げています。
flowchart LR
Client --> Server
Server --> Caffeine
Caffeine --> KV["KV(メニューマスタ)"]
Server --> Firestore["Firestore(在庫・セッション)"]
Server --> Postgres["PostgreSQL(監査ログ)"]
なぜ在庫を KV に入れなかったか
意図的な分離です。
- KV は結果整合性(最大おおよそ 60 秒)があり、在庫の即時正確性には向かない
- 在庫は注文時に Transaction が必要
- メニュー更新と在庫更新のライフサイクルが根本的に違う
「メニューも在庫も KV で CDN 配信すれば安いのでは?」という案は魅力的ですが、在庫だけは動くデータとして別扱い しました。
Firestore 在庫スキーマ
在庫の正本は Firestore 上の店舗単位コレクションです。
shops/{shopId}/inventory/menus/items/{menuPublicId}
stock: number
menuPublicId: string // doc ID と同値(UUID)
menuName: string
internalMenuId: number // Postgres menus.id(移行・デバッグ用)
updatedAt: timestamp
version: number
店舗単位の collection get のみ。複合インデックスは不要です。将来トッピング在庫は shops/{shopId}/inventory/toppings/{toppingId} を想定しています。
| 項目 | D1 時代 | Firestore 移行後 |
|---|---|---|
| 在庫の正 | D1 item_stocks |
Firestore inventory |
| ノード間同期 | D1 更新 → inventory_signals → キャッシュ |
在庫コレクション リスナー → キャッシュ |
| 書き込み | SQL UPDATE WHERE stock >= ? |
Firestore Transaction |
本番既定は MASTERORDER_INVENTORY_STORE=firestore です。
読み取り戦略:課金を抑える4つの技
Firestore は読み取り課金。放置テストではおおよそ 1000 回/時 → 最適化後 300 回/時(約 70% 削減、セッション最適化 PR #41)。在庫を Firestore に移したあとも、既存の ~130 回/時 に +数回〜十数回程度 に収める設計を目指しました。
キャッシュ階層は次のとおりです。
MenuStockEnricher.enrichAll()
→ InventoryPort.getMenuStocksByShop(shopId)
→ FirestoreInventoryStore
→ MenuStockLocalCache(Caffeine、店舗単位キー)
→ ミス時のみ FirestoreInventoryLoader で店舗一括 load
1. 店舗単位バッチ読込
メニュー 13 件でも、Firestore への問い合わせは collection get 1 回 で済みます。N+1 を根絶する基本です。
2. 売切れフラグ(outOfStock)
stock ≤ 0 になったメニューは ShopMenuStocks.outOfStock に記録。同一 TTL 内は Firestore に再問い合わせしません。意図的なコスト削減 です。補充直後は、アクセス時 refresh か手動操作で復活するトレードオフがあります。
3. アクセス時リフレッシュのみ(ensureFreshOnAccess)
refresh-interval-ms=900000(15 分)を超えたキャッシュは、来客・スタッフが在庫 API に 触れたときだけ 再取得- 誰も来ていない店舗はバックグラウンド更新しない
- 在庫に対する暖機・定期ポーリングは 行わない(意図的)
4. Caffeine TTL
shop-cache-expire-minutes=90 で JVM 内キャッシュを保持。
| 操作 | Firestore アクセス |
|---|---|
| 初回メニュー表示(キャッシュなし) | 1 回(店舗 menus コレクション全件) |
| 2 回目以降(キャッシュ有効) | 0 回 |
| 売切れメニューの enrich | 0 回 |
注文 decreaseMenuStock |
Transaction 1 回(必須) |
| アクセス時 refresh(15 分超) | 店舗 1 回 |
| 他ノード減算(リスナーあり) | 0 回(doc イベントでキャッシュ patch) |
設計でこう決めた理由: 読み取り最適化は「キャッシュヒット率」だけでなく、キャッシュミス時の形(店舗一括・売切れスキップ) で決まります。
書き込みと整合性:Transaction と InventoryPort
減算の核心は Firestore Transaction です。
// FirestoreInventoryStore.decreaseMenuStock(概要)
firestore.runTransaction(tx -> {
snap = tx.get(shops/{shopId}/inventory/menus/items/{menuPublicId})
current = snap.exists() ? snap.stock : 0
if (current < qty) throw OutOfStockException
tx.update(stock=current-qty, updatedAt=now, version++)
})
localCache.subtract(shopId, menuId, qty) // 売切れなら outOfStock フラグ
注文フロー(InventoryService.decreaseByOrder)では、注文行をマージ → 事前充足チェック → メニュー ID 昇順で減算(デッドロック回避)→ Postgres の inventory_operation_logs に記録。
KV のメニュー JSON は在庫を知りません。Server の MenuStockEnricher が 倉庫の品目リストとフロアの在庫表を突合してから 客に渡す――この合成パターンが API 層の要です。
sequenceDiagram
participant C as Client
participant S as Server
participant KV as KV
participant E as MenuStockEnricher
participant Cache as MenuStockLocalCache
participant FS as Firestore
C->>S: GET /menus/search
S->>KV: メニューマスタ取得
S->>E: enrichAll()
E->>Cache: getMenuStocksByShop
alt キャッシュミス
Cache->>FS: 店舗一括 load
end
E-->>S: stockQuantity 合成
S-->>C: メニュー + 在庫
マルチノード同期:SSE 連動リスナー
本番は複数 Spring Boot ノード + Cloudflare Tunnel。各ノードは独立した JVM 内キャッシュで、Redis Pub/Sub は使いません。
旧方式(廃止): D1 書き込み → inventory_signals → 各ノードがシグナル doc を購読。
現行方式:
- ローカル SSE 購読開始(
OrderSseService) FirestoreInventoryListenerSync.ensureShopInventoryListenershops/{shopId}/inventory/menus/itemsコレクション listenerMenuStockLocalCache.replaceShop
リスナーは「当該ノードに SSE 購読者がいる店舗のみ」 登録します。在庫 API 単体(スタッフ画面、SSE なし)は lazy GET のみ。SSE 終了時は releaseShopInventoryListener + キャッシュ解放。
FirestoreActiveSessionSync も同じパターンです。reconcile-interval-ms=0 で定期全件 GET を止めたのが、読み取り削減でいちばん効きました。
sequenceDiagram
participant A as Node A(注文)
participant FS as Firestore
participant B as Node B(listener)
participant Cache as Node B cache
participant C as Node B Client
A->>FS: Transaction 減算
FS-->>B: doc 変更イベント
B->>Cache: replaceShop
C->>Cache: 次の GET は最新在庫
D1 から Firestore へ:移行の実録
移行理由はシンプルです。
- D1 を在庫専用から解放し、別用途に回したい
- Firestore Transaction でマルチノード整合性を一本化
inventory_signalsという間接同期をやめ、在庫 doc リスナーに統一
手順は、feature/inventory-firestore ブランチで段階 PR → 移行スクリプトで件数・stock 突合 → 全ノード同時に MASTERORDER_INVENTORY_STORE=firestore で再起動 → smoke test(表示・減算・売切れ・補充)。
@ConditionalOnProperty で D1 / Firestore を並存させた期間があり、MenuStockEnricher / InventoryService / 来客 API は 無変更 でした。インターフェースを先に切って、実装を後から差し替えた――これが移行でいちばん効いた判断です。
計測結果と教訓
効いた施策のランキング(体感・運用観測ベース):
- reconcile 定期全件 GET 停止(
reconcile-interval-ms=0) - SSE 購読店舗のみ Firestore リスナー
- 売切れメニューの Firestore 再読込スキップ
- 店舗単位バッチ読込 + Caffeine
- N+1 根絶(legacy inventory read OFF)
トレードオフ: 売切れ後 15 分以内に補充しても、アクセスがなければ表示は古いまま。完全リアルタイムはコストと引き換えです。Firebase コンソールは店舗別の読み取り内訳が見えないので、本番では shopId タグ付きメトリクスが欲しくなる――これは別途検討中です。
読者への actionable 教訓
- 動的データと静的マスタは 最初から分ける
- 読み取り最適化は キャッシュミス時の形 で設計する
- マルチノードは リスナー + ローカルキャッシュ で十分なことが多い
- Port 抽象化 でストア移行コストを下げる
- ブラウザ直読は将来目標。まず Server 経由で境界を固める
現状と将来の差分
| 現状(2026-06 時点) | 将来目標 | |
|---|---|---|
| 来客・スタッフ UI | Server REST + SSE が主経路 | クライアント Firestore scoped 直読(読取のみ) |
| Firestore 接続 | Server プロセスのみ | 端末から在庫「見る」 |
| 在庫の正 | Firestore | 同左(パス設計は継続) |
本記事は 現状の Server 経由アーキテクチャ を解説しています。将来直読に移行しても、InventoryPort とパス設計はそのまま活きる想定です。
まとめ
一文で言うと:
MasterOrder では KV を倉庫、Firestore を営業フロアとし、Server の MenuStockEnricher が両者を合成して来客に届ける。在庫の正は Firestore、読み取りは Caffeine + 売切れ抑制で課金を抑える。
3 つの設計パターン:
- 合成パターン … マスタと在庫を API 層で merge
- アクセス駆動キャッシュ … ポーリングしない、触られたときだけ refresh
- SSE 連動リスナー … 必要な店舗・ノードだけ Firestore を購読
在庫を KV に入れて CDN 配信する案もある。あなたなら、どこで線を引きますか?