Upgrade to Pro — share decks privately, control downloads, hide ads and more …

Outboxパターンの素振りをした

Avatar for Kazuki Nitta Kazuki Nitta
October 16, 2026
330

 Outboxパターンの素振りをした

会社の勉強会で喋る小ネタ
https://github.com/ntk221/outbox-suburi-cf

Avatar for Kazuki Nitta

Kazuki Nitta

October 16, 2026

Transcript

  1. DBの変更を、ほかにも伝えたい どうやって届ける? 検索 Elasticsearch に反映したい キャッシュ 古くなったら消したい 注文DB INSERT /

    UPDATE / DELETE 分析基盤 DWH に取り込みたい ほかのサービス 在庫・通知に知らせたい 03 / 15
  2. 間にメッセージブローカーを置く メール送信 メッセージブローカー 注文サービス 送る 「注文が入った」という イベントを送る お客さんに通知 在庫 在庫を引き当てる

    いったん預かる 検索 検索に反映 送る側は相手を知らなくていい 受け手が止まっていても大丈夫 製品はいろいろ ブローカーに渡すだけ。受け手が増えて も、送る側は変わらない ブローカーが預かっておき、復旧したら 届ける Kafka、RabbitMQ、Amazon SQS、 Cloudflare Queues など 04 / 15
  3. 素朴なやり方の課題 例:注文を保存して、「注文が入った」イベントをブローカーに送る // トランザクションの中で送る await db.transaction(async (tx) => { await

    tx.insert("orders", order); await broker.send(orderCreated(order)); }); // ← 1. COMMIT に失敗したら? 1. 送ったのに、注文がない 送ったあとで COMMIT に失敗すると、取り消された注 文の「受け付けました」メールが届く 2. 注文はあるのに、届かない // じゃあ、コミットしてから送る? await db.transaction(async (tx) => { await tx.insert("orders", order); }); // ← 2. ここで落ちたら? await broker.send(orderCreated(order)); コミット後、送る前に落ちるとイベントが消える 3. 2PC はたいてい使えない DB とブローカーをまたぐトランザクションに、多くの ブローカーは対応していない 05 / 15
  4. Outboxパターン 二重書き込み問題を解く、定番のやり方 1つのトランザクションで書く orders outbox 中継役(relay) メッセージ基盤 注文を更新 イベントを1行追加 outbox

    を読んで送る 「注文作成」などを 他サービスが購読 片方だけ成功、が起きない イベントの形を設計できる 送り終えたら消す 書くのはDBだけ。コミットされればイベ ントも必ず出る テーブル構造をそのまま晒さず、意味の ある形で出せる outbox は放っておくと溜まる。送った分 は掃除する 07 / 15
  5. 素振りの構成 OrdersDB(Durable Object) transactionSync() … 同じトランザクションで両方書く orders 業務テーブル + outbox

    中継役(relay)は alarm。 outbox を定期的に読んで送る 外に出すイベント alarm() Queues MailSender outbox を読んで送る 送った位置を保存 順序の保証なし メール送信役(お客さんへ) 重複は送らない・順番を待つ アプリから PUT 08 / 15
  6. イベントは同じトランザクションで書く async upsert(id, status) { this.ctx.storage.transactionSync(() => { const prev

    = this.findOrder(id); this.saveOrder(id, status); // 業務の書き込み this.addEvent( // イベントも書く prev ? "OrderStatusChanged" : "OrderCreated", id, { id, status }); }); await this.scheduleRelay(); } 片方だけコミットされることはない 途中で例外が出たら、orders も outbox もロール バック イベントの形はアプリが決める 行の差分ではなく「注文作成」「ステータス変更」 // alarm をセット 1つ前の seq も書く addEvent の中で記録。受け側が注文ごとの順番 を守るのに使う 10 / 15
  7. 流れてくるイベント { "seq": 2, "type": "OrderStatusChanged", seq 通し番号。送った位置の目印 type イベントの種類。アプリが決める

    aggregate_id どの注文のイベントか prev_seq この注文の1つ前のイベント。順番待ちに 使う payload 中身。テーブルの形とは切り離せる "aggregate_id": 42, "prev_seq": 1, "payload": { "id": 42, "status": "shipped" }, "ts_ms": 1792076127301 } 行の差分ではなく、意味のあるイベントを流す 11 / 15
  8. リレーとして alarm で Queue に送る async alarm() { const sent

    = this.loadOffset(); // どこまで送ったか const rows = this.sql.exec( `SELECT * FROM outbox WHERE seq > ? ORDER BY seq`, sent ).toArray(); if (rows.length === 0) return; await this.env.EVENTS.sendBatch( rows.map((r) => ({ body: toEvent(r) }))); // ← ここで落ちたら、次の alarm で同じ seq を再送 失敗したら自動で再試行 alarm は例外を投げると、間隔を空けて再実行さ れる 送る → 位置を保存 この順なので、間で落ちると重複する。中継役は どれもこう 送り終えた分は消す const last = rows.at(-1).seq; this.saveOffset(last); // 送った位置を保存 this.sql.exec(`DELETE FROM outbox WHERE seq <= ?`, last); 消さないと outbox は増え続ける } 12 / 15
  9. 気をつけること 例:注文イベントを受けて、お客さんにメールを送る場合 1. 同じメールが2通届く relay がイベントを送ったあとに落ちてイベントが再送 される場合がある。メールを送った seq を consumer

    側で記録し、2回目は送らないようにする 2. 順番が入れ替わる Queues は順序を保証しない。注文ごとに、前のメール を送ってから次を送るようにする 3. まとめて遅れて届く relay や Queue の不具合でイベントを送れない間は outbox が溜まる。未送信の件数を監視する [mail] send seq=3 注文43「ご注文を受け付けました」 [relay] send seq=3..3 ← 再送 [mail] skip seq=3 注文43(送信済み) [mail] hold seq=5 注文44(seq=4 待ち) [mail] send seq=4 注文44「ご注文を受け付けました」 [mail] send seq=5 注文44「お支払いを確認しました」 [relay] Queue に送れない(未送信 3 件)retry=1 (復旧) [relay] send seq=6..8 13 / 15
  10. 思ったこと consumer 側で重複をはじくには、結局「処理済み」を記録しないといけない。それ、Outbox でやりたいことができ 自分 てなくない? そうでもない。Outbox は業務の記録とイベントの記録をアトミックにするテクニックで、配信されるイベントの重複 は at-least-once

    を選んだ時点で残る。consumer 側の副作用が DB なら、処理済み seq を業務の書き込みと同じトラ Claude ンザクションで書けば済む。 じゃあメール送信のような外部システムへの副作用のときは、わざわざ Outbox にするメリット薄い? 自分 薄くない。比較相手は「コミット後に送る」。幻のメール・届かない・2通届く、のうち一番マシな「2通」だけが残 る。また、外部サービスによっては冪等キーを渡すと重複を弾いてくれる API があるので、seq をそのまま渡せる。 Claude 14 / 15
  11. おわり 今日のコード https://github.com/ntk221/outbox-suburi-cf Pattern: Transactional outbox microservices.io › patterns Durable

    Objects:Alarms developers.cloudflare.com › durable-objects Durable Objects:SQLite Storage API Q&A 質問・ツッコミ、どうぞ developers.cloudflare.com › durable-objects Queues:Delivery guarantees developers.cloudflare.com › queues 15 / 15