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)のコンマ数ミリ秒〜数秒の間に、同じキーを持った再送リクエストが到着した場合」の排他制御が完全に抜け落ちているからです。
モバイルアプリやブラウザの現場では、次のような事象が日常茶飯事で発生します。
- 電波の悪い地下鉄でユーザーが決済ボタンを押した直後にパケットがロスし、OkHttpやURLSessionなどのHTTPクライアントライブラリが自動リトライを走らせた。
- フロントエンドのボタン非活性化(Disable)処理がコンマ数ミリ秒遅れ、ユーザーが決済ボタンを連打した。
- 上流のAPI Gatewayやロードバランサがタイムアウト誤検知により、同一リクエストを別のバックエンドPodへ再送した。
このとき、システム内部では何が起きるでしょうか? 時系列で追ってみましょう。
【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つあります。
- ACIDトランザクションの恩恵: 業務データ(注文や残高)の更新と、冪等性ステータスの更新を「同一トランザクション内」でアトミックに確定できる。
- 行ロックとUNIQUE制約: DBエンジンが数十年磨き上げてきた主キー/ユニーク制約により、並行リクエストの衝突をナノ秒レベルで確実に1勝1敗に仕分けられる。
- 永続性と監査性: 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
}
この実装によって、以下の安全性が完全に担保されます。
- 完全な並行排他:
ON CONFLICT DO NOTHINGとRowsAffected() == 0の組み合わせにより、同一キーで1万件のリクエストが同時に押し寄せても、最初の1行しかhandlerを実行できません。 - In-Flightレースの瞬殺: 2件目以降のリクエストは即座に既存レコードを検知し、実行中であれば
409 Conflictを返して停止します。二重決済の余地は1ミリ秒たりとも存在しません。 - ペイロード改ざん防止: SHA-256ハッシュが1ビットでも異なれば即座に
422 Unprocessable Entityを返し、意図しない事故を防ぎます。 - ゾンビロックの自浄作用: 万が一サーバーが途中で落ちても、
locked_until(30秒)を超過したレコードは安全に再試行権が回収されます。
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円の狂いもなく平然と動き続けるはずです。