WaterPunch
← ブログ一覧

ランチピークで注文が二重に入らないようにした話 ― MasterOrder の注文API冪等性設計

API設計MasterOrder冪等性FirestoreSpring Boot個人開発

はじめに

飲食店向けモバイルオーダー MasterOrder を個人開発しています。在庫アーキテクチャFirestore 読み取り最適化 の記事に続き、今回は来客が注文を確定するときの話です。

確定ボタンの二重タップ、通信が遅いときの再タップ、回線の自動リトライ——来客UIでは毎日起きます。MasterOrder でいちばん避けたい事故のひとつが、同じ注文が厨房に2枚届く ことでした。

Idempotency-Key だけでは足りません。実際に困ったのは次の3パターンです。

パターン 典型原因 キーだけでは
A 同じ POST が2回来る 防げる
B 連打で キーだけ毎回変わる 抜ける
C 1回目は成功したが レスポンスが届かない 再送と二重登録の境界が曖昧

最終的に、クライアント・協調KV・セッションロック・結果キャッシュを重ねて、A〜C をまとめて潰しました。

クライアント:サーバーに届く前に1本に束ねる

requestId をカート画面で1回だけ発行

来客UIは カート画面を開いたタイミングrequestId を1回発行し、確定〜送信完了まで同じ値を使います。連打しても再送しても、同じ ID です。

// order-sdk.js — カート表示で1回。連打・再送は同一 ID
function generateRequestId() {
    return generateOrderIdempotencyKey(); // crypto.randomUUID()
}

function refreshCartRequestId() {
    state.cartRequestId = orderSdk.generateRequestId();
}
// showScreen('Cart') → refreshCartRequestId()
// sendOrder() → const requestId = state.cartRequestId;

HTTP ヘッダー:

Idempotency-Key: 550e8400-e29b-41d4-a716-446655440000
X-MasterOrder-Client-Id: guest-<端末ごとに localStorage 永続>
X-Session-PIN: <卓セッション PIN>

送信中は in-flight を join

planGuestOrderSubmit() は、同じ sessionId + 同じカート内容(payloadSig)で送信中なら、新しい HTTP を張らず進行中の Promise を join します。

if (slot && slot.inFlightPromise) {
    if (slot.payloadSig === payloadSig) {
        return { mode: 'join', promise: slot.inFlightPromise };
    }
    return { mode: 'blocked', message: '…' };
}

409 でも「もう成功してるかも」を照合

サーバーが 409 CONFLICT を返しても、1回目は通っていることがあります。クライアントは注文履歴 API を最大5回ポーリングし、同内容の注文が見つかれば成功扱いにして idempotentReplay: true を返します。

// reconcileGuestOrderAfterDuplicate — 400ms × attempt のバックオフ
return getGuestConnectOrderHistory(sessionId, pin, shopId, 20)
    .then(function (orders) {
        var match = findRecentGuestOrderMatch(orders, items);
        if (match) return { orderId: match.orderId, ... };
        if (tryNo >= 4) return null;
        return delayMs(400 * (tryNo + 1)).then(...);
    });

409 をそのままエラー画面に出すと、ランチピークで来客がパニックになります。ここは「失敗」より「処理中」の方が現場に優しい、という判断です。

サーバー入口:協調KVで「受け付けるか」を先に決める

API ノードが複数あっても、全ノード共通の協調 KV で受付を一本化します。

OrderController.guestOrderWithPin() の流れ:

PIN 認証 → カタログ ready 確認 → tryAdmit() → [ADMITTED なら] sessionLock → saveOrder()
                              ↓ 重複
                         結果キャッシュがあれば 200 + idempotentReplay
                              ↓ なければ 409

tryAdmit() のチェック順

GuestOrderSubmissionGuardService.tryAdmit() は、短い TTL のロックを段階的に取ります。

// 1. 端末クールダウン(直前の成功後 N 秒)
if (coordinationKvStore.hasKey(cooldownKey(clientId)))
    return COOLDOWN_ACTIVE;  // → 429

// 2. セッション burst(同一セッションの POST 最小間隔)
if (!setIfAbsent(sessionBurstKey(sessionId), "1", sessionBurst))
    return SESSION_ORDER_IN_PROGRESS;  // → 409

// 3. セッション処理中ロック(最大90秒)
if (!setIfAbsent(sessionInflightKey(sessionId), idempotencyKey, 90s))
    return SESSION_ORDER_IN_PROGRESS;

// 4. 同一カート内容フィンガープリント(キーだけ変わる連打対策)
String payloadKey = payloadKey(sessionId, items);  // SHA-256
if (!setIfAbsent(payloadKey, "1", duplicatePayloadWindow))
    return DUPLICATE_PAYLOAD;  // → 409

// 5. Idempotency-Key 本体(UUID v4 必須、TTL 60分)
if (!setIfAbsent(idempotencyKey(normalizedKey), "1", idempotencyTtl))
    return DUPLICATE_IDEMPOTENCY_KEY;  // → 409

return ADMITTED;

パターン B は ④ payload fingerprint で止めます。GuestOrderPayloadFingerprintsessionId + menuId:quantity:toppings を正規化して SHA-256 化します。クライアントが毎回新 UUID を付けてしまう実装でも、サーバー側で内容が同じなら弾けます。

成功結果をキャッシュ(パターン C)

saveOrder() 成功後:

guestOrderSubmissionGuardService.recordSuccessfulGuestOrder(
    requestId, sessionId, orderItems,
    new GuestOrderCachedResult(orderId, status, totalPrice));
guestOrderSubmissionGuardService.markSubmissionSucceeded(clientId, sessionId);

同一 Idempotency-Key または同一 payload で再 POST された場合、findCachedGuestOrder() が JSON を返し、新規 saveOrder は走りません

{
  "requestId": "550e8400-…",
  "allowed": true,
  "orderId": 12345678,
  "idempotentReplay": true
}

ヘッダー X-MasterOrder-Idempotent-Replay: true でも判別できます。

失敗時は releaseAfterFailedSubmission() でロックを解放し、正当な再送を許可します。一方、結果キャッシュ保存に失敗しても idempotency ロックは TTL まで残す 設計です。成功直後の再 POST で二重注文が通るのを防ぐためです。

if (admission == Admission.DUPLICATE_IDEMPOTENCY_KEY) {
    return false;  // TTL 自然失効まで待つ
}

セッション単位の排他

受付を通過したあとも、同一卓セッション内の saveOrder / checkout で TOCTOU が起きます。SessionOperationLockService で卓単位に直列化します。

return sessionOperationLockService.executeWithLock(sessionId, () -> {
    TableSession lockedSession = tableSessionRepository.findById(sessionId)...;
    if (!lockedSession.isActive()) return 410 GONE;
    return saveOrder(lockedSession, orderItems, ...);
});

ロックは CoordinationKvStore.setIfAbsent("lock:session:{sessionId}", token, ttl) をリトライ。取れなければ SessionOperationBusyException → 409 SESSION_BUSY。ランチピークに同一卓から注文がほぼ同時に来ても、ここで1本ずつ処理されます。

どこに何を書くか

領域 保存先 役割
協調ロック・冪等キー・結果キャッシュ 協調 KV(Gate Worker 経由 D1 等) 全 API ノード共有の受付ゲート
卓セッション・メニュー参照 PostgreSQL(+ キャッシュ) 認可・価格検証・在庫減算の前提
アクティブ注文 Firestore …/orders/{orderId} 厨房・スタッフが読む営業フロア
会計後 Gate KV / ローカルノード等 退店後の長期保存

saveOrder() 本体:

// 1. メニュー解決・トッピング検証
// 2. orderPriceVerificationService.verifyBeforePersist(...)
// 3. inventoryService.decreaseByOrderWithToppings(...)  // 在庫不足 → 409
// 4. FirestoreActiveOrderRepository.save()
// 5. orderSseService.broadcast(...)  // 厨房向け通知

Firestore の orderId はランダム整数 + lookup 衝突チェック(最大8回)で採番します。PostgreSQL シーケンスに依存しないので、複数ノードから同時書き込みしても衝突しにくいです。

private int generateOrderId() {
    for (int attempt = 0; attempt < 8; attempt++) {
        int candidate = ThreadLocalRandom.current().nextInt(1, Integer.MAX_VALUE);
        if (!orderLookupRef(candidate).get().exists()) return candidate;
    }
    throw new IllegalStateException("Failed to allocate unique orderId");
}

冪等性の正本は Firestore ではなく協調 KV です。Firestore は「通った注文の置き場」、PostgreSQL は「ビジネスルール検証」、KV は「二重通過防止」——在庫記事 で書いた倉庫・フロア・事務所の分担と同じ考え方です。

Idempotency-Key は UUID v4 に限定しています。任意文字列だとキー空間の衝突やログ injection の余地が残るためです。スタッフ向け POST /orders?sessionId= は Idempotency-Key 必須にしていません。UI 制御とセッションロック中心で、来客 API ほど攻撃面が広くないからです。

現場で想定したケース

連打(同一 requestId)

[Client] POST ×3(同一 Idempotency-Key)
[Guard] 1本目 ADMITTED → saveOrder 成功 → 結果キャッシュ
        2・3本目 DUPLICATE_IDEMPOTENCY_KEY → キャッシュ返却 200

厨房には1枚。来客には成功(2回目以降は idempotentReplay)。

キーだけ毎回変わる連打

[Guard] payload fingerprint が同一 → DUPLICATE_PAYLOAD(30秒窓)
        キャッシュがあれば 200、なければ 409
[Client] reconcile が履歴から orderId を拾う

1回目成功・レスポンスロスト・再送

[Server] saveOrder 成功 → recordSuccessfulGuestOrder
[Client] タイムアウト → 同一 requestId で再 POST
[Guard] DUPLICATE_IDEMPOTENCY_KEY + キャッシュヒット → 200 idempotentReplay

saveOrder 失敗(在庫不足・価格不一致)

[Guard] ADMITTED → saveOrder 409/400 → releaseAfterFailedSubmission()
[Client] カートを開き直して新しい cartRequestId で再送

ランチピーク(多卓・同一卓)

別卓は sessionId 単位で独立。同一卓の同時 POST は burst + inflight + SessionOperationLock で直列化。複数 API ノード間は GateCoordinationKvStore が D1 上の coordination_locks を共有します。

HTTP ステータスの整理

Admission / 例外 HTTP 来客向けメッセージ例
DUPLICATE_* / SESSION_ORDER_IN_PROGRESS 409 「同じ注文を処理しています…」
COOLDOWN_ACTIVE 429 「送信が早すぎます…」
SessionOperationBusyException 409 「セッション処理中です…」
キャッシュヒット(重複だが成功済み) 200 idempotentReplay: true

監査ログ(GuestOrderAccessAudit)に requestId, clientId, idempotentReplay, reasonCode を残しています。厨房トラブルで「本当に2枚来たのか / 1枚の表示ズレか」を切り分けるときに効きます。

シーケンス図

sequenceDiagram
    participant G as 来客ブラウザ
    participant SDK as order-sdk
    participant API as OrderController
    participant Guard as GuestOrderSubmissionGuard
    participant KV as Coordination KV
    participant Lock as SessionOperationLock
    participant FS as Firestore

    G->>SDK: カート画面表示 → requestId 発行
    G->>SDK: 確定(連打)
    SDK->>SDK: 同一 payload → in-flight join
    SDK->>API: POST + Idempotency-Key + Client-Id
    API->>Guard: tryAdmit()
    Guard->>KV: setIfAbsent (burst/inflight/payload/idempotency)
    alt 重複 & キャッシュあり
        Guard-->>API: cached result
        API-->>SDK: 200 idempotentReplay
    else ADMITTED
        API->>Lock: executeWithLock(sessionId)
        Lock->>KV: lock:session:{id}
        API->>API: saveOrder (在庫・価格検証)
        API->>FS: orders/{orderId}
        API->>Guard: recordSuccessfulGuestOrder
        Guard->>KV: result JSON
        API-->>SDK: 200 orderId
    end

注文が厨房に載るまでの経路は 次の記事 で書きました。signals / SSE / pending 一覧の話です。