- Authors

- Name
- Youngju Kim
- @fjvbn20031
- はじめに — 「正確に一度」という誘惑
- 配信保証の三段階
- なぜexactly-once配信は不可能なのか
- 二人の将軍問題 — 合意の不可能性
- 配信ではなく処理 — 発想の転換
- 冪等性 — 何度やっても同じ結果
- 重複排除 — 既に見たものを記憶する
- トランザクションアウトボックス — 処理と発行を一つに
- Kafkaのexactly-onceは何を保証するのか
- 決済システムでの実戦
- 実践的な指針
- おわりに
- 参考資料
はじめに — 「正確に一度」という誘惑
メッセージキューや決済システムを初めて扱うと、誰もが同じ願いを抱きます。「メッセージがちょうど一度だけ処理されてほしい」。決済は一度だけ請求されるべきで、メールは一度だけ送られるべきで、在庫は一度だけ減らされるべきです。二度請求すれば顧客は激怒し、一度も請求されなければ会社が損をします。だから私たちは**exactly-once(正確に一度)**を渇望します。
ところが分散システムを学ぶと、すぐに戸惑う一文に出会います。「exactly-once配信は不可能だ」。この言葉は私たちの願いを真っ向から否定するように見えます。しかし同時に、Kafkaのようなシステムは「exactly-once semantics」を謳います。いったいどちらが正しいのでしょう。
本稿の目標は、この混乱を晴らすことです。核心は一つの区別にあります。exactly-once「配信(delivery)」は不可能だが、exactly-once「処理(processing)」は達成可能だ。 この一文を最後まで解きほぐせば、決済とメッセージングの正確性をどう設計すべきかが明確になります。
これらの概念につながるメッセージの流れを目で見たければ、このサイトのメッセージキュープレイグラウンドで可視化できます。
配信保証の三段階
メッセージングシステムが「メッセージを配信する」と言うとき、その保証には三つの段階があります。この三つを正確に区別することが、すべての議論の出発点です。
At-most-once(最大一度)。 メッセージは一度配信されるか、まったく配信されません。決して重複はありませんが、喪失はありえます。送る側が「とりあえず送って確認はしない」方式です。速く単純ですが、失っても構わないデータ(例: 大量のメトリクスサンプル、一部のログ)にのみ適します。
At-least-once(少なくとも一度)。 メッセージは必ず一度以上配信されます。喪失はありませんが、重複はありえます。 送る側が確認応答(ack)を受け取るまで再送するからです。ほとんどの実用的なメッセージングシステムのデフォルトです。「失わない」代わりに「重複しうる」を受け入れるのです。
Exactly-once(正確に一度)。 メッセージが正確に一度、喪失も重複もなく配信されます。もっとも理想的に聞こえますが、すぐ見るように「配信」の水準でこれを純粋に達成するのは原理的に不可能です。
at-most-once : 0または1回 (喪失ありうる、重複なし)
at-least-once: 1回以上 (喪失なし、重複ありうる)
exactly-once : 正確に1回 (喪失も重複もなし — 理想)
ここで重要な直感: at-most-onceとat-least-onceは正反対のトレードオフです。一方は喪失を甘受して重複を無くし、もう一方は重複を甘受して喪失を無くします。そして現実の信頼性あるシステムはほぼ常にat-least-onceを選びます。喪失はたいてい重複よりはるかに致命的だからです。ならば残る問題は「その重複をどう無害にするか」です。
なぜexactly-once配信は不可能なのか
exactly-once配信が不可能な理由は深遠な定理ではなく、ごく単純な状況から生まれます。すなわちネットワークは失敗し、失敗は区別がつかないという事実です。
送信者Aが受信者Bへメッセージを送りackを待つ状況を見ましょう。Aはackを受け取れませんでした。ところがAは二つの場合を決して区別できません。
ケース1: メッセージがBに届かなかった
A --メッセージ--X (喪失) B
-> Bは受け取っていない。Aは再送すべき。
ケース2: メッセージは届いたがackが喪失した
A --メッセージ--> B (処理した!)
A <--ack--X (喪失)
-> Bは既に受け取った。Aが再送すれば重複!
Aから見れば二つのケースは同じに見えます。「メッセージを送ったのにackが来なかった」。ここでAはジレンマに陥ります。
- 再送すれば: ケース1は解決しますが、ケース2ではBがメッセージを二度受け取ります(重複)。→ at-least-once
- 再送しなければ: ケース2は安全ですが、ケース1ではメッセージが永遠に喪失します。→ at-most-once
つまり「ackが来なかったとき再送するかしないか」というたった一つの選択で、at-least-onceとat-most-onceが分かれます。そしてその間の完璧な点、「喪失も重複もない配信」は、この根本的な曖昧さのため配信そのものの水準では存在しえません。ネットワークがいつでも切れうる限り、送信者は「相手が受け取ったか」を確実に知る方法がないからです。
二人の将軍問題 — 合意の不可能性
この曖昧さをもっとも鮮明に示す古典が**二人の将軍問題(Two Generals' Problem)**です。分散合意の根本的な難しさを露わにする思考実験です。
二人の将軍がそれぞれ丘に陣を張り、間の谷には敵がいます。二つの軍は同時に攻撃してのみ勝てます。片方だけ攻撃すれば負けです。将軍たちは伝令を谷の向こうへ送って通信しますが、伝令は敵に捕まって消えることがあります。
将軍A: 「夜明けに攻撃しよう」--伝令--> 将軍B
(伝令が捕まれば? AはBが受け取ったか分からない)
将軍Bが受け取り「同意!」--伝令--> 将軍A
(この伝令が捕まれば? BはAが同意を受け取ったか分からない)
Aがその同意を受け取り「確認!」--伝令--> ...
(これも捕まりうる。果てしない確認の確認の確認...)
核心はこうです。どれほど多くの確認メッセージをやり取りしても、最後のメッセージが届いたかは送った側が決して確信できません — 最後の伝令が捕まったかもしれないからです。ですから二人の将軍は「二人とも確実に知っている」という**共通知識(common knowledge)**に、有限のメッセージ交換では到達できません。これは証明された不可能性です。
二人の将軍問題が私たちに与える教訓は明確です。信頼できないチャネルの上では「確実な一度の合意」は不可能です。ですから私たちは確実性を諦める代わりに、別の層で問題を解く迂回路を見つけねばなりません。その迂回路が次の話です。
配信ではなく処理 — 発想の転換
ここで決定的な転換が起こります。exactly-once配信が不可能なら、目標を変えましょう。私たちが本当に望むのは「メッセージが正確に一度配信されること」ではなく「その結果が正確に一度反映されること」です。
顧客が望むのは「決済メッセージがネットワークを正確に一度渡ること」ではありません。「請求が正確に一度だけ起こること」です。この二つは違います。メッセージがネットワークを二度渡ってきても、請求が一度だけ起これば顧客は満足します。
これがexactly-once processing(正確に一度の処理)のアイデアです。配信はat-least-onceのままにし(つまり重複を許し)、代わりに受信側で重複を無害に吸収して、最終効果が正確に一度になるようにするのです。
諦める: exactly-once配信 (原理的に不可能)
採用: at-least-once配信 (重複ありうるが喪失なし)
+ 受信側で重複を吸収
= exactly-once処理 (最終効果は一度)
この転換の美しさは、不可能なことを無理にやろうとせず、可能なものの組み合わせで望む結果を作るところにあります。その組み合わせの核心的な材料が三つ、冪等性・重複排除・トランザクションアウトボックスです。一つずつ見ましょう。
冪等性 — 何度やっても同じ結果
exactly-once処理のもっとも重要な武器は**冪等性(idempotency)**です。ある演算が冪等だとは、それを一度やろうと何度やろうと結果が同じだという意味です。
いくつかの例で感覚をつかみましょう。
- 「残高を200に設定する」→ 冪等です。何度実行しても結果は残高200です。
- 「残高から100を引く」→ 冪等ではありません。二度実行すれば200引かれます。
- 「この注文を『決済完了』状態に表示する」→ 冪等です。何度やっても状態は『決済完了』一つです。
核心は、重複配信が起きても処理が冪等なら重複が自動的に無害になることです。ですからexactly-once処理設計の第一歩は「できる限り演算を冪等にする」ことです。
問題は「100を引く」のような本質的に冪等でない演算です。決済や在庫減少はたいていこの性質です。こうした場合、演算自体を冪等に変えられないなら、「既に処理したか記憶」して重複を濾さねばなりません。それが次の材料、重複排除です。
重複排除 — 既に見たものを記憶する
本質的に冪等でない演算を冪等にする標準的な技法が重複排除(deduplication)です。アイデアは単純です。各メッセージ(または要求)に固有の識別子を付け、受信側が「既に処理した識別子のリスト」を記憶し、同じ識別子が再び来たら無視します。
要求に固有キーを付ける: idempotency-key: "abc-123"
受信側の処理:
IF "abc-123"を既に処理したか?
YES -> 何もせず、前回の結果を返す (重複を吸収)
NO -> 処理し、"abc-123 処理済み"を記録
決済APIでよく見る**冪等キー(idempotency key)**がまさにこれです。クライアントが決済要求に固有キーを載せて送れば、サーバはそのキーで重複を判別します。ネットワーク問題でクライアントが同じ要求を再送しても、サーバは同じキーを見て「ああ、これは既に処理した」と、新しい請求を作らず前回の結果を返します。
ここに微妙だが決定的な点があります。「処理したという記録」と「実際の処理」が原子的に一緒に起こらねばなりません。 もし処理はしたのに「処理済み」の記録を残す直前に死ねば、次の再試行でまた処理してしまいます。逆に記録だけ残して処理前に死ねば、処理が喪失します。ですから重複排除は必ず処理と一つのトランザクションに束ねられねばならず、この点で三つ目の材料が登場します。
トランザクションアウトボックス — 処理と発行を一つに
前の記事で扱ったアウトボックスパターンが、ここで再び核心的な役割を果たします。exactly-once処理においてアウトボックスは二つを原子的に束ねます。一つは「メッセージ処理の結果(状態変更)」、もう一つは「その処理をしたという記録(そして必要なら発行する次のメッセージ)」です。
典型的な消費者のexactly-once処理フローはこうです。
メッセージ受信 (メッセージには固有idがある)
|
v
一つのローカルトランザクションの中で:
BEGIN
IF このメッセージidが処理テーブルに既にあれば -> 何もせず終了
ビジネス処理 (例: 状態変更)
INSERT 処理済みメッセージid -- 「見た」という記録
INSERT INTO outbox (次のイベント) -- 必要なら次のメッセージ
COMMIT -- 処理・記録・発行がすべて一緒に、または全無
この構造の力は、「処理したという記録」と「実際の処理」が同じデータベースの同じトランザクションにあり、決して食い違えないことです。トランザクションがコミットされれば三つとも反映され、失敗すれば三つとも無かったことになります。途中で死んでも一貫します。そして発行は依然としてat-least-onceですが(アウトボックスがそうです)、次の消費者も同じ冪等/重複排除の構造を持てば、この連鎖全体が各段階で「正確に一度の効果」を保ちます。
まとめると、exactly-once処理は三つの材料の組み合わせです。
| 材料 | 役割 |
|---|---|
| at-least-once配信 | 喪失を防ぐ (重複は甘受) |
| 冪等性 / 重複排除 | 重複を無害にする |
| トランザクションアウトボックス | 処理と記録を原子的に束ねる |
Kafkaのexactly-onceは何を保証するのか
この点でよく出る質問。「でもKafkaはexactly-onceを支援すると言わないか?」その通りです。ただ、それが何を意味するかを正確に知る必要があります。
Kafkaの**exactly-once semantics(EOS)**は、魔法で物理法則に勝ったのではありません。上で見た原理をプロトコル水準で実装したものです。二つの軸が核心です。
- 冪等プロデューサ(idempotent producer): プロデューサが各メッセージにシーケンス番号を付け、ブローカーがこれを検査して再送による重複記録をブローカーログ水準で除去します。つまりプロデューサがネットワーク問題で再送してもログには一度だけ残ります。
- トランザクション(transactions): 複数パーティションにまたがる書き込みと、消費オフセットのコミットを一つの原子的トランザクションに束ねます。これにより「メッセージを読み(consume)→ 処理し → 結果を書き(produce)→ オフセットをコミット」する連鎖がすべて一緒に確定するか一緒に取り消されます。
ここに決定的な条件があります。KafkaのEOSが本当に成立するのは**「Kafkaから読んでKafkaへ書く」閉じた世界の中です。処理とオフセットコミットと出力がすべてKafkaトランザクションに入るからです。ところが処理が外部システム**(例: 決済ゲートウェイ呼び出し、メール送信、別のDBへの書き込み)に副作用を出す瞬間、その外部効果はKafkaトランザクションの外にあります。
Kafka EOSが完結する場合:
Kafka読み -> 処理 -> Kafka書き (+オフセット) <- すべて一トランザクション
EOSが自動でカバーしない場合:
Kafka読み -> 外部決済API呼び出し <- この呼び出しはKafkaの外!
-> ここは依然として冪等キー/重複排除が必要
ですからKafkaのexactly-onceでさえ、外部副作用がある瞬間には結局、私たちが先に見た冪等性と重複排除でその境界を塞がねばなりません。Kafka EOSは強力ですが万能ではなく、「閉じたストリーム処理」という条件の中で強力なのです。この条件を誤解すると「Kafkaを使うから重複の心配は終わり」という危険な錯覚に陥ります。
決済システムでの実戦
これらの原理を、もっとも敏感な領域である決済に適用してみましょう。決済は重複がすなわちお金なので、exactly-once処理の原則が特に重要です。
- すべての決済要求に冪等キーを要求しましょう。 クライアントが生成した固有キー(注文idベースが一般的)を決済要求に載せさせ、サーバはこのキーで二重請求を遮断します。ネットワークタイムアウト後にクライアントが再試行しても二重請求が出ないようにする、唯一堅牢な方法です。
- 「請求したという記録」と「請求」を原子的に束ねましょう。 外部決済ゲートウェイの呼び出しはトランザクションの外なので微妙です。よくあるパターンは「要求開始」を先に記録し(pending)、ゲートウェイの応答を受けて状態を確定し、その間の再試行を冪等キーで判別することです。
- ウェブフック受信も冪等に。 決済ゲートウェイが送る決済完了ウェブフックはたいていat-least-onceで、同じイベントが何度も来えます。ウェブフック処理も必ずイベントidベースの重複排除をせねばなりません。
- 照合(reconciliation)を置きましょう。 どれほどよく設計しても分散システムは食い違いえます。定期的に自分の記録と決済会社の記録を照合して不整合を見つける照合手続きが、最後の安全網です。
核心のメッセージはこうです。決済で「exactly-once」は、ネットワークが魔法のように保証してくれるものではなく、冪等キーと重複排除と照合によって私たちが作り出す性質です。
実践的な指針
ここまでの内容を圧縮します。
「exactly-once配信」を約束するシステムを警戒しましょう。 純粋なexactly-once配信は原理的に不可能です。そうした約束はたいてい「at-least-once + 重複排除」をそう呼んでいるか、特定の閉じた条件の中でのみ成立します。条件を必ず確認しましょう。
at-least-onceを基本として受け入れましょう。 喪失は重複よりたいてい致命的です。ですから信頼性あるシステムはat-least-onceを選び、残る重複問題を処理層で解きます。
演算をできる限り冪等に設計しましょう。「設定」が「増減」より優れています。本質的に冪等でないなら固有の識別子で重複排除しましょう。
重複排除と実際の処理を一つのトランザクションに束ねましょう。「見たという記録」と「処理」が食い違えばexactly-onceが崩れます。アウトボックスパターンがこの原子性を与えます。
最後の安全網として照合を置きましょう。 特に決済のように重複が致命的な場所では、定期的な照合で不整合を捕まえる手続きが不可欠です。
おわりに
「exactly-onceは幻想か?」という問いへの答えは「はいでもあり、いいえでもある」です。exactly-once配信は幻想です。ネットワークが切れうり失敗が区別できない限り、二人の将軍問題が言うように、信頼できないチャネルの上の確実な合意は不可能です。しかしexactly-once処理は幻想ではありません。at-least-once配信を受け入れ、冪等性と重複排除で重複を無害にし、トランザクションアウトボックスで処理と記録を原子的に束ねれば、最終効果が正確に一度のシステムを実際に作れます。
Kafkaのexactly-onceさえこの原理の上に立っています。それは閉じたストリーム処理の中で冪等プロデューサとトランザクションによってこれらの概念を実装したものであり、外部副作用が入る瞬間、私たちは再び冪等性と重複排除で境界を守らねばなりません。
ですから実務の知恵は「正確に一度配信されよう」と努めることではなく、「何度来ても一度の効果だけが残るように」設計することです。この視点の転換こそ、決済とメッセージングの正確性を幻想ではなく工学にする鍵です。
参考資料
- "Two Generals' Problem" (概念整理): https://en.wikipedia.org/wiki/Two_Generals%27_Problem
- Confluent, "Exactly-Once Semantics in Apache Kafka": https://www.confluent.io/blog/exactly-once-semantics-are-possible-heres-how-apache-kafka-does-it/
- Apache Kafka公式ドキュメント, "Delivery Semantics": https://kafka.apache.org/documentation/#semantics
- Stripe, "Idempotent Requests": https://docs.stripe.com/api/idempotent_requests
- Martin Kleppmann, "Designing Data-Intensive Applications" (第9章, 第11章)