WaterPunch
← ブログ一覧

在庫は「営業フロア」、メニューは「倉庫」― KV と Firestore を分けた MasterOrder の在庫アーキテクチャ

FirestoreCloudflare KVSpring Boot在庫管理MasterOrderSaaS

はじめに:QRオーダーで在庫が難しい理由

飲食店向け QR オーダーシステム MasterOrder では、「メニューを見せる」と「在庫を減らす」が 別の頻度・別の整合性要件 を持ちます。

これを単一ストアに全部入れると、メニューの 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 で 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)

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 を購読。

現行方式:

  1. ローカル SSE 購読開始(OrderSseService
  2. FirestoreInventoryListenerSync.ensureShopInventoryListener
  3. shops/{shopId}/inventory/menus/items コレクション listener
  4. MenuStockLocalCache.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 へ:移行の実録

移行理由はシンプルです。

手順は、feature/inventory-firestore ブランチで段階 PR → 移行スクリプトで件数・stock 突合 → 全ノード同時に MASTERORDER_INVENTORY_STORE=firestore で再起動 → smoke test(表示・減算・売切れ・補充)。

@ConditionalOnProperty で D1 / Firestore を並存させた期間があり、MenuStockEnricher / InventoryService / 来客 API は 無変更 でした。インターフェースを先に切って、実装を後から差し替えた――これが移行でいちばん効いた判断です。

計測結果と教訓

効いた施策のランキング(体感・運用観測ベース):

  1. reconcile 定期全件 GET 停止(reconcile-interval-ms=0
  2. SSE 購読店舗のみ Firestore リスナー
  3. 売切れメニューの Firestore 再読込スキップ
  4. 店舗単位バッチ読込 + Caffeine
  5. N+1 根絶(legacy inventory read OFF)

トレードオフ: 売切れ後 15 分以内に補充しても、アクセスがなければ表示は古いまま。完全リアルタイムはコストと引き換えです。Firebase コンソールは店舗別の読み取り内訳が見えないので、本番では shopId タグ付きメトリクスが欲しくなる――これは別途検討中です。

読者への actionable 教訓

現状と将来の差分

現状(2026-06 時点) 将来目標
来客・スタッフ UI Server REST + SSE が主経路 クライアント Firestore scoped 直読(読取のみ)
Firestore 接続 Server プロセスのみ 端末から在庫「見る」
在庫の正 Firestore 同左(パス設計は継続)

本記事は 現状の Server 経由アーキテクチャ を解説しています。将来直読に移行しても、InventoryPort とパス設計はそのまま活きる想定です。

まとめ

一文で言うと:

MasterOrder では KV を倉庫、Firestore を営業フロアとし、Server の MenuStockEnricher が両者を合成して来客に届ける。在庫の正は Firestore、読み取りは Caffeine + 売切れ抑制で課金を抑える。

3 つの設計パターン:

  1. 合成パターン … マスタと在庫を API 層で merge
  2. アクセス駆動キャッシュ … ポーリングしない、触られたときだけ refresh
  3. SSE 連動リスナー … 必要な店舗・ノードだけ Firestore を購読

在庫を KV に入れて CDN 配信する案もある。あなたなら、どこで線を引きますか?