振込完了の通知を二度送らないための二重防御 — Idempotent Consumer と、正当な2通目まで殺しかけた冪等キーの粒度
はじめに
自作の銀行連携システムに、通知サービスを付けている。「振込が完了しました」のような通知を、イベントを受け取ってメールやプッシュで送る部分だ。システムの全体像——構成・画面・背骨となるコードは 自作の銀行系システムは実際に何が動いているか に一巡した。
分散システムでは、同じイベントが二度届くことが普通に起きる。何もしなければ、利用者には同じ通知が二通届く。この記事は、それを二重の防御で止めた話と、その設計でうっかり踏みかけた——「重複を防ぐキーの粒度」という落とし穴の話だ。
分散した口座間で、途中で落ちてもお金を消さないための整合の話(送信側の確実配信)は 振込の途中でサービスが落ちても、お金が消えないように に書いた。この記事はその裏側、受信側で同じイベントを二度処理しない話にあたる。
なぜ通知は、二度届きうるのか
通知サービスは、他のサービスが流すイベント(「振込完了」など)をメッセージ基盤(Kafka)経由で受け取る。ここで効いてくるのが at-least-once(少なくとも一回) という配送保証だ。
メッセージ基盤は「一回きり」ではなく「最低一回は必ず届ける」ことを保証する。裏を返すと、ネットワークの再送やリバランスで、同じイベントが二回届くことを許す。一回も落とさない代償に、たまに重複する——それが at-least-once だ。
送信サービス ──「振込完了(event_id=A)」──▶ メッセージ基盤 ──▶ 通知サービス
│
└─ 再送で同じ event_id=A がもう一度届く
│
(素朴に処理すると)通知が二通飛ぶ
だから受け取る側が、「この event_id はもう処理した」を自分で覚えて、二度目を弾く必要がある。この「何度受け取っても結果は一回分」に振る舞う受け手を Idempotent Consumer(冪等な消費者) と呼ぶ。冪等とは、同じ操作を何回繰り返しても結果が変わらない性質のことだ。
受信台帳(Inbox)と、DB制約の二重防御
二度処理しないための定番の型が Inbox パターン(受信済みイベントの記録台帳。Transactional Inbox とも呼ぶ)だ。処理したイベントIDを専用テーブルに記録しておき、次に同じIDが来たら「処理済み」として捨てる。
肝は、業務処理とInboxへの記録を、必ず同じトランザクションで行うことだ。別々にやると「業務処理は済んだがInbox記録前に落ちた」瞬間、次の再送で二重処理が通ってしまう。同一トランザクションなら、両方成功か両方失敗のどちらかしかない。
event_id=A 受信
│
▼
Inbox に A は有る?
├─ 有る ─▶ 二度目 → 捨てる(もう送らない)
└─ 無い ─▶ ┌─ 同じトランザクション ────────────┐
│ ① 通知を作る │
│ ② Inbox に A を記録 │
└─ どちらも成功 or どちらも失敗 ─────┘
さらに、その一段だけには頼らない。Inboxをすり抜けた場合の最終防御として、通知本体テーブルの側にも「同じイベントからの通知は重複させない」制約をDBに直接置いた。アプリの検査が一箇所漏れても、最後はDBが弾く——多層防御だ。
-- 通知本体。Inbox をすり抜けた場合の最終防御として event_id に UNIQUE 制約
CONSTRAINT notifications_event_id_uk UNIQUE (event_id)
ここまでは、素直だ。問題は、この最終防御のキーの選び方にあった。
落とし穴:一つのイベントは、二通に「正しく」化ける
通知には、一つのイベントを複数のチャネルに展開する設計がある。「振込完了」を、メールでもプッシュでも送る——これを fan-out(一つを複数に展開する) と呼ぶ。このとき通知は「1件=1チャネルへの1配信」なので、同じ event_id から メール用とプッシュ用の2行が正しく生まれる。
ここで、さっきの UNIQUE (event_id) を思い出してほしい。これは「同じ event_id の通知は1行だけ」という制約だ。fan-out で2行目(プッシュ用)をINSERTした瞬間、event_id が同じだから、自分で仕掛けた制約に弾かれる。重複を防ぐはずのキーが、正当な2通目を殺す。
event_id=A(振込完了)
├─ メール用の通知(event_id=A, channel=EMAIL) … INSERT 成功
└─ プッシュ用の通知(event_id=A, channel=PUSH) … ✗ UNIQUE(event_id) 違反で失敗
└─ 重複ではないのに弾かれる
これは、fan-out をまだ実装していない段階(1イベント→1チャネルの簡略実装)では表に出ない。マルチチャネルを足した瞬間に初めて牙をむく——時限爆弾だった。実装で顕在化する前の設計レビューで気づけたのが救いだった。
直し方は、キーの粒度を変えることだ。重複判定の単位を「イベント単位」から「イベント×チャネル単位」に下げる。
-- 修正: event_id 単体 → (event_id, channel) の複合キーへ
ALTER TABLE notifications DROP CONSTRAINT notifications_event_id_uk;
ALTER TABLE notifications ADD CONSTRAINT notifications_event_id_channel_uk
UNIQUE (event_id, channel);
これで、「同じイベント・同じチャネルの重複」は今まで通りDBが拒否し、「同じイベント・別チャネル」(fan-out)は別行として許容される。防ぎたい重複だけを防ぎ、正当な2通目は通す。二重防御の意図はそのまま、キーの粒度だけを正した。
防ぎたい:同じ event × 同じ channel の二度目 → 拒否(重複)
通したい:同じ event × 別 channel の展開 → 許容(fan-out)
│
UNIQUE(event_id) … 両方まとめて拒否(正当な2通目まで殺す)
UNIQUE(event_id, channel) … 前者だけ拒否・後者は許容 ◀ これが正しい粒度
冪等性は「一度だけ」ではなく「何を単位に一度だけか」
作ってみて腹落ちしたのは、冪等性の本当の難しさは「二度やらない」ことそのものではない、ということだ。難しいのは、何を”同じ”とみなすか——重複判定の単位(キーの粒度)を、業務の実態に合わせて選ぶことだった。
- 粒度が粗すぎると(event_id だけ)、別物であるはずの fan-out を「同じ」と誤判定して、正当な処理まで潰す。
- 粒度が細かすぎると、本当に防ぎたい重複をすり抜けさせる。
「イベントは一度だけ処理する」は正しい。だが実装に落とすと、「イベントの何を一度だけにするのか」を決めねばならない。今回それは (event_id, channel) だった。別の場面では別の粒度になる。冪等キーは、思考停止で主キーやイベントIDを選ぶ場所ではない——防ぎたい重複と、通したい正当な繰り返しの、境界線そのものだ。
そしてもう一つ。この境界線のズレは、fan-out を実装してからでは「なぜか2通目が失敗する」という本番障害として現れていた。実装より前、設計を読み直す段階で見つけられたのは、“今動くか”ではなく”次に足すと何が壊れるか”を先に問うたからだ。動いているコードほど、その問いを忘れやすい。
関連する記事
- 送信側で、状態変更とイベント送信を取りこぼさず確実に結びつける(Outbox)話は 振込の途中でサービスが落ちても、お金が消えないように — Saga と Outbox で分散した口座間の整合を守る に書いた。この記事の Inbox は、その受信側の対になる。