データベース

Exactly-onceとは?重複も欠落も出さない処理保証の仕組みと実装条件を解説

月末の突合で売上が1件分多い。追いかけると、応答が返らずに再送されたメッセージが二重に計上されていた——ストリーム処理やメッセージ連携でよく踏む事故です。本記事では、Exactly-onceを「1回だけ配送する」ではなく「結果が1回だけ反映された状態」として定義し直したうえで、成立に要る3つの条件、Kafkaの冪等プロデューサとトランザクション、FlinkでKafkaSinkにEXACTLY_ONCEを選ぶときの設定と失効条件、宛先がトランザクションを持たない場合の受け側設計までを実装者の目線で整理しました。保証を上げる経路を絞る判断基準も条件付きで示します。

まとめ|Exactly-onceが成立する3条件と適用範囲の絞り方

先に結論を置きます。Exactly-onceは単体の機能ではなく、3つの条件が同時に成り立ったときだけ観測できる性質です。巻き戻して読み直せる入力ログがあること、処理途中の状態と読み出し位置を同じ時点で固定できること、そして出力先がトランザクションを扱えるか冪等に書けること。この3点セットのうち1つでも欠けると、保証は自動的に at-least-once へ落ちます。「Flinkを入れたからExactly-once」という理解が事故を生む理由がここにあります。

実装の難易度は、境界をどこに引くかでまるで変わります。入口も出口もKafkaの中で閉じるなら、冪等プロデューサとトランザクション、読み手側の read_committed という設定の組み合わせで届きます。Kafka Streamsなら processing.guarantee を exactly_once_v2 にする一行が入口です。難しくなるのは宛先が外部システムのときで、書き込みと「どこまで読んだか」を同じトランザクションに入れられないなら、受け側を冪等に作る以外の道はありません。

そして代償があります。読み手からの可視性はコミットまで遅れるため、実効的な遅延はチェックポイント間隔に支配されます。だから全経路に適用しません。金額が動く経路だけをExactly-onceにして、通知やログ転送は at-least-once と受信側の冪等化で受ける。この線引きを最初に決めておくと、後から遅延要件と衝突して設計をやり直す事態を避けられます。

Exactly-onceの定義と「1回だけ配送」との決定的な違い

用語の輪郭を先に固めます。ここを曖昧にしたまま設定値の話に入ると、何を守っているのかが分からないまま保証水準だけが上がります。

処理結果が1回だけ反映された状態を指す定義と配送保証との境界

Exactly-onceとは、障害や再試行があっても、結果が欠落も重複もせず1回だけ反映されたように見える処理保証を指します。日本語では「正確に一度」と訳されますが、実装上の意味は訳語より狭い。Apache Flinkの公式ドキュメントは、最も強い水準について「すべてのイベントがFlinkの管理する状態にちょうど1回だけ影響する」と書き、各イベントが1回だけ処理されるという意味ではないと明示しています。

ネットワークを越える配送そのものを1回に固定する手段はありません。送信側が応答を受け取れなかったとき、相手に届かなかったのか、届いたうえで応答が消えたのかを区別できないからです。区別できない以上、再送するか諦めるかの二択になります。つまり実装の言葉に直すと、Exactly-onceの中身は「再送は許す。ただし2回目の効果をゼロにする」という設計です。

3水準の処理保証とオフセット確定の位置が決める失敗の型の比較

処理保証は3水準に分かれます。分かれ目は、どこまで読んだかを示すオフセットを「処理の前に確定するか、後に確定するか」という一点です。

水準 オフセット確定の位置 障害時に起きること
at-most-once 処理を始める前に確定 再開時にデータが欠落
at-least-once 処理を終えてから確定 再開時にデータが重複
exactly-once 出力と同じ単位で確定 欠落も重複も出ない

Kafkaの既定は at-least-once です。Confluentの公式ドキュメントも、Exactly-onceを得るには明示的な設定が要ると書いています。逆に言えば、何も設定していない連携は再送で重複しうる前提で下流を作るべきで、この事実を知らないまま「メッセージ基盤が保証してくれる」と考えるのが最初の落とし穴になります。

内部状態の一貫性と端から端までの配送保証を分ける線引きの判断基準

もう1つ、混同されがちな区別があります。Flinkの公式ドキュメントは、Flink内部の状態が exactly-once であることと、ソースからシンクまでの端から端までの保証は別物だと明記しています。後者は外部システムの性質に依存し、Flinkの制御が及ばない領域だからです。

この違いは障害時の見え方に出ます。内部状態だけ守られている構成では、ジョブが直近のスナップショットから復元して計算は正しく続きますが、復元前に外へ書き出した分は取り消せません。結果として下流のテーブルには重複行が残ります。設計レビューでは、まず「どこからどこまでを1回にするのか」を宣言してください。境界を言わずに保証水準だけを議論すると、必ず認識がずれます。

Exactly-onceの成立に要る3条件と欠けたときの落ち方

ここからは仕組みです。3条件を1つずつ、満たせない典型例とセットで押さえます。ストリーム処理の仕組みとバッチ処理との使い分けは方式の全体像として整理しているため、本記事は保証の成立条件に絞ります。

巻き戻して読み直せる入力ログという第1条件と満たせない入力の型

再処理が可能でなければ、欠落を埋める手段がありません。Kafkaのオフセット、Kinesis Data Streamsのシーケンス番号のように、任意の位置から読み直せる保持層が要ります。Flinkの公式ドキュメントも、端から端までの保証には再生可能なソースが前提だと書いています。

満たせない入力は具体的に3つあります。HTTPで直接受けてメモリ上で処理する構成、UDPのように到達確認を持たない経路、そして外部SDKがプッシュしてくるだけで位置指定の読み直しができないサービスです。この場合の定石は、受け口の直後に永続ログを1段挟み、そこから先を保証区間にすること。境界を手前にずらすだけで、以降の議論は既知の型に落ちます。

チェックポイントで状態と読み出し位置を同時に固定する仕組みと復旧条件

第2の条件は、処理途中の状態と入力の読み出し位置を、同じ時点のものとして一括で記録する仕組みです。Flinkはこれを非同期バリアスナップショットで実現しており、公式ドキュメントはChandy-Lamportアルゴリズムの変種だと説明しています。番号付きのバリアをストリームに差し込み、各オペレータはバリアが通過した時点の状態を書き出します。

設定面では、最も強いモードがバリアの整列を伴い、at-least-onceで足りるなら整列を切って性能を取れる、という関係です。復旧時は状態と読み出し位置を同じスナップショットから戻します。片方だけを戻す実装——たとえば状態はDBに随時書き、オフセットは別管理という構成——は、この時点で保証が崩れる原因です。状態が大きい場合の置き場は、ヒープ上のバックエンドと組み込みKVSを使うバックエンドの2種があり、後者は増分スナップショットに対応します。

出力をトランザクションか冪等な書き込みへ寄せる第3条件と判断基準

最後は出口です。取れる道は2つしかありません。1つは、出力の書き込みと「どこまで読んだか」を同じトランザクションに入れて、両方成功か両方失敗のどちらかにする方法。もう1つは、同じ内容を何度書いても結果が変わらない書き方に寄せる方法で、主キーによるupsertや処理済みIDの記録がこれにあたります。後者の考え方は冪等性の意味とAPI・IaCでの担保方法で整理した設計と同じで、対象がメッセージ処理に変わっただけです。

3条件の関係は直列です。入力が再生できず、状態と位置が同時に固定できず、出口が重複を吸収しない——どれか1つでも欠ければ、保証は at-least-once に落ちます。落ちること自体は失敗ではありません。危険なのは、落ちているのに落ちていないと思い込み、下流の重複排除を省くことです。

Kafkaの中で閉じる場合の設定値と満たすべき前提バージョン

入口も出口もKafkaなら、実装は設定の組み合わせに収まります。位置と出力を同じシステムに置けるぶん、外部連携より格段に安く済みます。

冪等プロデューサとトランザクションが担う重複排除の適用範囲と制約

まず enable.idempotence です。有効にするとプロデューサIDとシーケンス番号が付与され、ブローカー側が再送を検出して除去します。Confluentの公式ドキュメントによれば、この仕組みが入ったのは0.11.0.0からです。ただし効くのは同一セッション・同一パーティションへの再送に限られ、アプリが落ちて別プロセスが同じデータを送り直す場面は範囲外になります。

そこで transactional.id を与えてトランザクションを使う構成です。複数パーティションへの書き込みと、消費側のオフセットコミットを1つの単位にまとめられるため、読んで処理して書くという典型の連鎖が原子的になります。忘れやすいのが読み手側の設定で、isolation.level を read_committed にしていないと、中断されたトランザクションのレコードまで読んでしまいます。送信側だけ整えて安心する構成が、実務で最も多い設定漏れです。

Kafka Streamsのprocessing.guaranteeと必要なブローカー版

Kafka Streamsでは処理保証が設定1つで切り替わります。既定は at_least_once で、強い保証が要るなら exactly_once_v2 を指定します。公式のアップグレードガイドによれば、旧来の eos-v1(設定値 exactly_once)は3.0.0で非推奨となり、4.0.0で削除されました。3.0.0で exactly_once_beta が exactly_once_v2 へ改称されているため、古い記事の設定値をそのまま持ち込むと起動時に弾かれます。

設定項目 指定する値 前提と注意
enable.idempotence true 同一セッションの再送に有効
transactional.id 一意の文字列 複数の書き込みを1単位に
isolation.level read_committed 読み手側にも設定が要る
processing.guarantee exactly_once_v2 ブローカー2.5以降が要件

ブローカー版の要件は公式ガイドに明記があり、eos-v2 を使うならブローカーは2.5以降である必要があります。Kafka本体は4.3系が公開されている状況です(2026年8月時点の公式サイト表記)。マネージドサービスで動かしている場合は、クライアント側の設定より先にブローカー版を確認してください。

Kafkaの外へ書いた瞬間に保証が切れる境界と持ち込む対策の実装例

Confluentの公式ドキュメントは、Kafkaの強みは読み出し位置とデータを同じシステムに保持できる点にあり、外部の宛先へ書く場合は追加の調整が要ると書いています。この一文が境界そのものです。

持ち込める対策は2通りです。宛先がリレーショナルデータベースなら、業務データの更新と一緒にオフセットを同じトランザクションで書き、再開時はそのテーブルの値から読み直します。宛先がトランザクションを持たないなら、次章の受け側設計に寄せます。どちらでもない中間案——「たぶん重複しないはず」という運用前提——だけは選ばないでください。障害はその前提が崩れたときにだけ起きます。

FlinkからKafkaや外部システムへ通すときの設定と代償

処理エンジンを挟む構成では、チェックポイントとシンクのトランザクションを噛み合わせます。Flinkは2.3.0が最新の安定版で、2026年6月25日に公開されました。

KafkaSinkのEXACTLY_ONCEで必須になる設定と失効するトランザクション

KafkaSinkに DeliveryGuarantee.EXACTLY_ONCE を選ぶと、書き込みはトランザクションとして開かれ、チェックポイントが成功した時点でコミットされます。そのためチェックポイントの有効化が前提条件です。加えて公式ドキュメントは transactionalIdPrefix を同一クラスタ上のアプリケーション間で一意にするよう求めています。プレフィックスが衝突すると、別ジョブのトランザクションと干渉します。

見落とすと損失につながるのがタイムアウトの設計です。公式ドキュメントは、Kafkaのトランザクションタイムアウトを「最大チェックポイント時間+最大再起動時間」より大きくするよう強く推奨しており、超えるとブローカーが未コミットのトランザクションを失効させ、データ損失が起きうると明記しています。ブローカー側の上限値も併せた確認が必要です。復旧に10分かかる構成で既定値のまま運用すれば、長い障害のたびに静かに欠落が積み上がります。

可視性がチェックポイント間隔まで遅れる代償と間隔の決め方の基準

強い保証には遅延の対価があります。read_committed で読む下流からは、コミットされるまでレコードが見えません。公式ドキュメントも、この方式はチェックポイントが書かれるまで実質的にレコードの可視性を遅らせるため、チェックポイント時間を調整するようにと書いています。つまり実効的な遅延の下限は、処理時間ではなくチェックポイント間隔で決まります。

決め方は要件から逆算します。可視性の要件が10秒以内なら間隔は数秒に置き、分単位で足りるなら1分に置く。間隔を詰めるほどスナップショットの負荷とトランザクションの発行数が増えるため、無条件に短くはできません。ここを詰めずに保証だけを上げると、遅くなっただけで事故は減らないという結果になります。

宛先がトランザクションを持たない場合に受け側へ寄せる設計の選択基準

宛先がHTTPのエンドポイント、オブジェクトストレージ、トランザクションを持たないKVSであれば、二相コミットは組めません。この制約は製品を替えても消えません。データ連携基盤のEstuaryが掲げるexactly-onceにも「宛先システムが対応できるかぎり」という条件が付いており、宛先の性質が保証の上限を決めるという関係は共通です。

受け側に寄せる手は3つあります。業務上の一意キーでupsertする、処理済みイベントIDをTTL付きで記録して2回目を捨てる、条件付き書き込みでバージョンが進んでいるときだけ反映する。決済Webhookで冪等な注文確定を組む実装はこの型の代表例で、イベントIDを主キーにした受信記録が中心になります。保持期間は再送が起こりうる窓——多くの基盤では数日——を基準に決めてください。

保証を上げる経路の見極めと過剰投資を避けるための実務判断基準

最後は判断です。技術的に組めるかではなく、組む価値がある経路かで決めます。

Exactly-onceを設計する経路の条件と適用しないと決める経路

言い切ります。Exactly-onceを設計するのは、重複1件がそのまま金額の誤りになる経路だけです。課金、決済、在庫の引当、ポイント付与、会計仕訳の連携がこれにあたります。判断式は単純で、重複1件あたりの損害と想定発生頻度の積が、追加の遅延と運用コストを上回るなら採用します。

適用しないと決める経路も明示しておきます。通知の配信、ログ転送、レコメンドの特徴量更新、ダッシュボードの速報値。これらは重複しても損害が小さいか、受け側で潰せます。全経路を一律に強い保証で組む設計は、遅延要件を割ったうえに運用対象だけ増える典型的な過剰投資です。

受信側の冪等化のほうが安く済む条件と実装コストの比較による判断

もう一段踏み込みます。宛先が1つで、業務上の一意キーが取れるなら、Exactly-onceを組むより受信側を冪等にするほうが安い。処理済みIDのテーブルに主キーとTTLを置くだけで、実装は数十行、運用は肥大化の監視だけで済みます。

対してExactly-onceの側は、トランザクションのタイムアウト設計、旧プロセスの書き込みを締め出すフェンシング、チェックポイントの運用と監視が付いてきます。分岐点は宛先の数と状態の有無です。宛先が複数あり、処理の途中に集計状態を持つ構成では、受け側の冪等化だけでは整合が取れないためExactly-onceが要ります。逆に、状態を持たない単純な転送で宛先が1つなら、冪等化で十分です。この見極めから設計・構築まで外部と組む選択肢もあり、当社ではデータ分析基盤構築・MLOps構築支援として、こうしたリアルタイム連携基盤の設計を支援しています。

実装で踏み抜きやすい失敗パターンと稼働前に潰す確認項目の実測方法

実務で繰り返し見る失敗は5つに集約されます。全経路に強い保証をかけて遅延要件を割るもの。トランザクションのタイムアウトを既定のままにして、チェックポイントが伸びた障害時に欠落させるもの。送信側だけ整えて読み手を read_committed にし忘れるもの。トランザクションIDのプレフィックスを複数ジョブで共有して干渉させるもの。そして状態にTTLを置かず、スナップショットが肥大して復旧時間が伸びるものです。

稼働前の確認は机上ではなく実測でやってください。処理中にタスクを強制終了し、復旧後に入力件数と出力件数、金額の合計が一致するかを突き合わせる。この障害注入を1度通せば、上の5つのうち3つはその場で表面化します。設定値のレビューだけで通した構成は、本番の最初の障害で初めて検証されることになります。

Exactly-onceの実装条件と適用範囲についてよくある質問

検討段階で実際に挙がる質問のうち、公式ドキュメントの記述に照らして答えられるものを5つ挙げます。

Exactly-onceは本当に実現できるのですか?

配送そのものを1回に固定することはできません。応答が失われたとき、届いたのか届かなかったのかを送信側が判別できないためです。実現できるのは「結果が1回だけ反映された状態」で、再送を許したうえで2回目の効果を打ち消す設計になります。Flinkの公式ドキュメントも、各イベントが1回だけ処理される意味ではなく、管理する状態への影響が1回だけになる意味だと明示しています。

at-least-onceと受信側の冪等化では不十分ですか?

宛先が1つで、業務上の一意キーが取れるなら十分です。むしろ実装量と運用負荷の面で有利になります。不十分になるのは、処理の途中に集計状態を持つ場合と、宛先が複数あって片方だけ成功する部分失敗が起こりうる場合です。この2つに当てはまるなら、出力とオフセットを同一トランザクションに入れる構成へ寄せてください。

Exactly-onceにするとどれくらい遅くなりますか?

処理そのものより、可視性の遅れが支配的です。read_committed で読む下流からは、トランザクションがコミットされるまでレコードが見えないため、実効遅延の下限はチェックポイント間隔になります。間隔が1分なら、下流の見え方も最大で1分遅れる前提で要件を確認してください。公式ドキュメントもチェックポイント時間の調整を促しています。

exactly_once_v2はどのバージョンから使えますか?

Kafka Streamsの公式アップグレードガイドによれば、この設定値は3.0.0で導入され、ブローカーは2.5以降が要件です。旧来の exactly_once は3.0.0で非推奨、4.0.0で削除されました。exactly_once_beta も3.0.0で exactly_once_v2 へ改称されているため、古い設定値のまま新しい版へ上げると起動できません。移行時はクライアントとブローカーの両方の版を確認してください。

宛先がトランザクションに対応していない場合はどうしますか?

受け側を冪等に作ります。一意キーによるupsert、処理済みイベントIDをTTL付きで記録して2回目を捨てる方式、バージョンが進んでいるときだけ反映する条件付き書き込みのいずれかです。基盤を替えても宛先の性質が保証の上限を決める関係は変わらないため、宛先の選定段階でトランザクションの有無を確認しておくと設計のやり直しを避けられます。

関連記事

資料請求

RELATED POSTS 関連記事