本文へ移動
Event-Driven Integrity

イベント駆動の整合性Event-Driven Integrity

データベースの更新と、その後の通知メッセージの送信は、本来それぞれ別の処理です。そのため「更新はできたが通知は送れなかった」という食い違いや、同じ通知が重複して届くことが起こり得ます。こうした事態を防ぐため、3 つの仕組みを実装しました。送り出し側の Outbox(アウトボックス)パターン、流通を担う Pub/Sub(パブサブ。発行・購読型のメッセージ基盤)、受け取り側の 2 層の冪等性(べきとうせい。何度処理しても結果が変わらない性質)です。

実装済み: 送り出し・受け取り・退避の仕組み 今後: 実イベントを積む / 受けて動く業務処理
01

なぜこの仕組みが要るのかThe problem

松葉では「ファイルがアップロードされた」「ウイルス検査が終わった」などの出来事(イベント)を、別のサービスへ伝えて連鎖的に処理します。このとき注意すべき点が 2 つあり、それぞれに対処しました。

送り出し: DB の更新とメッセージ送信は本来別系統のため、「DB は更新できたが、送信前に障害でメッセージが失われる」ことが起こり得ます。→ Outbox パターンで対処します。

受け取り: Pub/Sub の配送保証は「最低 1 回(at-least-once。確実に届く代わりに、まれに同じものが 2 回届く)」です。→ 受信側の冪等性で対処します。

Dispatcher
1分間隔

送り出し役(dispatcher)が 1 分ごとに未送信を拾って publish。

Idempotency
2層

重複の早期排除 + 外部副作用の効果層、二重で重複処理を防止。

Retry → DLQ
上限到達で退避

上限回数まで失敗したメッセージは捨てず退避キュー(DLQ)へ保全。

Topics
13 + 13主 / DLQ

業務イベント用 13 種と、同数の退避先 DLQ(前段で整備済の流通路)。

02

送り出し側 — Outbox パターンOutbox pattern

メッセージをその場で直接送るのではなく、業務データの更新と同じトランザクション(まとめて成功/失敗する一区切り)の中で、いったん outbox_events という発信箱テーブルに書き留めます。送信は別系統の処理(dispatcher)が後追いで確実に行うため、ネットワーク障害が起きてもメッセージを取りこぼしません。

このアプリでの例取引先へ共有リンクを発行したとき、発行の記録を保存するのと同じトランザクションで「受取人へ案内メールを送る」指示を発信箱に書き留めます。画面の操作はその場で完了し、メール送信は dispatcher が後追いで確実に行います。
実線 = 実装済み・本番疎通済み 破線 = まだ実装していない業務処理(今後の開発で対応)
図 01 · 更新と同一トランザクションで発信箱に追記 → 運び手が後追いで送信
業務処理例: 共有リンク発行 など同一トランザクションoutbox_events に追記発信箱テーブル · status=pending業務データ更新と同時にコミットoutbox-dispatcher1 分ごと · 取りこぼし防止ロック取得Pub/Sub トピックevent_id を付けて発行publish受信側へ成功失敗published送信完了として記録再試行 → dead_letter上限到達で退避状態に

到達点: 発信箱テーブル・運び手(dispatcher)・発行までをコードで実装し、本番にデプロイ済みです。実際にイベントを積む業務処理(左の破線。例: 共有リンク発行)は、今後の開発で追加します。dispatcher は 1 分間隔で動き、複数同時に動いても同じメッセージを二重送信しないための取得ロック(FOR UPDATE SKIP LOCKED)を使っています。

発信箱の状態意味と扱い
pending送信待ち。運び手が次回拾って publish する。
published送信完了。published_at に時刻を記録。
dead_letter送信の再試行が 上限回数に達した。手動対応のため退避(試行のたびに attempts を加算)。

設計上のトレードオフ: 「いったん溜めて 1 分ごとに送る」ため、送信に最大 1 分の遅延が出ます。これは「確実に届ける(取りこぼさない)」ことと引き換えに受け入れた、意図的な設計判断です。

03

受け取り側 — 2 層の冪等性Idempotency

Pub/Sub は「最低 1 回」配送のため、まれに同じイベントが 2 回届きます。そのまま処理すると「同じ案内メールが 2 通届く」「同じファイルが二重に登録される」といった不都合が生じます。これを 2 つの層で防ぎます。

このアプリでの例「ウイルス検査が完了(問題なし)」というイベントを受けて Google Drive へ同期する処理では、同じイベントが 2 回届いても Drive 上のファイルは 1 つだけになります。1 回目で処理済みとして記録し、2 回目は記録を見てスキップするためです。
図 02 · 受信 → 送信元確認 → 既処理チェック → 業務処理 → 記録 → 応答
Pub/Sub 受信Push で届く① 送信元を検証OIDC(正規の発行元か)② 既処理か確認processed_events で event_id 照合既処理 → 即 応答2xx を返して重複を無視済未処理なら③ 業務処理例: Drive 同期 など④ 処理済みを記録markProcessed(同一Tx)2xx = 応答消費完了(ack)

第 1 層(早期排除): processed_events に処理済みの event_id を記録し、2 回目以降は業務処理をスキップして即応答します。このテーブルはシステム専用権限(service_role)でのみ読み書きでき、業務ユーザからは触れません。
到達点: 送信元検証(OIDC)・既処理チェック・記録の枠組みを実装。中央の業務処理(破線。例: Drive 同期)は、今後の開発で追加します。

第 2 層(効果層の冪等性): メール送信や Google Drive 操作のような外部への副作用は DB の取り消し(ロールバック)が効きません。そこで処理そのものを「何度実行しても同じ結果」になるよう設計します。例: Drive 同期は Drive 側の付帯情報(appProperties)と突き合わせて重複作成を防ぎ、通知は宛先ごとの送信状態を見て二重送信を防ぎます。第 1 層だけでは防ぎきれない領域を、この第 2 層が受け持ちます。

04

取りこぼさない — 2 つの再試行と退避先Two retry layers & DLQ

再試行の仕組みは 独立した 2 つの層に分かれています。混同しやすいため、図で分けて示します。送り出し(Outbox)側と、流通(Pub/Sub)側は、別々に「相手に届くまで」を担保します。

このアプリでの例Google Drive 側が一時的に不調で、ある同期イベントの配信が上限回数まで続けて失敗した場合、そのイベントは破棄せず退避先(DLQ)へ保全します。復旧後に内容を確認し、手動で再投入できます。
図 03 · 送り出しの再試行(上)と、配信の再試行 → DLQ(下)は別レイヤ
Layer 1 — 送り出しの再試行(Outbox 内)outbox-dispatcherpublish を試行失敗attempts を加算(上限未満)pending のまま次回また送る上限dead_letter退避状態にして手動対応Layer 2 — 配信の再試行(Pub/Sub 内)サブスクリプションPush · 上限まで再配信上限到達DLQ トピック退避先(13 種に各 1)DLQ 受信(保全)無期限保持・件数を監視dlq-monitor件数を監視(未実装)

上段は「まだ送り出せていない」メッセージの再試行(発信箱の中の話)、下段は「送り出した後、受信側が受け取れない」ときの再試行(流通路の中の話)です。両者は別々のしきい値・別々の退避先を持ちます。

なぜ 2 層に分けるのか: 送り出す前の失敗(DB やネットワーク)と、送り出した後の失敗(受信側の不具合)は、原因も対処も異なります。層を分けることで「どこで詰まったか」が切り分けられ、退避先(DLQ)の中身を見れば原因の見当がつきます。13 種類のイベントすべてに同じ構造の退避先を 1 対 1 で用意しています。