第17章:冪等性(同じのが2回来ても壊れない)🛡️🔁
この章のゴール🎯✨
- Outbox を使っていても「二重送信が起こり得る」理由をちゃんと理解する😇📨
- 冪等(べきとう)=「同じものが2回来ても結果が1回分になる」状態を作れるようになる🛡️
- **冪等キー(idempotency key)**の作り方・持たせ方・使い方がわかる🔑
- TypeScript で「重複を安全に捨てる」超定番の実装が書けるようになる🧑💻✨
1) Outboxでも二重送信は“起こり得る”😇📮
Outbox は「送る予定をDBに残す」ので 送信漏れを減らせるけど、送信処理は現実世界(ネットワーク、外部API、メッセージ基盤)と戦うから、どうしても **“少なくとも1回” 配信(=時々重複する)**が起きがちなんだよね📨🌩️ たとえば AWS の標準キューは “at-least-once” で、同じメッセージが複数回届く可能性があるって説明されてるよ📦🔁。(AWS ドキュメント)
二重が起きる典型パターン👇😵💫
- 送信自体は成功してたのに、成功レスポンスを受け取る前にタイムアウト→再送🔁⏱️
- ワーカーが処理中に落ちた(送ったか不明)→復旧後に再処理🔁💥
- 並行実行で「同じOutbox行」を別ワーカーが掴んだ(ロックが甘い)👯♀️🔒
- メッセージ基盤が「たまに重複配送」する設計(=普通にある)📮📮
なのでこの章の結論はこれ👇 Outboxの世界は “at-least-once を前提に、冪等性で安全にする” が基本形🛡️🔁
2) 冪等性ってなに?🧠✨(超ざっくり)
冪等=「同じ操作を何回やっても、結果が1回分と同じ」って性質だよ🙆♀️✨
例で覚えるのが一番👇🍀
- ✅ 冪等:
支払い完了フラグ = trueをセット(何回trueにしてもtrue) - ❌ 非冪等:
ポイント += 100(2回来たら200増える) - ❌ 非冪等:
メール送信(2回来たら2通送られる)
Outboxのイベント処理って、放っておくと「+=」や「メール送信」みたいな非冪等が混ざりやすいから、**“重複が来ても1回に見せる仕組み”**が必要になるよ🛡️📨
3) 冪等キー(idempotency key)の考え方🔑✨
冪等キーは「この操作は“同一の操作”だよ」って識別するためのキーだよ🔎🔑
3-1) 良い冪等キーの条件✅
- リトライしても同じ値(再送のたびに変わると意味ない)🔁❌
- 十分ユニーク(別の操作と衝突しない)🆔✨
- **“業務的に1回であるべき単位”**を表している🎯
3-2) 冪等キーの作り方(定番)🍱
代表的にはこのへん👇
- **イベントID(UUID)**を冪等キーとして使う🆔
- コマンドID(例:注文確定ボタン押下1回)を作って、それをイベントに引き継ぐ🛒🆔
- 自然キー(例:
orderId + "OrderConfirmed")みたいに“同一性”が明確ならそれでもOK(ただし注意あり⚠️)
3-3) 外部APIの冪等キーは世界標準になりつつある🌍🔑
決済などの外部APIでは「同じPOSTをリトライしても1回分にする」ために Idempotency-Key を使う設計がよくあるよ💳✨ たとえば Stripe は「同じ idempotency key なら同じ結果を返す」仕組みを提供してる📨🧾。(docs.stripe.com) HTTP でも Idempotency-Key ヘッダーを標準化しようという仕様ドラフトがあるよ🧩📬。(IETF Datatracker)
4) 冪等性はどこで守るのが正解?🛡️🏰
結論:受け側で守るのが最強💪✨ (送る側でもできる範囲で守ると、さらに強い)
4-1) 送る側(Publisher側)でできること📤🧠
- Outbox 行の
idをイベントIDとして固定し、再送でも同じイベントIDを使う🆔🔁 - 送信先に Idempotency-Key を渡せるなら渡す(HTTPでもメッセージでも)📨🔑
- ただし…送る側だけでは「相手が何をしたか」を完全には保証できない🙅♀️
4-2) 受け側(Consumer側)で守ること📥🛡️
受け側の鉄板はこれ👇 「Inbox(処理済みテーブル)」で重複排除する🗃️✨
- 受信したイベントIDを Inboxテーブルに記録
- すでに存在したら “重複なのでスキップ”
- 初回だけ「業務処理」を進める🎯
これが Outbox と対になる定番で、実運用の安心感が段違いになるよ🥹🫶
5) ハンズオン:Inboxで重複排除を実装しよう🧪🛠️✨
ここからは「最小で強い」実装を作るよ💪 ポイントは **DBの一意制約(unique)**に仕事を任せること🎛️✨
5-1) Inboxテーブル(最小構成)🗃️
event_id:処理済みかどうかの判定キー🆔processed_at:いつ処理したか🕒handler:どのハンドラが処理したか(デバッグ用)🧭
(DBは例として PostgreSQL で書くよ。ON CONFLICT の説明は公式ドキュメントにあるよ📚✨)(PostgreSQL)
-- Inbox(処理済み)テーブル
CREATE TABLE inbox_processed (
event_id uuid PRIMARY KEY,
handler text NOT NULL,
processed_at timestamptz NOT NULL DEFAULT now()
);
✅ PRIMARY KEY / UNIQUE が超重要:重複が来たらDBが弾いてくれるからね🛡️
5-2) 「最強の1行」:まずInboxに“予約”する🔒✨
イベントを処理する前に、まず Inbox に insert するよ👇
- insert が成功=初回🎉 → 処理してOK
- insert が衝突=重複😇 → 何もせず終了
PostgreSQLならこれが定番👇(衝突したら何もしない)
ON CONFLICT DO NOTHING (PostgreSQL)
INSERT INTO inbox_processed(event_id, handler)
VALUES ($1, $2)
ON CONFLICT DO NOTHING;
5-3) TypeScript実装(例:pgでトランザクション)🧑💻✨
重要ポイント👇💡
- Inbox insert と 業務更新 は 同じトランザクションに入れる🔐
- 途中で落ちたらロールバックされて、次回リトライでやり直せる🔁
import { Pool } from "pg";
type IntegrationEvent = {
id: string; // uuid
type: string; // eventType
payload: unknown; // JSON
};
export class InboxIdempotency {
constructor(private readonly pool: Pool) {}
/**
* 重複なら何もせず return(=副作用を起こさない)🛡️
* 初回だけ handlerFn を実行する🎯
*/
async runOnce(
event: IntegrationEvent,
handlerName: string,
handlerFn: (client: any) => Promise<void>,
): Promise<"processed" | "duplicate"> {
const client = await this.pool.connect();
try {
await client.query("BEGIN");
// ① まず Inbox に「処理権」を取りにいく🔒
const res = await client.query(
`
INSERT INTO inbox_processed(event_id, handler)
VALUES ($1, $2)
ON CONFLICT DO NOTHING
RETURNING event_id;
`,
[event.id, handlerName],
);
// ② 取れなかった=重複イベント😇(安全に無視)
if (res.rowCount === 0) {
await client.query("ROLLBACK");
return "duplicate";
}
// ③ 取れた=初回だけ業務処理を実行🎯
await handlerFn(client);
await client.query("COMMIT");
return "processed";
} catch (e) {
await client.query("ROLLBACK");
throw e;
} finally {
client.release();
}
}
}
使い方イメージ🍀
async function handleOrderConfirmed(event: IntegrationEvent, idem: InboxIdempotency) {
return idem.runOnce(event, "HandleOrderConfirmed", async (tx) => {
// 例:通知テーブルに1件入れる(=本当はメール送信など)
await tx.query(
"INSERT INTO notifications(order_id, message) VALUES ($1, $2)",
[(event as any).payload.orderId, "注文が確定しました!"],
);
});
}
6) “外部API呼び出し”があるときの冪等性💳📨🛡️
メール送信や決済みたいな外部APIは、こちらのDBトランザクション外で起きるから難易度が上がるよね😵💫
6-1) 外部APIが Idempotency-Key をサポートしてるなら最優先で使う🔑✨
Stripe みたいに 同じキーなら同じ結果を返す仕組みがあると、リトライ地獄が一気に楽になるよ🙏✨(docs.stripe.com)
// fetch の例(外部APIが idempotency をサポートしている想定)
await fetch("https://example.com/payments", {
method: "POST",
headers: {
"Content-Type": "application/json",
"Idempotency-Key": event.id, // ✅ イベントIDをそのまま使うのが楽
},
body: JSON.stringify(event.payload),
});
※ “Idempotency-Key” はHTTPでも広く使われていて、標準化の仕様ドラフトもあるよ📬🧩。(IETF Datatracker)
6-2) サポートしてない外部APIならどうする?😵💫
選択肢は主に2つ👇
- 外部APIを叩く前に 自分のDBに「送信済み記録」を残して、重複なら叩かない(ただし順序と例外設計が大事)🗃️
- 外部APIの結果を取り込む設計(Webhook/ポーリング)に寄せて「最終結果」を基準にする🧲
このへんはプロジェクト事情で最適解が変わるけど、まずは Idempotency-Key 対応の外部APIを選ぶのが現実的に強いよ💪✨
7) ありがち落とし穴集⚠️😇
落とし穴①:リトライのたびに冪等キーを作り直す🔑❌
- それ、毎回“別操作”になっちゃうよ😭
- ✅ 同じ操作なら同じキー!
落とし穴②:Inboxに書くのが“処理の後”🗃️❌
- 処理→落ちる→リトライ→また処理…で二重になる😵💫
- ✅ 最初に Inbox に insert!
落とし穴③:Inboxが「イベント単位」じゃなく「注文単位」だけ🚚⚠️
- 注文に複数イベントがあると、雑に弾いて事故ることがあるよ💥
- ✅ 基本は **event_id(イベント単位)**で弾くのが安全
落とし穴④:副作用がDB外(メール/決済)なのに対策なし📨💳❌
- ✅ 可能なら Idempotency-Key
- ✅ それが無理なら「送信済み記録」と設計の工夫が必要
8) テストで安心する🧪✅
8-1) 最低限のテスト観点(これだけで強い)💪
- 同じイベントを2回投げる → 1回だけ処理される🛡️🔁
- 途中で例外を投げる → 次のリトライでちゃんと処理される🔁💥
- 並行で同じイベントを処理する → どちらか片方だけが勝つ👯♀️🏁
8-2) “並行”の簡易テスト(イメージ)👯♀️🧪
// 疑似コード:同じ event を Promise.all で同時に処理
const results = await Promise.all([
handleOrderConfirmed(event, idem),
handleOrderConfirmed(event, idem),
]);
// processed が1つ、duplicate が1つになってほしい
9) AI活用ミニ型🤖✨(そのままコピペOK)
9-1) 設計レビュー用👀
- 「Inboxで冪等性を担保したい。今の処理は“副作用が先”になってない?危ない順序を指摘して」🧯🔍
- 「このイベントハンドラは冪等?二重実行されたら何が壊れる?」🛡️🧠
9-2) テスト増殖用🧪
- 「この runOnce 実装に対して、落ちやすい境界ケースを10個挙げて。テストケースにして」📋✨
- 「並行実行で事故るパターンを作って、どう直すか提案して」👯♀️🔒
9-3) 命名相談📛
- 「event.id / idempotencyKey / correlationId の役割を混同してないか、名前と用途を整理して提案して」🧩✨
10) まとめ🌈✨
- Outboxでも 二重配信は起こり得る(むしろ普通)📨🔁(Amazon Web Services, Inc.)
- だから **冪等性で“1回に見せる”**のが必須🛡️
- 鉄板実装は Inbox(処理済みテーブル)+一意制約+最初にinsert🗃️🔒
- 外部APIは Idempotency-Key が使えるなら最優先で使う🔑💳(docs.stripe.com)
次章の「順序(Ordering)」に進むと、**“重複は捨てられるけど、順番が崩れると困るケース”**の扱いが見えてくるよ🍱➡️🍱✨