命令ではなく事実を送る
一言でいうと
同期呼び出しは「これをやって」で、イベントは「こういうことがあった」です。後者は、相手が今生きていなくてもかまいません。
なぜ必要なのか
注文が作られたら、在庫を減らし、ポイントを計上し、通知を送り、分析イベントを溜める必要があるとしましょう。これを同期呼び出しでつなぐと、注文APIの応答時間は4つのサービスの応答時間の合計になり、可用性は4つのサービスの可用性の積になります。それぞれ99.9%なら、合わせて99.6%です。そして、通知サービスが落ちると、注文を受け付けられません。通知のせいで売上が止まる設計です。
イベントに変えると、注文サービスは「注文が作成された」という事実を1つだけ発行して終わりです。残りの4つは、それぞれの速度でそれを消費します。通知サービスが10分間落ちていても、注文は受け付け続けられ、復旧したら溜まったイベントを処理します。
どう動くのか
ここで重要なのは名前です。SendNotificationはコマンドで、OrderCreatedは事実です。コマンドをキューに送ると、それは単なる非同期RPCです。送信者が受信者を知っていて、受信者が増えると送信者のコードが変わります。事実を発行すれば、送信者は誰が聞いているかを知りません。新しいコンシューマーが加わっても、発行者はそのままです。これが、結合を実際に減らす地点です。
イベントのペイロードには、コンシューマーが自分の仕事を終えるのに必要な情報を、十分に入れます。コンシューマーが発行者のDBを逆に問い合わせると、結合が復活します。ただし、入れすぎるとイベントがスキーマになり、スキーマはそのまま契約なので、変えにくくなります。実務での妥協は、識別子と一緒に、変わらないはずの中核のフィールドだけを入れることです。
代償も明確です。結果整合性です。注文は作成されたのに、在庫はまだ減っていない可能性がある時間の窓が生じます。その窓が200msなのか30秒なのか、その間にユーザーが何を見るのかを、プロダクトの観点で決めなければなりません。「まもなく反映されます」という画面の文言が、実はアーキテクチャの決定なのです。
イベント名が設計を決める
同じ内容を入れても、名前によってシステムが変わります。表で見ると明確です。
| 名前 | 種類 | 送信者が知っていること | コンシューマーが増えると |
|---|---|---|---|
SendNotification |
コマンド | 誰が処理するかを知っている | 送信者のコードが変わる |
ReserveStock |
コマンド | 誰が処理するかを知っている | 送信者のコードが変わる |
OrderCreated |
事実 | 誰も知らない | 送信者はそのまま |
PaymentApproved |
事実 | 誰も知らない | 送信者はそのまま |
過去形の動詞を使えば、自然に事実になります。名前の規則1つが、アーキテクチャを守ります。
コマンドが必要な場面もあります。「今これをやれ」が、ちょうど1つのコンシューマーに 届かなければならないときです。そのときは、キュー(ポイントツーポイント)を使い、事実はトピック(パブリッシュ・サブスクライブ)で 送ります。両方を同じチャネルに混ぜると、すぐに区別が失われます。
発行が失敗する場面
最も危険な瞬間は、DBはコミットされたのに、イベントの発行が失敗することです。 注文は作られたのに、誰も知りません。逆に、イベントを先に送ってDBが失敗すると、 存在しない注文のイベントが出回ります。
❌ 위험한 순서
BEGIN; INSERT order; COMMIT;
publish(OrderCreated) ← 여기서 죽으면 이벤트가 영영 안 나간다
✅ 아웃박스 패턴
BEGIN;
INSERT order;
INSERT outbox(topic, payload); ← 같은 트랜잭션 안
COMMIT;
(별도 프로세스가 outbox 를 읽어 발행하고 표시한다)
1つのトランザクションの中で両方がコミットされるので、「注文はあるのにイベントがない」状態が、原理的に 不可能になります。その代わり、発行が少し遅くなり、同じイベントが2回出ていく 可能性があります(発行の後、マークの前に落ちた場合)。そのため、コンシューマーは常に冪等でなければなりません。
スキーマは契約
イベントを発行した瞬間、それは公開APIになります。誰が聞いているかわからないので、むやみに 変更できません。2つの規則で、たいてい持ちこたえられます。
- フィールドは追加だけにします。削除したり名前を変えたりすると、古いコンシューマーが壊れます。
- バージョンを名前に入れます。本当に互換性が壊れる変更なら、
OrderCreated.v2を新しく 出して、しばらくは両方を発行します。
コンシューマー側にも規則があります。知らないフィールドは無視します。厳格なデシリアライズ
(additionalProperties: false)を使うと、発行者がフィールドを1つ追加するたびに、コンシューマーが
すべて壊れます。
現場での姿
非同期に変えると、デバッグが難しくなります。同期呼び出しはスタックトレース1つで済みますが、イベントは発行の時点と消費の時点が離れています。そのため、イベントに相関IDを必ず載せる必要があります。これなしでイベントアーキテクチャを運用するのは、目を閉じて運転するのと同じです。
もう1つ。イベントは、順序が保証されないことがあります。同じ注文のOrderCreatedとOrderPaidが、逆転して届くと、コンシューマーが壊れます。パーティションキーを集約のID(例: 注文ID)にすれば、同じ集約の中では順序が保証されます。グローバルな順序は、あきらめるのが普通です。
次のクイズで確認すること
このモジュールは概念だけを扱います。イベントを実際に安全に発行する問題は、すぐ次のモジュールのアウトボックスのラボで、手を動かして扱います。