あなたのサービスが POST /charge リクエストの途中でクラッシュした。クライアントはタイムアウトを検知してリトライする。結果として2件の課金が発生する。顧客は怒っている。データベースは整合性を保っている。だがビジネスロジックは保っていない。

これはエッジケースではない。分散システムのデフォルトの挙動だ。ネットワークはパケットを落とす。コンテナはリクエストの途中でOOMキルされる。ロードバランサーは、すでにバックエンドに到達したリクエストに対して502を返すことがある。もしあなたのAPIが「エラーが返ってきたから、処理は確実に実行されていない」と仮定しているなら、あなたはバグの工場を作っているのだ。

回避不可能な at-least-once の現実

HTTPはデフォルトでat-least-onceだ。TCPは消失したパケットをリトライする。HTTPクライアントはタイムアウト時にリトライする。インフラは5xx時にリトライする。すべてのレイヤーは、前のレイヤーが失敗する可能性があると仮定して再送信する。

問題はリトライではない。問題は冪等でない操作だ。

GET は2回読んでも1回読んでも同じなので、リトライしても安全だ。しかし POST /chargePOST /orders はそうではない。2回実行すると2つのリソースが作成される。0回実行すると売上を失う。信頼性の低いネットワーク上で「exactly once」を選ぶことはできない。選べるのは、重複排除付きの「at least once」か、データ破損を伴う「0回かも、2回かも」かのいずれかだけだ。

idempotency(冪等性)とは、「at least once」を安全にする方法だ。

idempotency keyの実際の動作

Stripeがこのパターンを普及させたが、そのアイデア自体はもっと古い。クライアントは一意なキー(UUID)を生成し、ヘッダーで送信する: Idempotency-Key: <uuid>。サーバーは副作用を実行する前に、タプル (key, request_body, response) を保存する。同じキーが再度到着した場合、サーバーは保存されたレスポンスを返し、操作を再実行しない。

重要な洞察: サーバーは処理を実行する前にキーを保存しなければならない。後ではない。課金行を先に書き込んでキーを保存する前にクラッシュした場合、リトライによって2件目の課金が作成される。idempotency keyの保存とビジネス上の変更は、原子的でなければならない。

実際には、以下の2つのいずれかを意味する:

  1. idempotency store とビジネス store がデータベーストランザクションを共有する。 同じ BEGIN ... COMMIT の中で idempotency_keyscharges に挿入する。コミットが成功すれば両方存在する。失敗すればどちらも存在しない。

  2. idempotency store がビジネス store そのものである。 charges テーブルに UNIQUE 制約付きの client_idempotency_key カラムを持たせる。リトライは一意性チェックに失敗し、既存の行を返す。

オプション2はよりシンプルで、ほとんどのチームがそこから始めるべきだ。

原子的ストレージを使った実用的な実装

以下は、SQLiteを使った最小かつ完全なPythonサーバーの実装だ。idempotency keyはビジネス上の変更と同じトランザクション内に存在する。サーバーが COMMIT 後にクラッシュした場合、リトライはキャッシュにヒットする。COMMIT 前にクラッシュした場合、何も永続化されず、リトライは安全に再実行される。

import sqlite3
import json
import uuid

DB_PATH = "/data/inventory.db"

def init_db():
    conn = sqlite3.connect(DB_PATH)
    conn.execute("""
        CREATE TABLE IF NOT EXISTS reservations (
            id TEXT PRIMARY KEY,
            item_id TEXT NOT NULL,
            quantity INTEGER NOT NULL,
            idempotency_key TEXT UNIQUE NOT NULL,
            created_at INTEGER
        )
    """)
    conn.execute("""
        CREATE TABLE IF NOT EXISTS idempotency_responses (
            key TEXT PRIMARY KEY,
            response_body TEXT NOT NULL,
            created_at INTEGER
        )
    """)
    conn.commit()
    return conn

def reserve_inventory(conn, idempotency_key, item_id, quantity):
    # Step 1: Check for an existing response.
    cursor = conn.execute(
        "SELECT response_body FROM idempotency_responses WHERE key = ?",
        (idempotency_key,)
    )
    row = cursor.fetchone()
    if row:
        return json.loads(row[0])

    # Step 2: Do the work inside a single transaction.
    conn.execute("BEGIN")

    # Deduct inventory atomically.
    conn.execute(
        "UPDATE inventory SET available = available - ? WHERE item_id = ? AND available >= ?",
        (quantity, item_id, quantity)
    )
    if conn.total_changes == 0:
        conn.rollback()
        return {"error": "insufficient inventory"}

    # Record the reservation.
    reservation_id = str(uuid.uuid4())
    conn.execute(
        "INSERT INTO reservations (id, item_id, quantity, idempotency_key, created_at) VALUES (?, ?, ?, ?, strftime('%s','now'))",
        (reservation_id, item_id, quantity, idempotency_key)
    )

    # Cache the response for retries.
    response = {"reservation_id": reservation_id, "status": "reserved"}
    conn.execute(
        "INSERT INTO idempotency_responses (key, response_body, created_at) VALUES (?, ?, strftime('%s','now'))",
        (idempotency_key, json.dumps(response))
    )

    conn.commit()
    return response

idempotency_responses テーブルは安全網だ。最初のリクエストが変更を実行し、結果をコミットしてレスポンスをキャッシュする。同じキーを持つその後のすべてのリクエストは処理をスキップし、キャッシュされたJSONを返す。予約とキャッシュエントリは同じトランザクションで書き込まれるため、両方とも可視か、両方とも不在かのいずれかだ。

限界が露呈する場所: 副作用の境界

idempotency keyは同じクライアントからの重複リクエストを処理する。異なるクライアントからの並行した重複リクエストは処理しない。2人のユーザーが在庫の最後の1点で「購入」をクリックした場合、悲観的ロックまたは楽観的並行制御が依然として必要だ。上記の例における available >= ? のチェックはこれの原始的な形態だが、実際の在庫システムにはさらなる対策が必要だ。

より大きな問題は、トランザクション外の副作用だ。Stripeでカードを課金し、SendGridでメールを送信し、データベースに書き込む場合、idempotency keyが保護するのはデータベースの部分だけだ。メールは2回送信される可能性がある。Stripe自身のidempotency windowが期限切れになれば、カードは2回課金される可能性がある。真の安全性には、すべてのダウンストリームシステムの参加が必要だ。

これがStripeが課金作成時に独自の Idempotency-Key を受け入れる理由だ。彼らは自分たちのレイヤーで重複排除を行う。あなたも自分のレイヤーで同じことをすべきだ。同じキーをidempotentなダウンストリームサービスに渡す。それをサポートしないサービスの場合、呼び出しをローカルトランザクションでラップするか、リスクを受け入れる。

キーの衝突、TTL、その他の運用上の落とし穴

UUID4には122ビットのランダム性がある。現実的なボリュームでは衝突の確率は無視できる。連続した整数、タイムスタンプ、またはハッシュ化されたリクエストボディをキーとして使わない。クライアント生成のUUIDが業界標準であるのには理由がある。

古いエントリを期限切れにしない限り、キーのストレージは永遠に増大する。TTLを設定する: 24時間が標準だ。その後、古いキーを削除する。クライアントがTTLの後にリトライした場合、重複が発生する。これを文書化する。リトライウィンドウとTTLは技術的な詳細ではなく、ビジネス上の契約だ。

idempotency storeは、APIと同等以上に可用性がなければならない。Redisキャッシュがダウンしている場合、キーを検証できない。いくつかのチームは「新規リクエストと仮定する」ことにフォールバックし、これは障害時に重複を生じさせる。他のチームはリクエストを拒否し、これはより安全だが別の障害モードを生じさせる。ここにタダ飯はない。

クライアント側も同様に重要だ

クライアントが送信しない限り、サーバー側のidempotency keyは無意味だ。すべての変更リクエストは、呼び出し元でキーを生成し、タイムアウト時に同じキーでリトライすべきだ:

import uuid
import requests

def safe_post(url, payload, max_retries=3):
    key = str(uuid.uuid4())
    for attempt in range(max_retries):
        try:
            resp = requests.post(
                url,
                json=payload,
                headers={"Idempotency-Key": key},
                timeout=10,
            )
            return resp
        except requests.Timeout:
            if attempt == max_retries - 1:
                raise
            # Retry with the SAME key. The server deduplicates.

キーはHTTP試行ごとではなく、論理的な操作ごとに1回生成されなければならない。リトライのたびに新しいUUIDを生成すれば、要点を見失っている。キーは、リトライを結びつける契約なのだ。

FAQ

クライアントがブラウザで、ユーザーがページを更新した場合はどうか?

ページの更新は新しいJavaScriptコンテキストを作成する。キーを localStorage やURLに永続化しない限り、新しいリクエストは新しいidempotency keyを取得する。ほとんどのチームは、重要でないフローでは気にしない。支払いの場合は、気にすべきだ。

GETリクエストはidempotency keyを使うべきか?

いいえ。GETはHTTPセマンティクス上ですでに安全だ。idempotency keyは状態を変更するメソッド用である: POST, PUT, PATCH, DELETE。

リクエストボディのハッシュをidempotency keyとして使えるか?

ボディが決定論的で、タイムスタンプやランダム値を含まない場合のみ。実際には、クライアント生成のUUIDの方がシンプルで信頼性が高い。

idempotency keyはどのくらいの期間保持すべきか?

最も長いクライアントのリトライウィンドウよりも長く。クライアントが60秒間リトライする場合、24時間キーを保持する。バッチジョブのために翌日リトライする可能性がある場合、1週間キーを保持する。

今日から最初のリクエストをidempotentにする

すべての変更エンドポイントに Idempotency-Key ヘッダーを追加する。24時間のキャッシュから始める。UUID4を使う。ストレージをビジネストランザクションと原子的にする。コンテナがリクエストの途中で死に、クライアントがリトライした最初の時、あなたはそうしておいてよかったと思うだろう。代替案は、1人の顧客に17件の同一の課金がある理由を財務チームに説明することだ。