TT Lab
はじめる
学ぶ 学習パス コース

マイクロサービスアーキテクチャ

二重書き込み問題とその解法

TT Labで続きを見る

一言でいうと

DBに書き込むことと、ブローカーに発行することの2つの動作は、原子的ではありません。アウトボックスは、その2つを1つのDBトランザクションに折り込む手法です。

なぜ必要なのか

最もよくあるコードから見てみましょう。

def create_order(req):
    order = db.save(Order(req))        # 1. DB 저장
    broker.publish("OrderCreated", order)  # 2. 이벤트 발행
    return order

このコードブロックの韓国語コメントは、順に1がDBへの保存、2がイベントの発行、という意味です。

この5行には、深刻な問題があります。1は成功したのに2が失敗すると、注文は存在するのに、誰も知りません。逆に、2が成功した後でトランザクションがロールバックされると、存在しない注文のイベントが世の中に広まります。これが二重書き込み問題です。

「2つを1つの分散トランザクションにまとめればよいのではないか」という考えは自然ですが、ほとんどのブローカーはXAトランザクションに参加できず、2PC自体が、コーディネーターの障害時に参加者を握り続ける可用性の問題を生みます。そのため、実務では別の道を行きます。

どう動くのか

アウトボックスの考え方は単純です。イベントをブローカーに直接送らず、ビジネスデータと同じトランザクションでoutboxテーブルに1行入れます。DBのACIDが、2つの書き込みの原子性を保証します。その後、別のプロセス(リレー)がそのテーブルを読んで、ブローカーに移します。

テーブル設計で重要なカラムは4つです。aggregate_id(パーティションキーとして使う)、event_type、payload、そして状態または発行時刻です。リレーの方式は2つあります。定期的にPENDINGを問い合わせるポーリングと、DBのトランザクションログ(WAL、binlog)を読むCDCです。CDCは遅延が小さくDBの負荷も少ないのですが、運用のコンポーネントが1つ増えます。

ここで必ず理解しておくべき点が1つあります。アウトボックスは、消失をなくしますが、重複はなくせません。リレーがブローカーに発行した直後、状態をPUBLISHEDに変える前に落ちると、再起動後に同じイベントをもう一度発行します。これがat-least-onceで、この地点で、コンシューマーの冪等性が必須になります。厳密に1回は、配送が作る性質ではなく、受信側が作る性質です。

サーガは、別の問題を解きます。複数のサービスにまたがる1つのビジネストランザクションを、ローカルトランザクションの連鎖と、失敗時の補償トランザクションで構成します。決済が成功して配送が失敗したら、決済を取り消す補償のステップを実行します。サーガは、ロールバックではなく、前に進む取り消しであるという点が要点です。すでに送ったメールは元に戻せず、「キャンセルのお知らせメール」をもう1通送るだけです。

サーガには、コレオグラフィ(各サービスがイベントを聞いて次の行動を取る)と、オーケストレーション(中央の調整役が順序を指示する)の2つの形があります。ステップが3つ以下ならコレオグラフィが軽く、4つを超えると、流れがどこへ向かっているのか誰にもわからなくなるので、オーケストレーションのほうがよいでしょう。

現場での姿

アウトボックステーブルは、放置すると肥大化します。発行が完了した行を定期的に削除するか、パーティションを切って捨てる必要があります。そして、status='PENDING'の条件の部分インデックスを置かないと、リレーのクエリがテーブル全体をなめます。

補償トランザクションでよくあるミスは、補償自体が失敗しうるという事実を無視することです。補償もリトライされなければならず、したがって、補償も冪等でなければなりません。

イベントをどう設計するのか

アウトボックスとサーガが動作するには、その上を流れるイベント自体がよく設計されていなければなりません。ここがおろそかだと、手法をどれだけ正確に実装しても、数か月後に崩れます。

イベントは事実であって、コマンドではありません。OrderCreatedはすでに起きたことなので元に戻せず、聞く側が何をするかは、聞く側が決めます。一方、SendEmailのようにコマンドをイベントとして流すと、送る側が受け取る側の事情を知っている必要があり、サービスを分けた意味がなくなります。

必要な情報を入れるのか、参照だけを渡すのかを決めます。イベントに注文内容をまるごと入れると、聞く側が問い返さなくて済みますが、イベントが大きくなり、その中の値が古くなることがあります。逆にIDだけを渡すと、聞く側が毎回問い合わせなければならず、元のサービスに負荷が集中し、そのサービスが落ちるとイベントを処理できなくなって、非同期に分けた利点が失われます。実務での折衷案は、その時点の事実として固定すべき値は入れ、現在の状態を見る必要があるものは参照にしておくことです。前の正規化の話と同じ基準です。

形式が変わることを前提にします。イベントは、一度発行されると、複数のサービスがそれぞれの速度で読むので、送る側と受け取る側を同時にデプロイできません。そのため、フィールドを追加する変更だけを許可し、削除したり意味を変えたりする変更は、新しい名前のイベントとして出します。スキーマをどこかに登録して互換性を検査する仕組みがあれば、この規則が自動的に守られます。

時刻と順序を入れます。イベントに発生時刻と連番を入れておくと、遅れて届いた古いイベントを、受け取る側が見分けて無視できます。前に、リトライが順序を壊すと言った問題の実用的な解決策がこれで、この値がないと、受け取る側は到着順を発生順と勘違いしてしまいます。

次のラボですること

SQLiteで注文とアウトボックスのテーブルを作り、まず二重書き込みの不整合を再現してから、1つのトランザクションに折り込み、リレーをRedisで発行するようにし、リレーが途中で落ちたときに重複が生じることまで確認した後、コンシューマーに重複排除を付けます。