1. 現場の慢心:「とりあえずRedisにレスポンスをキャッシュ」の罪

決済、銀行送金、ポイント消費、ECの注文確定——。これらの「絶対に二重実行されては困る副作用を伴うAPI(Non-IdempotentなPOSTリクエスト)」を設計する際、もはやデファクトスタンダードとなったのが Idempotency-Key(冪等性キー)ヘッダの運用です。Stripeが広め、IETFでも標準化ドラフトが進むこの仕組みは、クライアントがUUIDなどの一意なトークンをヘッダに付与してリクエストを送信する作法です。

そして、多くのWebアプリケーションのコードベースや技術ブログで紹介されている「なんちゃって冪等性ミドルウェア」は、大抵以下のような設計になっています。

// 現場で量産される危険な「なんちゃって冪等性ミドルウェア」の例
func IdempotencyMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        key := r.Header.Get("Idempotency-Key")
        if key == "" {
            next.ServeHTTP(w, r)
            return
        }

        // 1. Redisから保存済みレスポンスを検索
        cachedResponse, err := redisClient.Get(r.Context(), key).Bytes()
        if err == nil {
            // キャッシュが存在すればそのまま返す
            w.Header().Set("Content-Type", "application/json")
            w.Header().Set("X-Cache", "HIT")
            w.Write(cachedResponse)
            return
        }

        // 2. 存在しなければ後続の決済処理(ビジネスロジック)を実行
        rec := httptest.NewRecorder()
        next.ServeHTTP(rec, r)

        // 3. 処理完了後、レスポンスをRedisにキャッシュ(TTL: 24時間)
        if rec.Code == http.StatusOK {
            redisClient.Set(r.Context(), key, rec.Body.Bytes(), 24*time.Hour)
        }

        // 4. クライアントに返却
        for k, v := range rec.Header() {
            w.Header()[k] = v
        }
        w.WriteHeader(rec.Code)
        w.Write(rec.Body.Bytes())
    })
}

一見すると「1度目のリクエストで決済が走りレスポンスがRedisに保存され、2度目のリクエストではそのキャッシュが返る」ため、見事に冪等性が保たれているように見えます。開発環境での手動テストやPostmanによる確認でも、同一キーで2回叩けば2回目はキャッシュが返り、全員が「これで完璧だ」と満足して本番にデプロイしてしまいます。

しかし、これこそが本番環境でユーザーのクレジットカードから二重に課金が発生し、深夜の緊急インシデントを引き起こす最悪の時限爆弾です。

2. 悲劇のシナリオ:In-Flight Race Condition(処理中競合)

なぜ上記の実装は本番で破綻するのでしょうか? 答えは明白です。「リクエストが処理されている最中(In-Flight)のコンマ数ミリ秒〜数秒の間に、同じキーを持った再送リクエストが到着した場合」の排他制御が完全に抜け落ちているからです。

モバイルアプリやブラウザの現場では、次のような事象が日常茶飯事で発生します。

このとき、システム内部では何が起きるでしょうか? 時系列で追ってみましょう。

【In-Flight Race Condition による二重決済発生フロー】

Client                     Backend (Thread 1)        Backend (Thread 2)        Redis / DB
  |                                |                         |                     |
  |-- [Req 1: Key=abc-123] ------->|                         |                     |
  |   (決済リクエスト送信)         |-- redis.Get("abc-123")->|                     | (MISS: まだない)
  |                                |-- [Stripe決済API呼出]-->|                     |
  |                                |   (外部通信で300ms待機) |                     |
  |                                |         :               |                     |
  |-- [Req 2: Key=abc-123] --------------------------------->|                     |
  |   (回線瞬断リトライが100ms後到着)                       |-- redis.Get(...) -> | (MISS! まだThread 1は処理中!)
  |                                |         :               |-- [Stripe決済API呼出]
  |                                |         :               |   (こちらも決済実行!)
  |                                |<-- 決済成功 (¥10,000) --|                     |
  |                                |-- redis.Set("abc-123")------------------------>| (レスポンス保存)
  |<-- [200 OK: 決済完了] ---------|                         |                     |
  |                                                          |<-- 決済成功 (¥10,000)|
  |                                                          |-- redis.Set(...) --->| (上書き)
  |<-- [200 OK: 決済完了] -----------------------------------|                     |
  |
  ※ ユーザーのカードから ¥10,000 が【合計2回】引き落とされた!

お分かりでしょうか。Thread 1がStripeやPayPayなどの外部決済プロバイダと通信し、レスポンスをRedisに書き込むまでの「無防備な空白期間」に到着したReq 2は、redis.Get でキーを見つけることができません。その結果、Req 2も「初回リクエストである」と誤認し、全く同じ決済処理をそのまま実行してしまうのです。

「結果のキャッシュ」だけでは、並行して走る「実行中のプロセス」を調停することはできない。冪等性とは単なるキャッシュ技術ではなく、分散システムにおける『実行権の排他調停(Concurrency Control)』そのものである。

3. 冪等性キー設計における「4つの致命的落とし穴」

In-Flight競合だけでなく、実務における冪等性キーの実装には、見過ごされがちな深刻な落とし穴がいくつも潜んでいます。

① 処理中(In-Flight)並行リクエストの排他漏れ

前述の通り、同じキーを持つリクエストが「既に処理中」である場合、後続リクエストは処理を開始してはならず、先行リクエストの完了を待機するか、即座に 409 Conflict(競合中)を返してクライアントに再試行を促さなければなりません。

② ペイロード不整合(Payload Mismatch)の盲点

もし悪意のある攻撃者、あるいはフロントエンドのバグによって、「同じ Idempotency-Key を使い回しながら、リクエストボディ(金額や送金先)が全く異なるリクエスト」が送信されたらどうなるでしょうか?

Request 1: Idempotency-Key: pay-999, Body: {"amount": 1000, "to": "Alice"}
Request 2: Idempotency-Key: pay-999, Body: {"amount": 50000, "to": "Bob"}

キーだけを見てキャッシュを返してしまうと、Request 2に対して「1000円をAliceに送金した結果」が返却され、システム上の帳簿とクライアントの認識が致命的に乖離します。IETFドラフト(draft-ietf-httpapi-idempotency-key-header)でも明記されている通り、「同一キーかつ同一パスでありながら、リクエストペイロードが異なる場合は 422 Unprocessable Entity または 400 Bad Request で即時拒否」しなければなりません。そのためには、リクエストボディのSHA-256ダイジェストを計算して照合する仕組みが不可欠です。

③ 外部決済呼び出し中のクラッシュと「ゾンビロック」

「じゃあRedisの SETNX で分散ロックを取ればいい」と考えたくなります。しかし、外部決済APIにリクエストを投げた直後、自社のコンテナがOOM Killerで強制終了されたり、ネットワークが切断されてプロセスが死んだ場合、どうなるでしょうか?

Redis上のロックはTTLが切れるまで解放されず、DB上のレコードも中途半端な状態になります。その状態でクライアントが再送してきたとき、外部プロバイダ側では決済が成立しているにもかかわらず、自社システム側ではエラーとみなして再度決済を試みたり、逆に「ロック中」として永久に処理できなくなる「ゾンビ状態」に陥ります。

④ 揮発するインメモリKVS(Redis)への「お金」の全委ね

Redisは高速ですが、本質的にはキャッシュ/揮発性インメモリデータストアです。メモリ圧迫によるキーのLRU Eviction(強制退避)や、マスターノードのフェイルオーバー時のレプリケーション遅延によって、キーが消失するリスクをゼロにはできません。数千万円の取引や法的な整合性が問われる決済のSingle Source of Truth(信頼できる唯一の情報源)を、DBではなくRedisのキャッシュに委ねる設計は、アーキテクチャとして極めて脆いと言わざるを得ません。

4. RDBMSによる「確定冪等性」のアーキテクチャ

これらすべての落とし穴を解消し、100%の確定的な冪等性を手に入れるための結論はシンプルです。外部の分散KVSに頼るのをやめ、業務データを管理しているRDBMS(PostgreSQL / MySQL)のテーブルとトランザクション、そしてステートマシンで実装することです。

なぜRDBMSなのか? 理由は3つあります。

  1. ACIDトランザクションの恩恵: 業務データ(注文や残高)の更新と、冪等性ステータスの更新を「同一トランザクション内」でアトミックに確定できる。
  2. 行ロックとUNIQUE制約: DBエンジンが数十年磨き上げてきた主キー/ユニーク制約により、並行リクエストの衝突をナノ秒レベルで確実に1勝1敗に仕分けられる。
  3. 永続性と監査性: Redisのように勝手にキーが蒸発せず、「誰が、いつ、どのキーで、どのペイロードを投げ、どんなレスポンスが返ったか」が完全に監査ログとして残る。

冪等性管理テーブルのスキーマ設計

まずは、冪等性を管理するための専用テーブル idempotency_keys を設計します。

-- PostgreSQL の場合
CREATE TABLE idempotency_keys (
    idempotency_key VARCHAR(255) NOT NULL,
    user_id VARCHAR(64) NOT NULL,
    request_path VARCHAR(255) NOT NULL,
    request_hash CHAR(64) NOT NULL, -- リクエストBodyのSHA-256ハッシュ
    status VARCHAR(32) NOT NULL,     -- 'PROCESSING', 'SUCCEEDED', 'FAILED'
    response_code INT,               -- HTTPステータスコード (200, 201等)
    response_body JSONB,             -- 返却したレスポンスJSON
    created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
    locked_until TIMESTAMPTZ NOT NULL, -- 処理タイムアウト検知用 (例: created_at + 30秒)
    
    PRIMARY KEY (user_id, idempotency_key)
);

CREATE INDEX idx_idempotency_created_at ON idempotency_keys (created_at);

ポイントは、主キーを (user_id, idempotency_key) の複合キーにしている点です。これにより、悪意のあるユーザーが他人の推測可能なキーを使ってAPIを妨害する「キー横取り攻撃」をマルチテナントレベルで物理的に防止します。

3つの状態を持つステートマシン

リクエストが到着した際、システムは以下のステートマシンに従って厳密に分岐します。

              [リクエスト到着]
                     │
         BodyのSHA-256ハッシュを計算
                     │
       INSERT (status='PROCESSING')
                     │
       ┌─────────────┴─────────────┐
 [INSERT成功 (初回)]        [一意制約違反 (既存キーあり)]
       │                           │
 決済・ビジネスロジック実行          既存レコードを取得
       │                           │
 ┌─────┴─────┐              ┌──────┴──────────────────────────┐
 │           │              │                                 │
[成功]      [失敗]     [request_hash不一致]           [request_hash一致]
 │           │              │                                 │
UPDATE      UPDATE    422 Unprocessable             ┌─────────┴─────────┐
status=     status=   Entity で即時遮断             │                   │
SUCCEEDED   FAILED                          [status=PROCESSING]   [status=SUCCEEDED]
 │           │                                      │                   │
200 OK      500 Error                       locked_untilを超過?   保存済みレスポンス
                                             ├── YES: ゾンビ回収   を即返却
                                             │   して再実行      (Idempotent-Replayed: true)
                                             └── NO: 409 Conflict
                                                 でリトライを促す

5. 本番仕様のGo言語ミニマル実装

このアーキテクチャを、Go言語の標準ライブラリと database/sql のみを用いてクリーンに実装したコードが以下です。外部のヘビーなフレームワークに依存せず、標準の仕組みだけで極めて堅牢に動作します。

package idempotency

import (
    "bytes"
    "context"
    "crypto/sha256"
    "database/sql"
    "encoding/hex"
    "encoding/json"
    "errors"
    "io"
    "net/http"
    "time"
)

type Status string

const (
    StatusProcessing Status = "PROCESSING"
    StatusSucceeded  Status = "SUCCEEDED"
    StatusFailed     Status = "FAILED"
)

type Record struct {
    Key          string
    UserID       string
    RequestPath  string
    RequestHash  string
    Status       Status
    ResponseCode int
    ResponseBody []byte
    LockedUntil  time.Time
}

// ComputeHash はリクエストボディからSHA-256ハッシュを算出する
func ComputeHash(body []byte) string {
    h := sha256.Sum256(body)
    return hex.EncodeToString(h[:])
}

// ExecuteWithIdempotency は確定的な冪等性を保証してビジネスロジックを実行する
func ExecuteWithIdempotency(
    ctx context.Context,
    db *sql.DB,
    userID, key, path string,
    reqBody []byte,
    handler func(ctx context.Context) (statusCode int, resBody []byte, err error),
) (statusCode int, resBody []byte, replayed bool, err error) {
    if key == "" {
        // キー未指定の場合は通常実行(冪等性保証外)
        statusCode, resBody, err = handler(ctx)
        return statusCode, resBody, false, err
    }

    reqHash := ComputeHash(reqBody)
    now := time.Now().UTC()
    lockTimeout := now.Add(30 * time.Second) // 30秒でロック期限切れ

    // ステップ1: PROCESSING 状態で atomic にインサートを試みる
    query := `
        INSERT INTO idempotency_keys 
        (idempotency_key, user_id, request_path, request_hash, status, locked_until, created_at, updated_at)
        VALUES ($1, $2, $3, $4, $5, $6, $7, $7)
        ON CONFLICT (user_id, idempotency_key) DO NOTHING;
    `
    res, err := db.ExecContext(ctx, query, key, userID, path, reqHash, StatusProcessing, lockTimeout, now)
    if err != nil {
        return http.StatusInternalServerError, nil, false, err
    }

    rowsAffected, _ := res.RowsAffected()

    if rowsAffected == 0 {
        // ステップ2: 既にキーが存在する場合(並行リクエスト、または再送)
        var existing Record
        var resBodyBytes []byte
        selectQuery := `
            SELECT idempotency_key, user_id, request_path, request_hash, status, response_code, response_body, locked_until
            FROM idempotency_keys
            WHERE user_id = $1 AND idempotency_key = $2;
        `
        row := db.QueryRowContext(ctx, selectQuery, userID, key)
        err = row.Scan(
            &existing.Key, &existing.UserID, &existing.RequestPath,
            &existing.RequestHash, &existing.Status, &existing.ResponseCode,
            &resBodyBytes, &existing.LockedUntil,
        )
        if err != nil {
            return http.StatusInternalServerError, nil, false, err
        }
        existing.ResponseBody = resBodyBytes

        // チェックA: ペイロード改変・衝突の検知
        if existing.RequestHash != reqHash || existing.RequestPath != path {
            // 同一キーで異なる中身が送信された場合は 422 で即遮断
            errRes, _ := json.Marshal(map[string]string{
                "error": "Idempotency key payload mismatch",
            })
            return http.StatusUnprocessableEntity, errRes, false, nil
        }

        // チェックB: 既に過去のリクエストが成功完了している場合
        if existing.Status == StatusSucceeded {
            // 保存済みのレスポンスを完全再生して返す
            return existing.ResponseCode, existing.ResponseBody, true, nil
        }

        // チェックC: 現在進行形で処理中の場合 (In-Flight)
        if existing.Status == StatusProcessing {
            if now.Before(existing.LockedUntil) {
                // ロック有効期限内 → 別のスレッドがまさに実行中!
                errRes, _ := json.Marshal(map[string]string{
                    "error": "A request with this idempotency key is currently in-flight",
                })
                return http.StatusConflict, errRes, false, nil
            }
            // ロック期限切れ(過去のプロセスがクラッシュしたゾンビ)
            // 自プロセスがロックを延長して再実行権を奪取する
            claimQuery := `
                UPDATE idempotency_keys
                SET locked_until = $1, updated_at = $2
                WHERE user_id = $3 AND idempotency_key = $4 AND status = $5;
            `
            _, err = db.ExecContext(ctx, claimQuery, lockTimeout, now, userID, key, StatusProcessing)
            if err != nil {
                return http.StatusConflict, nil, false, err
            }
        }
    }

    // ステップ3: 実行権限限を獲得!ビジネスロジック(決済など)を実行
    code, out, execErr := handler(ctx)

    // ステップ4: 結果をアトミックに永続化
    finalStatus := StatusSucceeded
    if execErr != nil || code >= 500 {
        finalStatus = StatusFailed
    }

    updateQuery := `
        UPDATE idempotency_keys
        SET status = $1, response_code = $2, response_body = $3, updated_at = $4
        WHERE user_id = $5 AND idempotency_key = $6;
    `
    _, updateErr := db.ExecContext(ctx, updateQuery, finalStatus, code, out, time.Now().UTC(), userID, key)
    if updateErr != nil {
        // ここでの失敗は重大ログとして記録するが、ビジネスロジックの結果はクライアントに返す
        // (外部決済が通っていれば二重決済ではなく自社DB更新エラーのハンドリング)
    }

    return code, out, false, execErr
}

この実装によって、以下の安全性が完全に担保されます。

6. 現場運用で差がつく3つの実践的知見

① クライアント側のリトライ作法と「409 Conflict」の調停

サーバーが 409 Conflict(処理中)を返したとき、クライアント側のSDKやモバイルアプリはどのように振る舞うべきでしょうか? ユーザーに「競合エラーが発生しました」と赤文字でエラーダイアログを突きつけるのは最悪のUXです。

正解は、クライアント側のHTTPレイヤーが自動的に「指数バックオフ(Exponential Backoff)+Jitter」を挟んで数回ポーリング再送することです。サーバー側は Retry-After: 1 ヘッダを付与して返し、クライアントが1秒後に同一キーでリクエストを再送すれば、その頃には先行リクエストが完了して status = SUCCEEDED に遷移しており、保存済みレスポンス(Idempotent-Replayed: true)がシームレスに返って正常終了します。

② 外部決済サービス(Stripe等)との「キーリレー(Key Relay)」

自社サーバーが status = PROCESSING で外部決済プロバイダ(StripeやPayPal等)を呼び出す際、クライアントから受け取った Idempotency-Key をそのまま外部決済APIのリクエストヘッダにも引き継ぐ(Key Relay)設計を徹底してください。

// Stripe API呼び出し時にも同一キーをリレーする
params := &stripe.PaymentIntentParams{
    Amount:   stripe.Int64(amount),
    Currency: stripe.String(string(stripe.CurrencyJPY)),
}
// 自社が受け取ったIdempotency-KeyをStripeのリクエストオプションにセット
params.SetIdempotencyKey(key)

pi, err := paymentintent.New(params)

これを徹底することで、仮に自社サーバーとStripe間のネットワークが切断され、「Stripe側では決済が成功したが、自社サーバーにはレスポンスが届かずにタイムアウトした」という極限状態が発生しても、次回自社サーバーがゾンビ回収してStripeに再送した際に、Stripe側でも二重決済がブロックされて前回の成功結果が返却されます。自社DBと外部決済プロバイダの二重の防壁が完成するのです。

③ テーブル肥大化を防ぐTTLとパーティショニング

すべてのPOSTリクエストで idempotency_keys テーブルにレコードを書き込んでいると、大規模サービスでは数ヶ月で数千万〜数億行に達し、ディスクとインデックスサイズを圧迫します。一般的に、決済や注文の冪等性キーの有効期限は「24時間〜72時間」で十分です(Stripeも24時間を規定しています)。

ここでおすすめなのが、日付ごとのテーブルパーティショニング(Range Partitioning)です。

-- 日付ごとのパーティションテーブル
CREATE TABLE idempotency_keys (
    idempotency_key VARCHAR(255) NOT NULL,
    user_id VARCHAR(64) NOT NULL,
    created_at TIMESTAMPTZ NOT NULL,
    ...
) PARTITION BY RANGE (created_at);

-- 7日前の古いパーティションを丸ごと破棄(超高速・バキューム負荷ゼロ)
DROP TABLE idempotency_keys_2026_09_13;

毎晩バッチで DELETE FROM idempotency_keys WHERE created_at < NOW() - INTERVAL '3 days'; を回すと、大量のDead Tuples(不要行)が生成されてバキューム負荷が跳ね上がりますが、パーティションの DROP TABLE であればディスクI/O負荷ほぼゼロで一瞬にして領域を解放できます。

7. まとめ:「結果のキャッシュ」ではなく「実行プロセスの所有権」を掴め

「冪等性」という言葉を聞いたとき、多くのプログラマは無意識に「同じ入力を受け取ったら同じ出力をキャッシュから返す関数」を連想します。しかし、実世界の分散システムにおける副作用の制御は、静的なキャッシュ戦略などでは決して太刀打ちできません。

本番で耐え抜く堅牢なAPIを作るための原則を、最後に3行でまとめます。

1. 冪等性とは「結果のキャッシュ」ではなく、並行して走る「実行プロセスの所有権争奪戦」である。
2. 金銭やリソースを動かすクリティカルな不変条件を、揮発するインメモリKVSだけに委ねてはならない。足元のRDBMSのACID特性を使い倒せ。
3. 同一キーによる並行リクエスト(In-Flight)とペイロード不整合を弾けない冪等性は、存在しないのと同じである。

チュートリアルの安易なRedisキャッシュ実装を脱ぎ捨て、RDBMSのステートマシンによる「確定冪等性」を手に入れてください。あなたのシステムは、ネットワークがどれほど荒れ狂おうと、ユーザーがどれほどボタンを連打しようと、1円の狂いもなく平然と動き続けるはずです。