1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

「冪等性とは何か」を実演で再定義する — 「2回送っても同じ結果になります」では半分しか答えていない

1
Posted at

なぜこの記事を書いたか

「冪等性 (idempotency) とは何ですか / なぜ重要ですか」は、今までの自分はこう答えていた。

「同じリクエストを何回送っても、結果が変わらない性質のことです」

この回答は 半分しか合っていない

  • 「結果が変わらない」とは何のこと? レスポンスか、副作用か?
  • 「HTTP メソッドの冪等性」と「業務処理の冪等性」は同じ話か?
  • 「冪等キー」を入れれば自動で安全になるのか?
  • 並列リクエストで「先に SELECT、なければ INSERT」をやると、本当に1回しか処理されないのか?

決済 API を実装するようになってから、これらが 別々の問題で、別々の解法が要る ことに気付いた。
本記事は、Rails + SQLite で実装した3段階の冪等性サービスを 並列リクエストで実測 して、教科書回答の何が足りなかったかを整理した記事です。

実測の結論を先に言うと、素朴な「SELECT then INSERT」だと並列実行で 10 リクエスト中 4 件が重複作成された。これが冪等性を「言葉だけで」分かっているのと「実装できる」レベルの差。

教科書回答とその限界

Q. 冪等性とは?
A. 同じリクエストを何度送っても同じ結果になる性質。POST は冪等じゃない、PUT は冪等。

これは事実だが、3つの別物がごっちゃ になっている。

レイヤー 何の話か
① HTTP メソッドの冪等性 プロトコル仕様 RFC 9110: GET/PUT/DELETE は冪等、POST は非冪等
② 業務処理の冪等性 アプリケーションの効果 「同じ注文番号で 2 回課金されない」
③ 冪等キーの仕組み ②を達成する実装パターン Idempotency-Key ヘッダ

たとえば「POST /charges は HTTP 仕様上は非冪等」だが、業務上は「同じ注文を2回課金したくない」。
このギャップを埋めるのが ③ の冪等キー。3つを混ぜずに語れるか が中堅の境目だと思う。

① HTTP メソッドの冪等性 — プロトコル仕様

RFC 9110 §9.2.2 Idempotent Methods
原典:

A request method is considered "idempotent" if the intended effect on the server of multiple identical requests with that method is the same as the effect for a single such request.

GET / HEAD / PUT / DELETE / OPTIONS / TRACE は 冪等。POST / CONNECT は 非冪等

ここで大事な注意点:

Idempotence and safety are separate properties. While safe methods are always idempotent, idempotent methods need not be safe.

「safe」(読み取り専用)と「idempotent」(複数回 = 1 回)は別の概念
PUT/DELETE は idempotent だが safe ではない(サーバの状態を変える)。

つまり HTTP の冪等性は 「同じ HTTP リクエストを再送しても、同じ最終状態に収束する」 というプロトコル仕様の定義。具体的に「課金が二重にならない」を保証するわけではない。

② 業務処理の冪等性 — 「2回課金されない」

決済システムでは、こういう障害がいくらでも起きる:

  • クライアントが POST /charges を送った
  • サーバは課金を完了したが、レスポンス送信前にネットワークが切れた
  • クライアントは「失敗した」と思い、リトライする
  • サーバは2回目の POST を受け、2回課金してしまう

HTTP の仕様上、POST はそもそも冪等じゃないので リトライしてはいけない はずだが、「リトライしないと一時障害から復旧できない」 という現実問題がある。

ここで必要になるのが 「業務処理を冪等にする仕組み」 = ③ 冪等キー。

③ 冪等キーの仕組み

クライアントが リクエストごとにユニークなキー を生成して送る。
サーバはそのキーを覚えておき、同じキーが来たら 新規処理せず、前回の結果を返す

Stripe のドキュメント が代表例:

Stripe's idempotency works by saving the resulting status code and body of the first request made for any given idempotency key, regardless of whether it succeeds or fails. Subsequent requests with the same key return the same result, including 500 errors.

ポイント3つ:

  1. status code と body を保存する: 同じキーには同じレスポンスを返す
  2. 成功・失敗を問わず保存: 「500 を返した」も含めて再現
  3. キーはクライアントが生成: サーバは検査するだけ

クライアントは UUID v4 などのランダムな ID を使う。

How you create unique keys is up to you, but we suggest using V4 UUIDs, or another random string with enough entropy to avoid collisions.

ここからが実装の難しさ — 3段階の実演

「冪等キーを保存しておけば良い」だけだと、並列リクエストで壊れる
これを実機で見せる。

実験設計

  • Ruby 3.4.5 / Rails 7.2.3.1 / SQLite (WAL モード)
  • Charge モデル: amount, status, idempotency_key, request_hash
  • 3段階のサービスを実装し、同じキーで 10 リクエスト を投げる
段階 戦略
Level 0 冪等性なし。来たら毎回 INSERT
Level 1 (Naive) SELECT → なければ INSERT
Level 2 (Safe) UNIQUE 制約 + RecordNotUnique 捕捉

Level 0: 冪等性なし

class NoIdempotencyService
  def self.create_charge(amount:, **)
    Charge.create!(amount: amount, status: "succeeded")
  end
end

何も考えず INSERT する。リトライされたら必ず重複する。

Level 1: 素朴な SELECT then INSERT

class NaiveIdempotencyService
  def self.create_charge(amount:, idempotency_key:, **)
    existing = Charge.find_by(idempotency_key: idempotency_key)
    return existing if existing

    sleep 0.01  # ← race window を観察可能にするため

    Charge.create!(
      amount: amount, status: "succeeded",
      idempotency_key: idempotency_key
    )
  end
end

「あれば返す、なければ作る」。逐次なら動く。並列だと壊れる

Level 2: UNIQUE 制約 + 衝突捕捉

class SafeIdempotencyService
  class ConflictError < StandardError; end

  def self.create_charge(amount:, idempotency_key:, request_hash:, **)
    sleep 0.01  # naive と条件を揃える

    Charge.create!(
      amount: amount, status: "succeeded",
      idempotency_key: idempotency_key,
      request_hash: request_hash
    )
  rescue ActiveRecord::RecordNotUnique
    existing = Charge.find_by!(idempotency_key: idempotency_key)
    if existing.request_hash != request_hash
      raise ConflictError, "Same key used with different body"
    end
    existing
  end
end

DB の UNIQUE 制約 に判断を委ねる。同じキーで衝突したら例外を捕まえて、既存レコードを返す。

マイグレーションでの制約

add_index :charges, :idempotency_key,
          unique: true,
          where: "idempotency_key IS NOT NULL"  # PG/SQLite では部分インデックスが使える

実測結果

シナリオ: 同じ idempotency_key='key-ABC-123' のリクエストを 10 回投げる
サービス 逐次 10 回 並列 10 スレッド
NoIdempotencyService 10 件 10 件
NaiveIdempotencyService 1 件 4 件 ← race condition
SafeIdempotencyService 1 件 1 件

Naive が壊れる理由

スレッド A: SELECT idempotency_key='K' → NIL(まだ無い)
スレッド B: SELECT idempotency_key='K' → NIL(まだ無い)
スレッド A: INSERT charges (..., 'K')   ← 成功
スレッド B: INSERT charges (..., 'K')   ← 制約がないので**これも成功**

Charge.find_byCharge.create! の間に、別スレッドが INSERT を完了させる
race window が存在する。アプリ層だけで防ぐのは事実上不可能。

Safe が動く理由

スレッド A: INSERT charges (..., 'K')   ← 成功
スレッド B: INSERT charges (..., 'K')   ← UNIQUE 制約違反 → RecordNotUnique
           → rescue で SELECT 1 件取って既存を返す

判断を DB の UNIQUE 制約 に任せる。
アプリ層で「あるか確認」をやってはいけない、というのが現場の教訓。

同じキーで違う body が来たとき

シナリオ: 同じ key で 1000円 → その後 9999円 を投げる
1回目: amount=1000 → 成功 (charges=1)
2回目: amount=9999 → ConflictError で拒否 ✅
       error: Same key 'K1' used with different body
最終 Charge: {"amount" => 1000, "idempotency_key" => "K1"}

Stripe も同じ仕様:

The idempotency layer compares incoming parameters to those of the original request and errors if they're not the same to prevent accidental misuse.

これがないと、「同じ key を使い回して別のリクエストを送る」事故 が起きる。
リクエストボディの SHA256 ハッシュを保存しておき、衝突時に比較する。

TTL(キーの有効期間)

冪等キーを 永遠に保持 すると、テーブルがどんどん膨らむ。
Stripe は 24 時間という目安を公開している:

You can remove keys from the system automatically after they're at least 24 hours old. We generate a new request if a key is reused after the original is pruned.

設計時に決めること:

TTL 影響
短すぎる(数分) クライアントのリトライが間に合わず、重複処理が起きる
長すぎる(数ヶ月) テーブル肥大、過去キーとの偶発衝突リスク
Stripe 推奨: 24 時間 一般的なリトライウィンドウとの整合

実装は cron / sidekiq-scheduler で Charge.where("created_at < ?", 24.hours.ago).delete_all を流す。

4. Webhook 受信側の冪等性

ここまでは クライアント → サーバ の話。
サーバ → サーバ(Webhook) の場合は、向きが逆になる。

たとえば Paygent からの通知 Webhook:

  • ネットワーク障害で送信側がリトライ → 同じイベントが2回届く
  • 受信側が「2回処理」してしまうとデータが壊れる

対策は同じ構造で、「イベント ID」をクライアント送信ではなくサーバ送信で管理:

class WebhookEvent < ApplicationRecord
  # vendor_event_id: ベンダーが付けたユニークID
  # processed_at: 処理完了タイムスタンプ
end

# Webhook 受信ハンドラ
def handle(event_id, payload)
  WebhookEvent.create!(vendor_event_id: event_id, payload: payload.to_json)
  # 以降の処理
rescue ActiveRecord::RecordNotUnique
  # 既に処理済みのイベント。ベンダーには 200 OK を返す(再送を止めるため)
  return :already_processed
end

ポイント:

  1. 「処理は失敗したがイベント記録は成功」を避けるため、イベント記録と本処理は同一トランザクション
  2. 重複検出時もベンダーには 200 を返す(4xx / 5xx だとベンダーがリトライを続けてしまう)
  3. イベントIDがベンダーから来ない場合は、ペイロードの SHA256 で代用

現在の判断軸

冪等性を必要とする業務で、自分が必ず確認する点:

1. このエンドポイントは「重複してはいけない副作用」を持つか?
   ├ Yes → 冪等キーが必要
   └ No  → 不要(読み取りなど)

2. クライアント or サーバが生成するキーか?
   ├ Client → Idempotency-Key ヘッダ(Stripe 方式)
   └ Server → DB 主キー、または受信側で発番

3. 並列リクエストへの対処
   ├ アプリ層 SELECT-then-INSERT → ✗ 壊れる
   ├ DB UNIQUE 制約 + 例外捕捉 → ✓ 正解
   └ pessimistic lock (SELECT FOR UPDATE) → ✓ 別解(保守コスト高い)

4. 同じキー × 違う body は?
   └ 必ず拒否する(リクエストハッシュで比較)

5. キーの TTL は?
   └ クライアントのリトライ猶予より長く、無限にはしない(24h が無難)

6. レスポンスの再現性は?
   └ status code + body を保存。500 も保存(クライアントが同じエラーで挙動を決められる)

更新できたこと

1〜3年目 現在
冪等性の定義 同じリクエスト = 同じ結果 「副作用が1回だけ起きる」と「レスポンスが再現される」を分けて考える
HTTP メソッド POST は非冪等、PUT は冪等 プロトコルの話と業務の話を 混ぜない
実装 アプリで SELECT then INSERT DB UNIQUE 制約 + 例外捕捉。アプリ層判定は race に勝てない
同じキー = 同じ結果 「同じレスポンスを返す」 異なる body は拒否まで含めて初めて冪等
Webhook 別問題と思っていた 構造は同じ(受信側のキー管理)

特に 「アプリ層 SELECT-then-INSERT は並列で壊れる」 を実測で見たのが大きい。
言葉では知っていたが、10 件中 4 件が重複した数字を見て、二度とこの実装は書かないと決めた。

参考

再現コード(抜粋)

Safe な実装

class SafeIdempotencyService
  class ConflictError < StandardError; end

  def self.create_charge(amount:, idempotency_key:, request_hash:)
    Charge.create!(
      amount: amount, status: "succeeded",
      idempotency_key: idempotency_key,
      request_hash: request_hash
    )
  rescue ActiveRecord::RecordNotUnique
    existing = Charge.find_by!(idempotency_key: idempotency_key)
    raise ConflictError if existing.request_hash != request_hash
    existing
  end
end

# マイグレーション
add_index :charges, :idempotency_key,
          unique: true,
          where: "idempotency_key IS NOT NULL"

並列実演

threads = 10.times.map do
  Thread.new do
    ActiveRecord::Base.connection_pool.with_connection do
      SafeIdempotencyService.create_charge(
        amount: 1000,
        idempotency_key: "key-ABC-123",
        request_hash: "hash-X"
      )
    end
  end
end
threads.each(&:join)
puts Charge.count  # => 1

実測結果

同じ idempotency_key='key-ABC-123' を 10 回投げる:

NoIdempotencyService      逐次=10件  並列=10件
NaiveIdempotencyService   逐次= 1件  並列= 4件  ← race condition!
SafeIdempotencyService    逐次= 1件  並列= 1件  ✅
1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?