結論から書きます。 「Exactly-once」は、何を(配信・処理・効果のどれを)、どの境界の中で1回にするのかを言わない限り、意味が決まりません。1つのデータベースのトランザクション、Kafka のトランザクション、SQS FIFO の重複排除のように、境界を限定すれば exactly-once の保証は実在します。一方で、PostgreSQL・Stripe・メール送信・Lambda のような独立したシステムをまたぐ業務効果に、どれかの製品の保証をそのまま当てはめることはできません。外部 API が成功して応答だけが消えると、呼び出し側には成否が分からないからです。実務でやることは、at-least-once の配信を前提に、副作用の前に永続化した操作IDと冪等キーで効果を1回に収束させ、冪等キーで守れない範囲を照合(Reconciliation)で埋めることです。
この記事では、まず用語を厳密に分け、主要な技術が公式に何を保証しているかを一次資料で確認します。そのうえで、Stripe の API 呼び出しの応答が消えた場合を題材に、業務効果を1回に収束させる実装を示します。コードは PostgreSQL 18.6 / psycopg 3.3 と、Stripe の冪等キー層を模したフェイクを使ったテストで検証済みです(2026年10月時点)。
立場:本記事は「Exactly-once は存在しない」とも「この製品を使えば Exactly-once」とも書きません。各製品の保証は、その製品の公式ドキュメントが述べる範囲で引用し、範囲の外へは広げません。
1. 5つの言葉を分ける
「exactly-once」が議論のたびに食い違うのは、別々のものを同じ言葉で呼んでいるからです。
| 言葉 | 何が1回なのか | 例 |
|---|---|---|
| Exactly-once delivery(配信) | メッセージが受信者に届く回数 | 「このメッセージは消費者に1回だけ渡される」 |
| Exactly-once processing(処理) | 受信者がメッセージを処理した結果が、状態に反映される回数 | Kafka のトランザクションで、読み取り位置と出力を一緒にコミットする |
| Exactly-once effect / effectively-once(効果) | 業務上の副作用(課金、元帳の計上、在庫の引当)が起きる回数 | 同じ注文に対する課金が、何度リトライしても1回 |
| Atomic local transaction(局所的な原子性) | 1つのシステムの中で、複数の書き込みが全部起きるか、何も起きないか | PostgreSQL の1トランザクションでの UPDATE と INSERT |
| Distributed side effect(分散した副作用) | 独立した複数のシステムにまたがる効果 | DB を更新し、Stripe で課金し、メールを送る |
この区別で、多くの議論が整理できます。
- 配信が at-least-once でも、効果は1回にできる。 受信者が冪等なら、2回届いても効果は1回です。実務の大半はこの形です。
- 配信が exactly-once でも、効果が1回とは限らない。 受信者が処理の途中でクラッシュし、副作用だけ残って処理済みの記録が残らなければ、再処理で効果は2回になります。
- 局所的な原子性は、分散した副作用を原子的にしない。 PostgreSQL のトランザクションは、Stripe の API 呼び出しを ROLLBACK できません。
delivery guarantee と business guarantee を混同しない:メッセージング基盤が保証するのは配信や処理の性質で、「顧客に1回だけ課金する」のような業務の保証ではない。業務の保証は、アプリケーションが冪等性と照合で作るもの。
2. 境界を限定すれば、exactly-once は実在する
「exactly-once は存在しない」と一言で片付けるのは不正確です。公式ドキュメントが exactly-once を名乗っている例と、その範囲を確認します。
2.1 1つのデータベースのトランザクション
PostgreSQL のトランザクションの中では、業務の更新と「このメッセージは処理した」という記録を一緒にコミットできます。コミットされれば両方が残り、失敗すれば両方が消えます。この範囲の中では、処理の効果はちょうど1回です。Webhook の受信でこれを使うのが Transactional Inbox、送信側で使うのが Transactional Outbox です。
2.2 Kafka のトランザクション
Apache Kafka の公式ドキュメント(Design の Message Delivery Semantics)は、exactly-once を名乗る主張は細部を読む必要があると前置きしたうえで、Kafka のトピックから読み取り、別のトピックへ書き込む処理では、トランザクションを使うプロデューサーと read-committed の分離レベルの消費者で exactly-once の配信を提供できると説明しています。鍵は、出力の書き込みと消費位置(committed offset)の更新を1つのトランザクションにすることです。
同じドキュメントは、外部システムへ書き込む場合は、消費位置と実際に出力されたものを協調させる必要があるとも述べています。Kafka の exactly-once は「Kafka のトピック間の読み取り・処理・書き込み」の範囲の保証で、Kafka から外部の決済 API を呼ぶ処理には及びません。
2.3 SQS FIFO の重複排除
AWS の公式ドキュメントは、SQS FIFO キューのページを "Exactly-once processing" と題し、送信(SendMessage)を5分間の重複排除期間内にリトライしても、キューに重複を持ち込まないと説明しています。重複排除IDは明示的に渡すか、コンテンツベースの重複排除(本文の SHA-256)を有効にします。
この保証の範囲は「キューに重複したメッセージが入らない」ことです。受信した消費者が処理の後、メッセージを削除する前にクラッシュすれば、可視性タイムアウトの後に同じメッセージが再び受信されます。消費者側の副作用まで1回になるわけではありません。
一方、標準キューについて AWS は、メッセージのコピーを複数のサーバーに保存しているため、まれに削除済みのメッセージのコピーが再び届くことがあるとして、アプリケーションを冪等に設計するよう明記しています(at-least-once delivery)。
2.4 Lambda の非同期呼び出し
Lambda の非同期呼び出しでは、関数がエラーを返すと、既定でさらに2回リトライされます(1回目と2回目の間に1分、2回目と3回目の間に2分)。スロットリングやシステムエラーでは、既定で最大6時間、イベントをキューに戻して再実行します(公式ドキュメント、2026年10月時点)。関数が副作用を起こした後でタイムアウトすれば、同じイベントで副作用がもう一度起きます。さらに公式ドキュメントは、関数がエラーを返さなくても、キュー自体が結果整合であるため同じイベントを複数回受け取ることがある、と明記しています。Lambda 関数は冪等に書く必要があります。
3. 境界をまたぐと何が起きるか:応答が消えた API 呼び出し
最も重要な失敗を、具体的なタイムラインで見ます。注文の確定時に、保存済みのカードで課金するケースです。
呼び出し側(あなたのサービス) Stripe
t1 POST /v1/payment_intents ─────────────▶ 課金を実行(成功)
t2 ◀───── ✕ ─── 200 OK(ネットワーク断・タイムアウトで届かない)
t3 ReadTimeout 例外
→ 課金されたのか、されていないのか分からない
t3 の時点で、呼び出し側が取れる行動は3つしかありません。
- 失敗とみなしてリトライする → 実は成功していたなら二重課金。
- 成功とみなす → 実は失敗していたなら課金漏れ。
- 分からないとして記録し、後で確かめる → これが正解。ただし「後でどう確かめるか」を設計しておく必要がある。
Stripe の公式ドキュメント(Advanced error handling)も、ネットワークの問題などでサーバーがリクエストを受け取ったかどうかクライアントに分からない状態が生じることを説明し、そうしたリクエストは冪等キーを付けてリトライするよう案内しています。また、500 エラーについて「結果を不確定(indeterminate)として扱う」よう述べています。
この「不確定」は、どれだけ通信を工夫しても消せません。呼び出し側と相手の間のメッセージが失われ得る以上、呼び出し側は相手が実行したかどうかを確実には知り得ないからです。だから設計の目標は「不確定をなくす」ことではなく、不確定な状態から、業務効果が1回の状態へ収束させることになります。
4. 業務効果を1回に収束させる4つの部品
| 部品 | 役割 |
|---|---|
| 永続化された操作ID(durable operation identity) | 副作用を起こす前に「この操作をする」とDBに記録する。リトライしても、プロセスが再起動しても、同じ操作を指せる |
| 安定した冪等キー(stable idempotency key) | 操作IDから決定的に作る。同じ操作のリトライは、相手側で同じリクエストとして扱われる |
| リトライ(retry) | 不確定・一時的な失敗を、同じキーで再実行する |
| 照合(reconciliation) | 冪等キーの寿命を超えた、またはリトライでも確定しない操作を、外部の状態を見て確定させる |
どれか1つでは足りません。冪等キーだけでは、キーをどこにも保存していなければ再起動後に同じキーを作れません。操作IDだけでは、相手側が重複を判別できません。リトライだけでは二重実行になり、照合だけでは遅すぎます。
4.1 SDK の自動冪等キーの落とし穴
stripe-python の README は、max_network_retries を設定すると、接続エラーやタイムアウト、409 Conflict などで自動的にリトライし、冪等キーが指定されていなければ自動生成して付けると説明しています。これは便利ですが、守れる範囲は限られます。
- 自動生成されたキーは、その1回の SDK 呼び出しの中のリトライでだけ共有されます。
- 呼び出しが例外で終わり、アプリが自分でリトライすれば、新しいキーが生成されます。
- プロセスがクラッシュして再起動すれば、元のキーはどこにも残っていません。
つまり、アプリケーションのレベルのリトライや、プロセスをまたぐリトライでは、自動生成のキーは二重実行を防ぎません。 冪等キーは、DB に永続化した操作IDから作ります。
5. 実装:操作を先に記録し、同じキーでリトライし、寿命を超えたら照合する
コードの前提:psycopg のコードは
psycopg.connect(dsn, autocommit=True)の接続を前提にし、トランザクションの境界をwith conn.transaction():だけで表しています。読み取りも含めて全ての SQL をtransaction()の中で実行しているので、autocommit が無効な接続でも外部 API の呼び出し中にトランザクションが開いたままにはなりません(テストで確認済み)。コード片は読む順に並べているので、モジュールの先頭にfrom __future__ import annotationsを置いてください(op: Operationのような注釈が、後ろで定義されるクラスを指すため)。使っている import(uuid、dataclass、timedelta、Protocol、psycopg、stripe、from psycopg.rows import class_row)も先頭にまとめてください。
5.1 スキーマ:操作を副作用の前に記録する
CREATE TABLE payment_operations (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
order_id text NOT NULL,
kind text NOT NULL CHECK (kind IN ('charge')),
amount bigint NOT NULL CHECK (amount > 0),
currency text NOT NULL,
customer_id text NOT NULL,
payment_method text NOT NULL,
status text NOT NULL DEFAULT 'pending'
CHECK (status IN ('pending', 'unknown', 'succeeded', 'failed')),
external_id text, -- pi_...(外部で作られたことが分かったら埋まる)
attempts integer NOT NULL DEFAULT 0,
first_attempted_at timestamptz, -- 最初に外部へ送ろうとした時刻(冪等キーの寿命の起点)
last_error text,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (order_id, kind) -- 業務上の同一性: 1注文につき課金操作は1つ
);
status に unknown(不確定) という状態を明示的に持つのが要点です。first_attempted_at は、冪等キーの寿命を「操作を作った時刻」ではなく「最初に外部へ送ろうとした時刻」から測るための列です(5.3節)。タイムアウトを failed に丸めると、成功していた課金を「失敗」として扱い、顧客に再決済を求めてしまいます。
UNIQUE (order_id, kind) は、業務上の同一性を DB に強制します。同じ注文の確定ボタンが2回押されても、操作は1つしか作られません。
def request_charge(
conn: psycopg.Connection,
*,
order_id: str,
amount: int,
currency: str,
customer_id: str,
payment_method: str,
) -> uuid.UUID:
"""副作用の前に「操作」を永続化する。同じ注文で何度呼ばれても操作は1つ。"""
with conn.transaction():
row = conn.execute(
"""
INSERT INTO payment_operations (order_id, kind, amount, currency, customer_id, payment_method)
VALUES (%s, 'charge', %s, %s, %s, %s)
ON CONFLICT (order_id, kind) DO NOTHING
RETURNING id
""",
(order_id, amount, currency, customer_id, payment_method),
).fetchone()
if row is None:
row = conn.execute(
"SELECT id FROM payment_operations WHERE order_id = %s AND kind = 'charge'",
(order_id,),
).fetchone()
assert row is not None # 競合した側の行はコミット済みなので必ず見える
op_id: uuid.UUID = row[0]
return op_id
この操作の作成は、注文の確定と同じトランザクションで行い、実行は Outbox 経由で Dispatcher に任せるのが本番の形です(Outbox Dispatcher の lease と fencing token)。注文は確定したのに課金の操作が記録されない、という2つの書き込みの問題を避けるためです。
5.2 外部 API の結果を「確定したか」で分類する
class AmbiguousOutcome(Exception):
"""リクエストが相手に届いて実行されたかどうか分からない(タイムアウト・接続断・500)。"""
class RetryLater(Exception):
"""実行されていないことが確実な一時エラー(429 など)。"""
class Declined(Exception):
"""確定的な失敗(カード拒否など)。再試行しない。"""
class PaymentGateway(Protocol):
def create_charge(self, op: Operation) -> ExternalCharge: ...
def retrieve_charge(self, external_id: str) -> ExternalCharge: ...
def find_charges(self, op: Operation) -> list[ExternalCharge]: ...
def outcome_for(external_status: str) -> str:
"""PaymentIntent の status を操作の状態に写像する。成功は succeeded だけ。"""
if external_status == "succeeded":
return "succeeded"
if external_status in ("canceled", "requires_payment_method"):
return "failed"
# processing / requires_action / requires_capture など: まだ確定していない
return "unknown"
class StripeGateway:
"""Stripe SDK のエラーを「結果が確定したか」で3分類する境界。"""
def __init__(self, client: stripe.StripeClient) -> None:
self._client = client
def create_charge(self, op: Operation) -> ExternalCharge:
try:
pi = self._client.v1.payment_intents.create(
params={
"amount": op.amount,
"currency": op.currency,
"customer": op.customer_id,
"payment_method": op.payment_method,
"confirm": True,
"off_session": True,
"metadata": {"operation_id": str(op.id)},
},
options={"idempotency_key": op.idempotency_key},
)
except stripe.CardError as exc:
raise Declined(exc.code or "card_error") from exc
except stripe.RateLimitError as exc:
raise RetryLater(str(exc)) from exc
# stripe.IdempotencyError(同じキーで別パラメータ)は捕まえない。キー設計のバグなので表に出す
except (stripe.APIConnectionError, stripe.APIError) as exc:
raise AmbiguousOutcome(type(exc).__name__) from exc
return ExternalCharge(pi.id, pi.status)
def retrieve_charge(self, external_id: str) -> ExternalCharge:
pi = self._client.v1.payment_intents.retrieve(external_id)
return ExternalCharge(pi.id, pi.status)
def find_charges(self, op: Operation) -> list[ExternalCharge]:
# Search はリアルタイムではない(通常1分未満で反映)。作成直後の確認には使わない
result = self._client.v1.payment_intents.search(
params={"query": f"metadata['operation_id']:'{op.id}'"}
)
return [ExternalCharge(pi.id, pi.status) for pi in result.data]
分類の根拠:
- カード拒否(
CardError)は、Stripe が処理して「拒否」という結果を返したので確定。再試行しない。 - レート制限(
RateLimitError、429)は、リクエストが処理されていないので確定(未実行)。後で同じキーで再試行する。 - 接続エラー(
APIConnectionError)とサーバーエラー(APIError、500 など)は不確定。Stripe の公式ドキュメントも 500 の結果を不確定として扱うよう述べている。 IdempotencyError(同じキーに別のパラメータ)は、キーの作り方のバグです。黙って処理せず、表に出します。
さらに、例外が出なかったことは「課金が成功した」ことを意味しません。 オフセッションの確定(confirm: True, off_session: True)でも、PaymentIntent は processing や requires_action で返ることがあります。outcome_for() は PaymentIntent の status を操作の状態に写像し、succeeded だけを成功、canceled と requires_payment_method を失敗、それ以外を unknown(後で確かめる)にします。
metadata に操作IDを入れているのは、冪等キーの寿命を超えたときに、外部の状態から操作を特定するためです(5.4節)。
5.3 冪等キーは操作IDから決定的に作る
# Stripe は冪等キーを「少なくとも24時間経過後」に削除し得る。それより前だけ同じキーで再送する
IDEMPOTENCY_SAFE_WINDOW = timedelta(hours=23)
@dataclass(frozen=True)
class Operation:
id: uuid.UUID
order_id: str
amount: int
currency: str
customer_id: str
payment_method: str
status: str
external_id: str | None
within_key_window: bool
@property
def idempotency_key(self) -> str:
# 操作の永続 ID から決定的に導く。プロセスが再起動しても同じキーになる
return f"payment-op:{self.id}"
@dataclass(frozen=True)
class ExternalCharge:
id: str
status: str # Stripe PaymentIntent.status
Stripe の公式ドキュメントによれば、同じ冪等キーで送られた2回目以降のリクエストには、成功・失敗を問わず最初のリクエストの結果(500 エラーを含む)が返されます。また、冪等層は受け取ったパラメータを最初のリクエストと比べ、違えばエラーにします。キーは最大255文字で、個人情報を含めないよう案内されています。
一方、キーは少なくとも24時間経過した後に削除され得て、削除後に同じキーを使うと新しいリクエストとして扱われるとも説明されています。本記事では余裕を持って、最初に送ろうとした時刻から23時間を「同じキーで再送してよい期間」としています。起点を操作の作成時刻にすると、キューで長く待っただけで一度も送っていない操作まで「キーが消えたかもしれない」扱いになり、不要に人の確認へ回ってしまいます。そのため送信の直前に first_attempted_at を記録します。この23時間は筆者の安全側の判断で、Stripe が保証する値ではありません。
5.4 実行:何度呼んでもよい関数にする
def _load(conn: psycopg.Connection, op_id: uuid.UUID) -> Operation:
with conn.transaction(), conn.cursor(row_factory=class_row(Operation)) as cur:
cur.execute(
"""
SELECT id, order_id, amount, currency, customer_id, payment_method, status, external_id,
(first_attempted_at IS NULL OR first_attempted_at > now() - %s) AS within_key_window
FROM payment_operations WHERE id = %s
""",
(IDEMPOTENCY_SAFE_WINDOW, op_id),
)
op = cur.fetchone()
if op is None:
raise LookupError(op_id)
return op
def _mark_attempt_started(conn: psycopg.Connection, op_id: uuid.UUID) -> None:
# 外部へ送る「前」に記録する。送った直後に落ちても、キーの寿命の起点が残る
with conn.transaction():
conn.execute(
"UPDATE payment_operations SET first_attempted_at = coalesce(first_attempted_at, now()) WHERE id = %s",
(op_id,),
)
def _record(conn: psycopg.Connection, op_id: uuid.UUID, status: str, *, external_id: str | None = None,
error: str | None = None) -> None:
with conn.transaction():
conn.execute(
"""
UPDATE payment_operations
SET status = %s, external_id = COALESCE(%s, external_id), last_error = %s,
attempts = attempts + 1, updated_at = now()
WHERE id = %s AND status IN ('pending', 'unknown') -- 確定済みは上書きしない
""",
(status, external_id, error, op_id),
)
def execute_charge(conn: psycopg.Connection, gateway: PaymentGateway, op_id: uuid.UUID) -> str:
"""何度呼んでもよい。外部 API 呼び出しはトランザクションの外で行う。"""
op = _load(conn, op_id)
if op.status in ("succeeded", "failed"):
return op.status
if op.external_id is not None:
# 外部で作られたことは分かっている(processing 等で止まっていた)。
# 同じキーで再送すると最初のレスポンスが返るだけなので、最新の状態を読む
charge = gateway.retrieve_charge(op.external_id)
_record(conn, op.id, outcome_for(charge.status), external_id=charge.id)
return outcome_for(charge.status)
if not op.within_key_window:
# キーが消えている可能性がある。同じキーで再送すると「新しいリクエスト」になり得るので、
# 作成ではなく照合(reconciliation)で結果を確定させる
found = gateway.find_charges(op)
if len(found) != 1:
_record(conn, op.id, "unknown", error=f"key window expired; {len(found)} match(es); needs review")
return "unknown"
_record(conn, op.id, outcome_for(found[0].status), external_id=found[0].id)
return outcome_for(found[0].status)
_mark_attempt_started(conn, op.id)
try:
charge = gateway.create_charge(op)
except AmbiguousOutcome as exc:
_record(conn, op.id, "unknown", error=str(exc)) # 失敗扱いにしない。同じキーで再試行する
return "unknown"
except RetryLater as exc:
_record(conn, op.id, op.status, error=str(exc))
return op.status
except Declined as exc:
_record(conn, op.id, "failed", error=str(exc))
return "failed"
_record(conn, op.id, outcome_for(charge.status), external_id=charge.id)
return outcome_for(charge.status)
このコードが保証すること:
- 同じ操作は、何度実行しても同じ冪等キーで送られる。 応答が消えても、次の実行は同じキーで送り、Stripe は最初の結果を返す。
- 不確定は不確定として残る。 外部IDが分かっていない
unknownの操作は、次の実行で同じキーで再送される。応答が消えただけ(接続エラー)なら、この再送で確定する。500 は同じキーで最初の結果が再生され得る(5.3節)ので、キーの寿命を超えて3つ目の分岐の照合で確定する(または人の確認に回る)までunknownのままのことがある。二重課金にはならない。外部IDが分かっている(processingなどで返った)操作は、同じキーで再送しても最初のレスポンスが再生されるだけなので、retrieveで最新の状態を読んで確定させる。 - 確定済みの操作は二度と外部を呼ばない。
_recordも確定済みの状態を上書きしない。 - キーの寿命を超えたら、作成しない。 照合で結果を確定させる。見つかった PaymentIntent も
statusで判定し(見つかった=成功ではない)、0件または複数件なら人の確認に回す。
5.5 なぜ寿命を超えたら照合で、見つからなければ人なのか
キーの寿命を超えた unknown の操作に対して、Stripe の Search で見つからなかったとき、「課金されていない」と断定して新しいキーで作り直すのは危険です。Stripe の公式ドキュメントは、Search への反映は通常1分未満だが、障害時には遅れ得るとしています。見つからないことは、存在しないことの証明になりません。
筆者の判断では、この段階まで来た操作はまれで、金額も特定できるので、人が Stripe のダッシュボードで確認する方が、自動で二重課金のリスクを取るより安全です。こうした「定期的に外部と突き合わせ、自動で直せないズレを人に渡す」仕組み全体は Reconciliation(整合性チェック)の設計 で扱っています。
6. テスト:応答が消えた状況を再現する
Stripe の冪等キー層を、同じキーには最初の結果を返すフェイクで模します。「相手側では成功したが、応答だけが消えた」状況は、フェイクの中で課金を記録してから例外を送出することで再現できます。
class FakeStripe:
"""Stripe の冪等キー層を模したフェイク:同じキーには最初の結果を返す。"""
def __init__(self, first_status="succeeded"):
self.by_key = {}
self.charges = {} # 実際に作られた PaymentIntent(= 外部の副作用): id -> [status, op_id]
self.lose_response_once = False
self.pruned = False
self.first_status = first_status
def create_charge(self, op):
key = op.idempotency_key
if key in self.by_key and not self.pruned:
return self.by_key[key] # 最初のレスポンスをそのまま返す(status も当時のまま)
pi = f"pi_{len(self.charges) + 1}"
self.charges[pi] = [self.first_status, str(op.id)]
res = ExternalCharge(pi, self.first_status)
self.by_key[key] = res
if self.lose_response_once:
self.lose_response_once = False
raise AmbiguousOutcome("ReadTimeout") # 相手側では作られている
return res
def retrieve_charge(self, external_id):
return ExternalCharge(external_id, self.charges[external_id][0])
def find_charges(self, op):
return [ExternalCharge(pi, st) for pi, (st, opid) in self.charges.items() if opid == str(op.id)]
def test_response_lost_then_retry_with_same_key(conn):
gw = FakeStripe()
gw.lose_response_once = True
op = new_op(conn)
assert execute_charge(conn, gw, op) == "unknown"
assert execute_charge(conn, gw, op) == "succeeded"
assert len(gw.charges) == 1 # 外部の副作用は1回
def test_processing_is_not_success_and_is_settled_by_retrieve(conn):
gw = FakeStripe(first_status="processing")
op = new_op(conn)
assert execute_charge(conn, gw, op) == "unknown"
gw.charges["pi_1"][0] = "succeeded" # 後で外部側が確定する
assert execute_charge(conn, gw, op) == "succeeded" # 再送ではなく retrieve で最新を読む
assert len(gw.charges) == 1
PostgreSQL 18 のコンテナに対して実行し、通過を確認したケース:
| テスト | 期待する結果 |
|---|---|
| 同じ注文で操作を2回要求 | 同じ操作IDが返る |
| 応答が消えた → 同じキーで再実行 | 1回目 unknown、2回目 succeeded、外部の課金は1回 |
| ❌ リトライのたびに新しいキー | 外部の課金が2回(失敗の再現) |
キーの寿命を超えた unknown(キーは削除済み) | 作成を呼ばず照合で succeeded、外部の課金は1回のまま |
| カード拒否 | failed で確定し、以後は外部を呼ばない |
processing で返った → 後で外部が確定 | 1回目 unknown、2回目は retrieve で succeeded、作成は1回 |
寿命超過の照合で見つかったのが requires_payment_method | 成功にせず failed |
| 寿命超過の照合で0件 | unknown のまま人の確認へ |
| 作成から3日経ったが一度も送っていない操作 | 照合ではなく通常どおり送信される |
| autocommit 無効の接続 | 外部 API の呼び出し中、接続はトランザクション外(IDLE) |
フェイクで検証できるのは自分のコードの分岐です。Stripe の冪等キー層そのものの挙動は、Stripe のテスト環境で、同じキーで2回リクエストを送って同じ id が返ることを確認しておきます。
7. 冪等キーが使えない副作用はどうするか
すべての外部 API が冪等キーを受け付けるわけではありません。例えばメール送信 API の中には冪等キーをサポートしないものもあります(Resend のように冪等キーをサポートするサービスもあります。Resend の冪等キー・リトライ・エラー分類)。
冪等キーが無いなら、業務上の効果を1回にする保証は作れません。どちらの失敗を許容するかを、副作用ごとに明示的に選びます。
| 副作用 | 二重実行の害 | 実行漏れの害 | 選ぶ側 |
|---|---|---|---|
| 入金完了の通知メール | 小さい(2通届く) | 中くらい | at-least-once(不確定ならリトライ) |
| 課金 | 大きい(二重課金) | 大きい(課金漏れ) | 冪等キーのある API を使う。無ければ照合を必須にする |
| パスワードリセットのメール | 小さい | 大きい(ログインできない) | at-least-once |
| 外部在庫の引当 | 大きい | 中くらい | 照合で確かめてから再実行 |
「exactly-once にできないから諦める」のではなく、どちらに倒すかを副作用ごとに決め、その判断をコードと監視に表すことが設計です。
8. よくある誤解
- 「Outbox を使えば exactly-once」:Outbox が保証するのは、業務更新とイベントの記録の原子性と、イベントの発行が at-least-once であることです。発行の重複はあり得るので、consumer の冪等性が必要です。
- 「SQS FIFO を使えば二重処理しない」:FIFO の重複排除は、5分間の送信の重複をキューに入れないことです。消費者が処理後、削除前に落ちれば再受信します。
- 「Kafka の exactly-once で外部 API も1回」:Kafka のトランザクションの範囲は Kafka のトピック間の読み取り・処理・書き込みです。
- 「冪等キーを付けたので何日後でも安全」:Stripe の冪等キーは少なくとも24時間経過後に削除され得ます。
- 「タイムアウトしたら失敗」:タイムアウトは不確定です。失敗として扱うと、成功していた操作を二重に実行します。
- 「冪等性と重複排除は同じ」:重複排除は「同じメッセージIDを2回処理しない」こと、冪等性は「同じ操作を何度適用しても結果が変わらない」ことです。別の Event ID で同じ業務事実が届く場合、重複排除では防げず、業務キーでの冪等性が必要です。
まとめ
- Exactly-once は「何を(配信・処理・効果)」「どの境界で」を言わないと意味が決まらない。
- 1つの DB トランザクション、Kafka のトランザクション(Kafka のトピック間)、SQS FIFO の5分間の送信重複排除など、境界を限定した exactly-once の保証は実在する。その保証を、独立した外部システムの副作用へ広げない。
- 外部 API の応答が消えると、成否は不確定になる。不確定は
failedに丸めず、unknownとして残す。 - 業務効果を1回に収束させるのは、副作用の前に永続化した操作ID、そこから決定的に作る冪等キー、同じキーでのリトライ、キーの寿命を超えたときの照合の組み合わせ。
- SDK の自動冪等キーは1回の呼び出しの中のリトライしか守らない。
- 冪等キーの無い副作用は、二重実行と実行漏れのどちらを許容するかを副作用ごとに決める。
この考え方を、受信側に当てはめたのが Webhookの冪等性をTransactional Inboxで実装する方法、送信側の原子性が Transactional Outbox、AWS のキューでの冪等な消費が SQS + Lambda + EventBridge で冪等な非同期処理を作る です。