Microservicesでデータ変更とイベント送信を確実に担保するOutbox Pattern


この記事は Kyash Advent Calendar 2019 の8日目の記事です。
Outbox Patternという、マイクロサービス間でのデータ送信をより堅固なものにするパターンについて、実際にKyashで取り入れている例を交えつつ紹介していきます。

まえがき

弊社Kyashではマイクロサービスの思想に基づき一連のインフラストラクチャを構築しており、マイクロサービス間ではイベントと呼ばれる単位でデータをat-least-onceに送受信することで相互通信を行なっています。
この記事ではそのあたりについての話は省略しますが、興味がある方はぜひ次のスライドもご参照ください: builderscon tokyo 2019 ウォレットアプリKyashの先 〜 Kyash Directのアーキテクチャ

マイクロサービス間の通信

さて、「マイクロサービス間の通信」と一口に言ってもここには色々な面倒くささがあるわけです。例えば主たるデータをRDBMSで管理しているごく一般的なサービス群があると仮定して、以下のようなケースを考えます。

  1. サービスAでDB上のレコードを変更しコミット
  2. サービスAから当該レコードを変更した旨のイベントを送信
  3. 他サービスがイベントを受信してそれに応じた処理を実行

このような処理はほとんどの場合で上手くいきますが、残念ながら上手くいかない場合もあります。ここでは特に「1.」のコミットは成功したが「2.」のイベントの送信は失敗してしまった、というような場合を考えてみましょう。

このような場合、本来サービスBで行われるべきであった処理が行われなくなってしまいますし、とはいえサービスAでのレコードの変更はコミット済みです。
コミット前の状態に戻すことは可能といえば可能ですが、そもそもコミット前の状態に戻すためのトランザクションのコミットが失敗する可能性もあります。
このコミットが成功するまでそれを繰り返すという手もありますが、その間にレコードに対してさらなる変更が加えられるかもしれません。恐ろしや恐ろしや。

Outbox Pattern

このような問題への解決策として、この記事のタイトルでもある Outbox Patternという名前のパターンがあります。
Outbox Patternでは以下のようにして件の問題に対処します。

  1. サービスAでDB上のレコードを変更
  2. 当該レコードを変更した旨のイベントもシリアライズして同一トランザクションで保存
  3. トランザクションをコミット
  4. シリアライズしたイベントと同一のイベントを送信
  5. 「4.」に失敗した場合、DB上にシリアライズされたイベントの内容を元に成功するまで送信をリトライ
  6. サービスBがイベントを受信してそれに応じた処理を実行

Outbox Patternのキモは、レコードの変更と不可分であるイベントを同一トランザクションでコミットして整合性を担保している点、またそれを元にイベントの送信が成功するまでリトライし続け、at-least-onceなイベント送信を担保する点にあります。
これにより、レコードの変更とイベントのどちらか片方が失われることはありませんし、仮にサービスが何らかの理由で停止したとしても、イベントはRDBMS上に残っているため「5.」の状態から再開することができればイベントを送信することができます。やったね。

Kyashでの例

Kyashではイベントの送信に際して以下のような簡素なテーブルを用意し、これを定期的にポーリングすることでイベントの確実な送信が成されるようにしています。

CREATE TABLE outboxed_events (
    id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY,
    lock_id VARCHAR(63) DEFAULT NULL,
    event JSON NOT NULL,
    released_at DATETIME,

    KEY released_at_idx(released_at),
    KEY lock_id_idx(lock_id)
);

このテーブルから再送信されるイベントを決定する際は、released_at列が現在時刻よりも過去のレコードについてlock_id列にランダムなID(<RANDOMSTRING>)、他のポーリングによって処理されないようにreleased_at列に未来の時刻を設定します。

UPDATE outboxed_events \
  SET lock_id = <RANDOMSTRING>, released_at = CURRENT_TIMESTAMP()+10 \
  WHERE released_at <= CURRENT_TIMESTAMP();

このUPDATEで変更された行があれば、同一のlock_idで対象のレコードを選択し、イベント内容を再送信すれば良いです。再送信が成功すればレコードを削除しますが、at-least-onceで送信されてくれれば良いので削除の成否にはあまりこだわっていません。何らかの理由で選択されたレコードの再送信が失敗しても、released_at列の時刻になれば自ずと再送信の対象になります。

Outbox Patternの色々な紹介を見ると、ポーリングはパフォーマンスに懸念がある旨が書かれています。RDBMSのテーブルをキューのように扱うのもあまり行儀の良い手法ではないかもしれませんが、個人的には実装の容易さ等を天秤に書けてありかなと思っています。

Kyashではイベント送信の必要性が生じた場合、パターンに従ってイベントをテーブルに保存しつつ、とりあえずその時点でイベント送信を試行し成功すればそのままイベントを削除、という流れにし、イベント用のテーブルのサイズが肥大化しないようにしています。

さいごに

Outbox Patternどうでしょう。個人的にマイクロサービスな環境に触れ始めて間もないのですが、モノリスなシステムと比べて整合性の担保に一手間かかるなという気もします。
こういう整合性を維持するパターンを実装していくことで、より安定したシステムの提供を目指したいですね。

参考資料