じゃあ、おうちで学べる

本能を呼び覚ますこのコードに、君は抗えるか

冪等性キー: リトライで二重処理を防ぐ仕組み

はじめに

決済APIにリクエストを投げた。3秒待っても応答がない。タイムアウト。

クライアントがリトライした。結果、数十件の二重課金が起きた。

発覚したのは、翌朝のカスタマーサポートへの問い合わせラッシュだった。

ネットワークタイムアウトの曖昧さ

ログを突き合わせ、同一注文IDに対する重複トランザクションを洗い出す作業に半日かかった。決済APIでこの状況に遭遇すると、胃が痛くなる。

サーバー側で処理が完了しているなら、リトライは二重課金を引き起こす。完了していないなら、リトライしなければ注文が宙に浮く。タイムアウトは成功とも失敗とも言えない。この曖昧さこそ、分散システムにおける厄介な問題だ。

ここで問題になるのは、「サーバー側で処理されたのか、されていないのか」がクライアントからは判別できないことだ。

ネットワーク障害には3つのパターンがある。リクエストがサーバーに到達しなかった。リクエストは到達して処理されたが、レスポンスが返ってこなかった。リクエストは到達したがサーバーがクラッシュして処理が中途半端に終わった。クライアントから見ると、どのケースでも同じ「タイムアウト」に見える。

HTTPのステータスコードが返ってくるならまだ判断できる。だがタイムアウトはステータスコードすら返ってこない状態だ。TCPコネクションが切れたのか、サーバーが重いだけなのかもわからない。

この不確実性が、リトライの設計を厄介にする。

素朴なリトライが生む二重課金

まず冪等性を考慮しない素朴なサービスを書いてみる。

#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PaymentResult {
    Success { balance: i64 },
    InsufficientFunds { balance: i64 },
    DuplicateRequest { original_result: Box<PaymentResult> },
}

pub struct NaivePaymentService {
    balance: i64,
    operations: Vec<(String, i64)>,
}

impl NaivePaymentService {
    pub fn new(initial_balance: i64) -> Self {
        Self {
            balance: initial_balance,
            operations: Vec::new(),
        }
    }

    pub fn charge(&mut self, amount: i64, description: &str) -> PaymentResult {
        if self.balance < amount {
            return PaymentResult::InsufficientFunds {
                balance: self.balance,
            };
        }
        self.balance -= amount;
        self.operations.push((description.to_string(), amount));
        PaymentResult::Success {
            balance: self.balance,
        }
    }
}

何の防御もない。chargeを呼ぶたびに残高が減る。テストで確認する。

#[test]
fn naive_service_double_charges_on_retry() {
    let mut svc = NaivePaymentService::new(1000);

    let r1 = svc.charge(100, "order-123");
    assert_eq!(r1, PaymentResult::Success { balance: 900 });

    // "ネットワークタイムアウト" -- クライアントが同じ操作をリトライ
    let r2 = svc.charge(100, "order-123");
    assert_eq!(r2, PaymentResult::Success { balance: 800 });

    // 残高が1000→800。本来は900であるべき
    assert_eq!(svc.balance(), 800);
    assert_eq!(svc.operation_count(), 2);
}

残高は1000から900になるはずが、800になった。同じ注文に対して100円が2回引かれている。ユーザーからすれば、タイムアウトでリトライしただけなのに二重課金された。

これは架空の例ではない。決済システムで実際に起こる障害パターンだ。

冪等性キーの導入

解決策は、リクエストにユニークなIDを付与し、サーバー側でそのIDに紐づく処理結果をキャッシュすること。同じIDのリクエストが再度来たら、処理を実行せずキャッシュされた結果を返す。

pub struct IdempotentPaymentService {
    balance: i64,
    operations: Vec<(String, i64)>,
    idempotency_store: HashMap<String, PaymentResult>,
}

impl IdempotentPaymentService {
    pub fn charge(
        &mut self,
        idempotency_key: &str,
        amount: i64,
        description: &str,
    ) -> PaymentResult {
        // 既に処理済みならキャッシュを返す
        if let Some(cached) = self.idempotency_store.get(idempotency_key) {
            return PaymentResult::DuplicateRequest {
                original_result: Box::new(cached.clone()),
            };
        }

        // 本来のビジネスロジック
        let result = if self.balance < amount {
            PaymentResult::InsufficientFunds {
                balance: self.balance,
            }
        } else {
            self.balance -= amount;
            self.operations.push((description.to_string(), amount));
            PaymentResult::Success {
                balance: self.balance,
            }
        };

        // 結果をキャッシュ
        self.idempotency_store
            .insert(idempotency_key.to_string(), result.clone());

        result
    }
}

idempotency_storeHashMap<String, PaymentResult>で、キーに対する処理結果を保持する。同じキーで2回目以降のリクエストが来たらDuplicateRequestを返し、残高は一切触らない。

冪等性キーの実装は単純に見えるが、ビジネスロジック(残高の変更)と冪等性レコードの保存が原子的に行われている点が重要だ。この原子性が崩れるとどうなるかは、次のTOCTOUの節で詳しく見る。

#[test]
fn idempotent_service_handles_retry() {
    let mut svc = IdempotentPaymentService::new(1000);

    let r1 = svc.charge("idem-key-001", 100, "order-123");
    assert_eq!(r1, PaymentResult::Success { balance: 900 });

    // 同じ冪等性キーでリトライ
    let r2 = svc.charge("idem-key-001", 100, "order-123");
    assert!(matches!(r2, PaymentResult::DuplicateRequest { .. }));

    // 残高は900のまま。二重課金なし
    assert_eq!(svc.balance(), 900);
    assert_eq!(svc.operation_count(), 1);
}

今度は残高が900で止まっている。リトライしても処理は1回だけ。

当然、異なる冪等性キーは独立した操作として扱われる。

#[test]
fn different_idempotency_keys_are_independent() {
    let mut svc = IdempotentPaymentService::new(1000);

    svc.charge("key-1", 100, "order-1");
    svc.charge("key-2", 200, "order-2");
    svc.charge("key-3", 300, "order-3");

    assert_eq!(svc.balance(), 400);
    assert_eq!(svc.operation_count(), 3);
}

キーが違えば別の操作。キーが同じならリトライ。この区別がクライアント側で制御できるのが冪等性キーの設計上の利点だ。

同一トランザクション内での処理が必須

ここまでの実装は単一スレッドで動作する前提だった。並行処理が絡むと、冪等性チェックとビジネスロジックの間にレースコンディションが生じる。

典型的な失敗パターンを見てみる。

pub struct TocTouVulnerableService {
    balance: Arc<Mutex<i64>>,
    idempotency_store: Arc<Mutex<HashMap<String, PaymentResult>>>,
    operation_count: Arc<Mutex<usize>>,
}

impl TocTouVulnerableService {
    pub fn charge_with_toctou_gap(
        &self,
        idempotency_key: &str,
        amount: i64,
    ) -> PaymentResult {
        // ステップ1: 冪等性チェック(別のロック)
        {
            let store = self.idempotency_store.lock().expect("lock poisoned");
            if let Some(cached) = store.get(idempotency_key) {
                return PaymentResult::DuplicateRequest {
                    original_result: Box::new(cached.clone()),
                };
            }
        }
        // <-- ここがTOCTOUウィンドウ。別スレッドがチェックをすり抜ける

        // ステップ2: 決済処理(別のロック)
        let result = {
            let mut bal = self.balance.lock().expect("lock poisoned");
            if *bal < amount {
                PaymentResult::InsufficientFunds { balance: *bal }
            } else {
                *bal -= amount;
                let mut count = self.operation_count.lock().expect("lock poisoned");
                *count += 1;
                PaymentResult::Success { balance: *bal }
            }
        };

        // ステップ3: 結果キャッシュ(別のロック)
        {
            let mut store = self.idempotency_store.lock().expect("lock poisoned");
            store.insert(idempotency_key.to_string(), result.clone());
        }

        result
    }
}

3つのステップがそれぞれ独立したロックで保護されている。ステップ1のロックを解放してからステップ2のロックを取得するまでの間に、別のスレッドがステップ1を通過できてしまう。

これがTOCTOU(Time-of-Check to Time-of-Use)問題だ。「冪等性キーがすでに使われていないか確認した時点(check)」と「実際に残高を更新する時点(use)」の間にギャップがあり、そのギャップの間に状態が変わりうる。Unix系のシンボリックリンク攻撃やファイル権限チェックでも同じ名前で議論される、定番の脆弱性パターンだ。

データベースに置き換えると、冪等性レコードのSELECTと、残高UPDATEと、冪等性レコードのINSERTが別々のトランザクションで実行されるのと同じ状況だ。

アトミックな冪等性サービス

修正は明快で、すべてを単一のロック(=単一のトランザクション)に収める。

pub struct AtomicIdempotentService {
    state: Arc<Mutex<ServiceState>>,
}

struct ServiceState {
    balance: i64,
    operation_count: usize,
    idempotency_store: HashMap<String, PaymentResult>,
}

impl AtomicIdempotentService {
    pub fn charge(&self, idempotency_key: &str, amount: i64) -> PaymentResult {
        let mut state = self.state.lock().expect("lock poisoned");

        // チェック + 処理 + キャッシュを単一のクリティカルセクション内で
        if let Some(cached) = state.idempotency_store.get(idempotency_key) {
            return PaymentResult::DuplicateRequest {
                original_result: Box::new(cached.clone()),
            };
        }

        let result = if state.balance < amount {
            PaymentResult::InsufficientFunds {
                balance: state.balance,
            }
        } else {
            state.balance -= amount;
            state.operation_count += 1;
            PaymentResult::Success {
                balance: state.balance,
            }
        };

        state
            .idempotency_store
            .insert(idempotency_key.to_string(), result.clone());

        result
    }
}

TocTouVulnerableServiceとの違いは、Arc<Mutex<ServiceState>>で状態全体を1つのロックに入れたこと。前節で示したTOCTOUウィンドウが消え、チェック・処理・キャッシュが不可分になる。データベースなら同一トランザクション、ここでは同一ロックが、その原子性を担っている。

10スレッド同時リクエストのテスト

AtomicIdempotentServiceが並行リクエストに耐えることを、10スレッドの同時実行で確認する。

#[test]
fn atomic_service_prevents_concurrent_double_charge() {
    use std::sync::{Arc, Barrier};
    use std::thread;

    let svc = AtomicIdempotentService::new(1000);
    let barrier = Arc::new(Barrier::new(10));
    let mut handles = Vec::new();

    // 10スレッドが全く同じキーで同時にcharge
    for _ in 0..10 {
        let svc = svc.clone();
        let barrier = Arc::clone(&barrier);
        handles.push(thread::spawn(move || {
            barrier.wait(); // 全スレッドの足並みを揃える
            svc.charge("same-key", 100)
        }));
    }

    let results: Vec<_> = handles.into_iter().map(|h| h.join().unwrap()).collect();

    let success_count = results
        .iter()
        .filter(|r| matches!(r, PaymentResult::Success { .. }))
        .count();
    let duplicate_count = results
        .iter()
        .filter(|r| matches!(r, PaymentResult::DuplicateRequest { .. }))
        .count();

    assert_eq!(success_count, 1, "exactly one should succeed");
    assert_eq!(duplicate_count, 9, "rest should be duplicates");
    assert_eq!(svc.balance(), 900, "balance should reflect exactly one charge");
    assert_eq!(svc.operation_count(), 1);
}

Barrierで10スレッドの開始タイミングを揃え、同一キー"same-key"で同時にchargeを呼ぶ。結果は、Successが正確に1件、DuplicateRequestが9件。残高は900。10回呼んでも100円しか引かれない。

TocTouVulnerableServiceで同じテストを走らせると、タイミング次第で2件以上のSuccessが出る可能性はある。スレッドスケジューリングは非決定的なので毎回再現するわけではない。しかしコード構造上、TOCTOUウィンドウは確実に存在する。

Stripeの冪等性キーパターン

Stripeの決済APIは冪等性キーの実装として広く参照されている。クライアントがIdempotency-KeyヘッダにUUIDを付与してリクエストを送る。サーバーは以下のように処理する。

  1. リクエスト受信時に冪等性キーでレコードを検索
  2. レコードが存在すれば、キャッシュされたレスポンスをそのまま返す
  3. レコードが存在しなければ、ビジネスロジックを実行し、レスポンスとともにレコードを保存

重要な点がいくつかある。

まず、キーの有効期限。Stripeは24時間で冪等性レコードを期限切れにする。永続的に保持するとストレージは無限に膨らむ。24時間以内にリトライしないクライアントの方が問題だ。

次に、進行中のリクエストのロック。同一キーのリクエストが並行して到着した場合、Stripeでは409 Conflictになりうる。ただし、この衝突結果は通常の冪等性レコードとして保存されない。クライアントは同じキーで再試行できる。重要なのは、ロック取得とビジネスロジックが同一トランザクション境界で扱われるところだ。先述のアトミックサービスと同じ構造になる。

失敗もキャッシュする

見落としがちだが、冪等性キーは失敗結果もキャッシュする。残高不足で決済が失敗した場合、その失敗結果をキャッシュしておく。同じキーでリトライされたら、残高を再チェックせずにキャッシュされた失敗を返す。

#[test]
fn idempotent_service_caches_failure_too() {
    let mut svc = IdempotentPaymentService::new(50);

    // 残高50に対して100の課金 → 残高不足
    let r1 = svc.charge("key-fail", 100, "big-order");
    assert!(matches!(r1, PaymentResult::InsufficientFunds { .. }));

    // 同じキーでリトライ → キャッシュされた失敗を返す
    let r2 = svc.charge("key-fail", 100, "big-order");
    assert!(matches!(r2, PaymentResult::DuplicateRequest { .. }));
    if let PaymentResult::DuplicateRequest { original_result } = r2 {
        assert!(matches!(
            *original_result,
            PaymentResult::InsufficientFunds { .. }
        ));
    }
}

なぜ失敗もキャッシュするのか。仮に失敗をキャッシュしないと、リトライ時に残高が変わっていた場合に異なる結果が返る。最初のリクエストでは残高不足で失敗、リトライ時にはたまたま入金があって成功、ということが起こりうる。クライアントは「同じ操作をリトライした」つもりなのに、1回目と2回目で異なる副作用が発生する。

冪等性の定義は「同じ操作を何回実行しても結果が同じ」だ。成功だけでなく失敗も含めて「同じ結果」を返さないと、この定義を満たさない。

ただし、これはStripeの実装でも議論がある。「残高不足は一時的なエラーなので、リトライ時に再評価すべき」という立場もある。どちらが正しいかはユースケース次第で、一概には言えない。決済のコンテキストでは「同一オペレーションの冪等性」を優先し、残高が変わった時は新しい冪等性キーで別リクエストとして送信させるのが一般的だ。

proptestで不変条件を検証する

テストケースを手で書くのには限界がある。「任意の初期残高、任意の課金額、任意のリトライ回数で、冪等性が保証されること」をproperty-based testingで検証する。

use proptest::prelude::*;

proptest! {
    #[test]
    fn idempotent_retry_never_changes_balance(
        initial_balance in 1000i64..10000,
        amount in 1i64..500,
        num_retries in 1usize..50,
    ) {
        let mut svc = IdempotentPaymentService::new(initial_balance);

        let first = svc.charge("test-key", amount, "test");
        let expected_balance = match first {
            PaymentResult::Success { balance } => balance,
            PaymentResult::InsufficientFunds { balance } => balance,
            _ => panic!("first call should not be duplicate"),
        };

        for _ in 0..num_retries {
            svc.charge("test-key", amount, "test");
        }

        prop_assert_eq!(svc.balance(), expected_balance);
        prop_assert!(svc.operation_count() <= 1);
    }
}

初期残高が1000〜10000、課金額が1〜500、リトライ回数が1〜50の範囲でランダムに生成される。最初の1回でどんな結果になろうと、その後何回リトライしても残高は変わらない。operation_countは最大1。

proptestはデフォルトで256ケースを生成する。256通りの組み合わせすべてで、残高の不変条件が保たれることを確認した。

もう1つ、異なるキーが独立に処理されることも検証する。

proptest! {
    #[test]
    fn unique_keys_process_independently(
        num_operations in 1usize..20,
        amount in 1i64..100,
    ) {
        let initial = (num_operations as i64) * amount + 1000;
        let mut svc = IdempotentPaymentService::new(initial);

        for i in 0..num_operations {
            let key = format!("key-{i}");
            let result = svc.charge(&key, amount, "op");
            prop_assert!(matches!(result, PaymentResult::Success { .. }));
        }

        let expected = initial - (num_operations as i64) * amount;
        prop_assert_eq!(svc.balance(), expected);
        prop_assert_eq!(svc.operation_count(), num_operations);
    }
}

初期残高を十分に確保した上で、N個の異なるキーで課金する。すべて成功し、残高は正確にN回分減る。キーが異なれば独立した操作として処理される、という性質がランダムな入力で成立することを確認した。

「副作用を持つPOST」すべての基盤として扱う

冪等性キーは「リトライしても安全」に見せるためのHTTPヘッダではない。Webサービス全体で、論理操作のIDをどこで作り、どの境界まで同じ操作として扱うかを決める設計だ。API Gateway、アプリケーションサーバー、DB、キュー、外部決済APIのどこかで境界が途切れると、キーはただの文字列になる。

Rustで実装するなら、Stringをそのまま引き回すよりIdempotencyKeyのようなニュータイプにする方がよい。リクエストID、注文ID、冪等性キーは全部文字列に見えるが、意味は違う。型を分けるだけで、注文IDを冪等性キーとして保存するような取り違えをコンパイル時に潰せる。さらに、PaymentResultのような戻り値もenumで表す。成功、残高不足、重複、実行中衝突、パラメータ不一致を文字列ステータスに潰すと、呼び出し側のリトライ戦略が曖昧になる。

運用面では、冪等性ストアのTTL、パラメータハッシュ、実行中ロック、レスポンスキャッシュ、メトリクスを最初から設計する。特に現代のWebサービスでは、HTTPリトライ、ロードバランサの再送、ジョブキューのat-least-once配送、外部APIのタイムアウトが重なる。冪等性キーは決済APIだけの話ではなく、注文作成、メール送信、ポイント付与、Webhook処理のような「副作用を持つPOST」すべてで考えるべき基盤になる。

実装上の注意点

冪等性キーの仕組み自体は単純だが、本番環境ではいくつか考慮する点がある。

まずキーの生成について。クライアント側でUUID v4を生成するのが一般的だ。UUID v7を使えばタイムスタンプが先頭に入るため、データベースのインデックス効率が上がる。サーバー側で生成するとクライアントがリトライ時に同じキーを送れないため、キーの生成責務はクライアントに持たせる。同一の論理操作に対して同一のキーを使い続けることが前提だ。

冪等性レコードの保存先も重要になる。残高テーブルと同じデータベースに置くのが原則で、別のデータベースやRedisに分離すると、障害時に不整合が生じる。残高更新と冪等性レコード書き込みが同一トランザクションでコミットされなければ、原子性の議論が根底から崩れる。

Brandur Leachの記事では、冪等性キーの管理とビジネスロジックを一体化する手法が解説されている。PostgreSQLのトランザクション内で処理する設計だ。

レコードの寿命も設計が必要だ。永続的に保持するとストレージが無限に膨らむ。Stripeは24時間のTTLを採用しているが、ユースケースによっては7日程度が妥当な場合もある。バッチ削除かデータベースのTTL機能で期限切れレコードを掃除する。

最後に、同じ冪等性キーで異なるパラメータが送られた時の扱いがある。金額が1回目と2回目で違うケースだ。Stripeは、最初のリクエストとパラメータが一致しない時は誤用防止のためエラーにする、と公式ドキュメントで説明している。

HTTPステータスを自前のAPI設計に落とすなら、実行中の同一キーには409 Conflictを使う。完了済みキーのパラメータ不一致には400 Bad Request409 Conflictのように、クライアントの再送設計と監視で判別しやすい形にするのがよい。キャッシュされた結果をそのまま返すのは危険で、「同じキーなのにリクエスト内容が違う」のはクライアント側のバグとして扱う。

この実装は単一プロセス内のシミュレーションだ。Mutexで原子性を保証しているが、本番環境ではデータベーストランザクションが同じ役割を果たす。しかし、データベーストランザクションにも限界がある。複数のマイクロサービスをまたぐ操作では、単一トランザクションでは冪等性を保証できない。Sagaパターンや補償トランザクションとの組み合わせが必要になる。

Outboxパターン: 副作用との原子化

決済の成功と、それを下流に通知するイベントの発行を別トランザクションで扱うと、すぐ事故になる。残高は引いたがイベントは飛ばなかった、イベントは飛んだが残高はロールバックされた、どちらも起きる。Transactional Outbox は「副作用そのものを送らず、副作用を発行したい事実をoutboxテーブルに書き込む」ことで原子化する。残高更新とoutbox書き込みは同一トランザクション、別プロセスがoutboxを読んで実際にイベントを送る。

pub fn charge_and_emit(
    &self,
    idempotency_key: &str,
    amount: i64,
    event_payload: &str,
) -> OutboxResult {
    let mut state = self.state.lock().expect("lock poisoned");
    // 冪等性チェック: Successなら Duplicate へ変換、失敗キャッシュはそのまま返す
    if let Some(cached) = state.idempotency_store.get(idempotency_key) {
        return /* Duplicate or cached failure */;
    }
    if state.balance < amount { return /* InsufficientFunds */; }
    state.balance -= amount;
    // 残高更新と「同一ロック内で」outboxにイベントを書き込む
    let outbox_id = state.next_outbox_id;
    state.next_outbox_id += 1;
    state.outbox.push(OutboxEntry { id: outbox_id, payload: event_payload.into(), published: false });
    ...
}

publisher側は冪等な配送(at-least-once + 受信側の冪等性キー)でよい。「副作用と状態更新を同一トランザクションで括る」というのが Outbox の核だ。DB-内 / DB→Kafka / DB→外部API といった境界ごとに具体的なツール(Debezium の logical replication CDC、Kafka transactional producer による consumer-then-produce の原子化など)は変わるが、構造は同じだ。

Durable Execution: 冪等性をランタイムへ

Temporalは2019年から本番採用が進んでおり、RestateとDBOSが2023年に登場して選択肢が広がった。durable executionのアイデアは共通で、アプリケーションのコードを書く側は普通の関数を書くだけで、エンジン側がステップ単位の実行ログを永続化し、クラッシュ後の再開時に「既に完了したステップは結果を replay する」「未完了のステップだけ再実行する」。冪等性キーやoutboxを呼び出し側が逐一書く代わりに、workflow ID(冪等性キー相当)とactivity単位の実行ログが事実上同じ役割を果たす。

www.restate.dev

3つの違いは主に運用面で、Temporalは専用クラスタ(Cassandra/PostgreSQL/MySQLバックエンド)、RestateはRestate Serverが間に立ってHTTP/gRPCで実行を記録、DBOSはPostgreSQLをそのまま実行ログのストレージにする。決済・予約・ワークフロー処理のような「マルチステップ+外部API+失敗復帰」のパスでは、自分で冪等性キーとoutboxを設計するより、durable execution層に委ねる手がある。自分はTemporalを少し触っただけで本番投入の経験はない。冒頭で半日かけてログを突き合わせた作業を、エンジンの実行ログに肩代わりさせられるなら、その魅力は大きい。ただし workflow(オーケストレーション)コードは決定論的でなければならない(activity は非決定論でよい)という制約が新たに加わる。

at-least-onceからexactly-onceへの変換という視点では、Apache KafkaのExactly-once semanticsも同じ問題に取り組んでいる。KafkaのProducer IDとSequence Numberは、メッセージングレイヤーにおける冪等性キーと本質的に同じ構造だ。

冪等性キーは、ネットワークの不確実性に対する防御壁だ。at-least-once(少なくとも1回は届くが、何回届くかは保証しない)の配送をexactly-once(結果として1回だけ反映されたかのように見せる)のセマンティクスに変換する。クライアント側のリトライ、ロードバランサの再送、メッセージキューの再配送——どれもat-least-onceの世界の話で、その上にexactly-onceを成立させる薄い1枚が冪等性キーだ。ただし、この変換が正しく機能するのは、チェック・処理・記録が原子的に行われる場合に限る。

形式ではなく、原子性こそが本質だ。冒頭の翌朝のラッシュには、もう戻りたくない。