「保存してから送る」はなぜ壊れるのか
注文を保存して、イベントを送る。たった2行の間でプロセスが落ちるだけで、メールの来ない注文や二重の請求が生まれる。注文の流れに沿って、どこで何を守ればよいかを確かめる。
どこで落ちても、片方だけが残る
ECサイトで注文を受けたら、注文をDBに保存し、「注文作成」イベントをメッセージブローカーに送る。在庫の引当と確認メールは、このイベントを受けて動く。
問題は、DBとブローカーが別のシステムで、2つをまとめて確定できないことだ。プロセスはデプロイやノードの故障で、どの行の間でも落ちうる。
- DB: 注文を保存する
- アプリ: デプロイでプロセスが止まる
- ブローカー: イベントは送られない
- アプリ: 在庫も確認メールも動かない
| 書き方 | 落ちる場所 | 結果 |
|---|---|---|
| 保存してから送る | 保存の後、送信の前 | 注文はあるのにイベントが消える |
| 送ってから保存する | 送信の後、保存の前 | 存在しない注文のイベントが流れる |
| トランザクションの中で送る | 送信の後、COMMITの失敗 | 送ったイベントは取り消せない |
どの順に並べても、1回の障害で片方だけが残る。DBとブローカーをまたぐ分散トランザクションは、多くの構成で使えないか重すぎる。
outbox:送る予定を、同じトランザクションで書く
イベントを直接送るのをやめる。業務データと「これから送るイベント」を、同じDBの同じトランザクションで書く。両方残るか、両方残らないかのどちらかになる。
BEGIN;
INSERT INTO orders (id, user_id, total, status)
VALUES ('ord_123', 'u_9', 4800, 'pending');
INSERT INTO outbox (id, aggregate_id, type, payload)
VALUES ('evt_456', 'ord_123', 'OrderCreated',
'{"orderId": "ord_123", "total": 4800}');
COMMIT;送信は、別のプロセス(リレー)がoutboxテーブルを読んで行う。作り方は2通りある。
| 比べる点 | ポーリング | CDC(変更ログを読む) |
|---|---|---|
| 仕組み | 未送信の行を定期的に読み、送ったら印を付ける | WALやbinlogを読んでブローカーへ流す(Debeziumなど) |
| 部品 | 少ない | CDCのツールが増える |
| 遅れ | ポーリングの間隔の分 | ほぼすぐ |
| 順序 | リレーを分担すると崩れうる | COMMITの順に読むので保ちやすい |
-- 未送信の行を取る。ほかのリレーと同じ行を取り合わない
SELECT id, aggregate_id, type, payload
FROM outbox
WHERE sent_at IS NULL
ORDER BY created_at
LIMIT 100
FOR UPDATE SKIP LOCKED;
-- 送れたら印を付ける。ここで落ちると、再起動後にもう一度送る
UPDATE outbox SET sent_at = now() WHERE id = ANY($1);exactly-onceは、システムの外までは届かない
受け手にも同じ問題がある。コンシューマーはメッセージを処理してから、どこまで読んだか(オフセット)をコミットする。その間に落ちれば、再起動後に同じメッセージをもう一度処理する。
Kafkaのトランザクションは、Kafkaから読んでKafkaへ書く範囲ならちょうど1回にできる。しかし、決済APIの呼び出しや別のDBへの書き込みのような外への副作用までは守らない。
だから配信は少なくとも1回と割り切り、副作用ごとに重複を除く。自分のDBに書く副作用なら、処理済みのメッセージIDを同じトランザクションで記録する。
BEGIN;
INSERT INTO processed_messages (message_id) VALUES ('evt_456')
ON CONFLICT (message_id) DO NOTHING;
-- 挿入が0件なら処理済み。何もせずにCOMMITする
UPDATE inventory SET reserved = reserved + 1 WHERE sku = 'sku_1';
COMMIT;
-- オフセットはCOMMITの後に進める外への副作用には、注文IDを冪等キーとして渡す
決済APIは自分のDBの外にあるので、上の方法では守れない。課金してから処理済みを記録するまでの間に落ちれば、記録のない課金が残る。
そこで、注文IDのような業務上のIDを、冪等キーとして決済APIに渡す。再送で同じ注文の課金がもう一度呼ばれても、相手は1回目の結果を返す。
自分のAPIで冪等キーを受け取るなら
逆に、自分のAPIが外から再試行される側なら、キーを保存するだけでは足りない。次の3つの場面で、どう応答するかを決めておく。
| 場面 | 応答 |
|---|---|
| 同じキーのリクエストが同時に2つ届いた | キーの一意制約で、片方だけを進める |
| 最初のリクエストがまだ処理中 | 409を返し、後で再試行させる |
| 同じキーで内容が違う | 誤用として422を返す |
結果(状態コードと本文)は業務データと同じトランザクションで保存し、以降の同じキーにはそれを返す。キーの保持期間は、クライアントが再試行しうる期間より長くする。IETFで検討されているIdempotency-Keyヘッダーの草案も、同じ場面ごとの応答を定めている。
サービスをまたぐなら、サーガでつなぐ
注文・決済・在庫がそれぞれ別のサービスとDBを持つと、1つのトランザクションにまとめられない。サーガは各手順をそれぞれのサービスで確定させ、後の手順が失敗したら、先に終わった手順を補償トランザクションで打ち消す。
- 調整役: 注文を「保留中」で作る
- 決済サービス: 与信を確保する
- 在庫サービス: 在庫の確保に失敗する
- 調整役: 補償を指示する(注文IDを冪等キーに)
- 決済サービス: 与信を取り消す
- 調整役: 注文を「キャンセル」にする
ここまでの部品が、そのままサーガの部品になる。各手順の指示はoutboxで送り、受け手はメッセージIDで重複を除き、補償の呼び出しも冪等にする。
DBのロールバックと違い、途中の状態(与信は確保したが在庫はまだ)はほかの処理から見える。「保留中」を状態として明示し、確定前の注文を出荷しないようにする。
見直すときのチェックリスト
- DBへの書き込みとメッセージの送信を、別々に行っていないか。
- 受け手は、同じメッセージが2回届いても結果が変わらないか。
- 処理済みの記録を、副作用と同じトランザクションで書いているか。
- 外部APIへの書き込みに、業務上のIDを冪等キーとして渡しているか。
- 自分の書き込みAPIは、同時・処理中・内容違いの3つの場面に応答を決めているか。
- サーガの各手順に補償があり、取り消せない手順はピボットの後ろにあるか。
面接で「注文とイベントの整合性は?」と聞かれたら
- 二重書き込みは、間で落ちると片方だけが残る
- outboxで、業務データとイベントを同じトランザクションに書く
- 配信は少なくとも1回なので、受け手をメッセージIDで冪等にする
- 外部の決済には、注文IDを冪等キーとして渡す
- サービスをまたぐならサーガ。取り消せない手順はピボットの後ろへ
「exactly-onceの配信を使います」より、「少なくとも1回の配信を、冪等な処理で1回に見せます」と答えるほうが、境界がわかっていることが伝わる。