ブログ一覧

「保存してから送る」はなぜ壊れるのか

· 約9分で読めます

注文を保存して、イベントを送る。たった2行の間でプロセスが落ちるだけで、メールの来ない注文や二重の請求が生まれる。注文の流れに沿って、どこで何を守ればよいかを確かめる。

どこで落ちても、片方だけが残る

ECサイトで注文を受けたら、注文をDBに保存し、「注文作成」イベントをメッセージブローカーに送る。在庫の引当と確認メールは、このイベントを受けて動く。

問題は、DBとブローカーが別のシステムで、2つをまとめて確定できないことだ。プロセスはデプロイやノードの故障で、どの行の間でも落ちうる。

保存してから送る途中で、プロセスが落ちたとき
  1. DB: 注文を保存する
  2. アプリ: デプロイでプロセスが止まる
  3. ブローカー: イベントは送られない
  4. アプリ: 在庫も確認メールも動かない
書き方落ちる場所結果
保存してから送る保存の後、送信の前注文はあるのにイベントが消える
送ってから保存する送信の後、保存の前存在しない注文のイベントが流れる
トランザクションの中で送る送信の後、COMMITの失敗送ったイベントは取り消せない

どの順に並べても、1回の障害で片方だけが残る。DBとブローカーをまたぐ分散トランザクションは、多くの構成で使えないか重すぎる。

outbox:送る予定を、同じトランザクションで書く

イベントを直接送るのをやめる。業務データと「これから送るイベント」を、同じDBの同じトランザクションで書く。両方残るか、両方残らないかのどちらかになる。

SQL:注文とイベントを同じCOMMITで書く
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の順に読むので保ちやすい
SQL:ポーリングのリレー(PostgreSQL)
-- 未送信の行を取る。ほかのリレーと同じ行を取り合わない
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を同じトランザクションで記録する。

SQL:処理済みのIDと副作用を同じトランザクションで書く(PostgreSQL)
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つのトランザクションにまとめられない。サーガは各手順をそれぞれのサービスで確定させ、後の手順が失敗したら、先に終わった手順を補償トランザクションで打ち消す。

在庫の確保に失敗したときのサーガ(オーケストレーション)
  1. 調整役: 注文を「保留中」で作る
  2. 決済サービス: 与信を確保する
  3. 在庫サービス: 在庫の確保に失敗する
  4. 調整役: 補償を指示する(注文IDを冪等キーに)
  5. 決済サービス: 与信を取り消す
  6. 調整役: 注文を「キャンセル」にする

ここまでの部品が、そのままサーガの部品になる。各手順の指示はoutboxで送り、受け手はメッセージIDで重複を除き、補償の呼び出しも冪等にする。

DBのロールバックと違い、途中の状態(与信は確保したが在庫はまだ)はほかの処理から見える。「保留中」を状態として明示し、確定前の注文を出荷しないようにする。

見直すときのチェックリスト

  1. DBへの書き込みとメッセージの送信を、別々に行っていないか。
  2. 受け手は、同じメッセージが2回届いても結果が変わらないか。
  3. 処理済みの記録を、副作用と同じトランザクションで書いているか。
  4. 外部APIへの書き込みに、業務上のIDを冪等キーとして渡しているか。
  5. 自分の書き込みAPIは、同時・処理中・内容違いの3つの場面に応答を決めているか。
  6. サーガの各手順に補償があり、取り消せない手順はピボットの後ろにあるか。

面接で「注文とイベントの整合性は?」と聞かれたら

  1. 二重書き込みは、間で落ちると片方だけが残る
  2. outboxで、業務データとイベントを同じトランザクションに書く
  3. 配信は少なくとも1回なので、受け手をメッセージIDで冪等にする
  4. 外部の決済には、注文IDを冪等キーとして渡す
  5. サービスをまたぐならサーガ。取り消せない手順はピボットの後ろへ

「exactly-onceの配信を使います」より、「少なくとも1回の配信を、冪等な処理で1回に見せます」と答えるほうが、境界がわかっていることが伝わる。

この記事で深掘りしたTips