【連載 第2回】freeeカード Unlimited での非同期通信の設計と実装

金融チームでエンジニアをしているimamuraです。freeeカード Unlimited の開発の裏側について紹介する連載の第2回目になります。freeeカード Unlimited がどのようなサービスなのかは第1回目の記事*1で紹介していますので、そちらをご覧ください。また、そこで紹介したように freeeカード Unlimited ではマイクロサービスアーキテクチャを採用しており、バックエンドのサービス間は主に非同期で通信しています。

freeeカード Unlimited の構成図
freeeカード Unlimited の構成図

この記事では、サービス間の非同期通信をどのような観点で設計したのかを共有できればと思います。要約するとfreeeカード Unlimited では以下の対応を行いました。

  • メッセージが必ず送信できるようにOutbox Patternを利用して永続化する
  • メッセージの受信の失敗のために再送可能なメッセージにする
  • メッセージの重複を防ぐために受信元で重複排除用のテーブルに保存する
  • システム全体で処理順序が保たれないので、順序に依存しないよう設計する
  • メッセージのフォーマットを統一するためにProtocol Buffersを利用する

非同期通信の概要

非同期通信はプロデューサー(あるいはパブリッシャー、センダー)がメッセージを生成・送信し、コンシューマー(あるいはサブスクライバー、レシーバー)が受信します。そして、双方の仲介となりメッセージを配信するインフラストラクチャをここではメッセージブローカーと呼びます。メッセージブローカーはキューを提供しており、メッセージをバッファリングすることができます。また、複数のコンシューマーがメッセージブローカーを購読することで一対多の通信も実現できます。このため、プロデューサーはコンシューマーが応答可能でなくてもメッセージを送信することができ、どのサービスに送るべきかも関知せずに済みます。つまり、送信元は送信先に関する前提条件を減らすことができるのでサービス間を疎結合にすることができます。

非同期通信の概要
非同期通信の概要

しかし、同期的なサービス間の通信と違って、介在するコンポーネントが増えるので運用の複雑度が高くなります。またメッセージブローカーが起点となり、単一障害点になる危険性もあるので、可用性の高いものを利用する必要があります。freeeカード Unlimited ではメッセージブローカーとして、Amazon SNSとAmazon SQSを組み合わせています。他の候補としてはAmazon KinesisやAmazon MKS(Apache Kafka)がありましたが、当面の考えられる取引量と必要十分な機能を鑑みて選定しました。しかし、この記事では特定のメッセージブローカーではなく、非同期通信に関する一般的な課題にフォーカスしてお伝えできれば思います。

通信時の失敗を想定する

同期的な通信と違って、メッセージの送信側(プロデューサー)側と受信側(コンシューマー)の処理のタイミングは異なるので、それぞれの失敗のケースを想定する必要があります。

送信時の失敗

まず、プロデューサーがメッセージブローカーへの送信に失敗する場合を考えます。通常、送信するメッセージはアプリケーションでのデータの作成や変更を伴った結果、発生します。例えば、freeeカード Unlimited で決済が行われると画面に取引の内容が表示されます。その場合、決済サービスで残高を変更して、ユーザー向けのカード管理サービスに取引内容を含むメッセージを送信します。

しかし、以下の図のようにコミットしてからメッセージを送信し失敗した場合、メッセージを伴う操作はコミットされているにも関わらずメッセージは失われます。

メッセージの喪失
メッセージの喪失

Outbox Pattern

送信時のメッセージの喪失への対策としてOutbox Patternがあります。これは、メッセージを伴う処理とメッセージの永続化を同一トランザクションで行い、別のプロセスがプロデューサーとしてメッセージを送信する方法です。メッセージはアプリケーションと同じデータストアで管理され、メッセージを伴う処理とメッセージの永続化の原子性が保証されます。メッセージが保存されるテーブルをここではOutbox(送信トレイ)テーブルと呼びます。この送信用の別プロセスは、Outboxテーブルから未送信のメッセージを読み取り、送信に失敗してもリトライします。そして、送信に成功した場合は送信済みステータスにします。処理後にレコードを削除しないことで、受信側で再送が必要になった場合もステータスを戻すだけで済みます。

Outbox Pattern
Outbox Pattern

送信側で保存されたメッセージを読み取って送信するには、アプリケーション側でポーリングするか、トランザクションログを読み取るCDC(Change Data Capture)を利用するなどの方法があります。CDCはOutboxテーブルにコミットした際にトランザクションログを検知し、Outboxのメッセージを取得しメッセージブローカーに送信します。Outboxテーブルをポーリングする方法は無駄なクエリが発行される一方で、CDCは設定用の実装が必要になる場合があります。サービスのリリース時はシンプルな設計から始めたかったので現在はポーリングする方法で送信しています。

受信時の失敗

次にコンシューマー側が、メッセージブローカーからの受信に失敗する場合を考えます。以下の場合が考えられます。

  1. そもそもメッセージが受け取れない
  2. 受け取ったメッセージを処理するとエラーになる

1は、クラウドサービスの障害やコンシューマーの負荷などで、受信とその処理ができない場合です。しばらくの間、メッセージブローカーでメッセージがバッファリングされているので、リトライで対応できます。例えば、Amazon SNSはメッセージの保持期間は最大14日*2になります。しかし、2はメッセージのフォーマットが不正だったりアプリケーション側の根本的なバグの場合です。これはリトライするだけでは解決できません。そのため、修正後にメッセージを再送する必要があります。

そして、freeeカード Unlimited ではプロデューサー側で送信メッセージをOutboxテーブルで永続化しているので、そのメッセージを未送信のステータスに戻すことで再送することができます。つまり、送信元のサービスとOutboxテーブルのIDが分かればメッセージを特定し再送することができます。そのため、Amazon SNSのメッセージのメタデータ*3に以下のようなサービス名とOutboxテーブルのIDを必ず付与するようにしています。そして、処理に失敗しリトライ上限を超えた時にその内容を通知し、処理に失敗したメッセージの保存用のキュー(Amazon SQSのデッドレターキュー*4)に移します。この再送の仕組みは自動化できる余地があるものの、実際にサービスを運用しながら発生頻度や原因に応じて対応していくつもりです。

メッセージの再送
メッセージの再送

通信するメッセージを設計する

重複排除

仮にメッセージブローカーがメッセージごとにIDを割り当て重複排除をサポートしていたとしても、必ずしも一回だけメッセージが送られること(exactly-once)を保証できるとは限りません。受信後にコンシューマーやネットワーク、メッセージブローカーの障害で、受信確認が受け取られずに再度メッセージが取得できてしまう場合もあります。例えば、Amazon SQSはメッセージを受信しても自動的にキューから削除しません。処理が終わり次第、削除のAPIをコンシューマーが呼び出す必要があります。しかし、それに失敗すると他のコンシューマーが再度取得してしまいます。他のメッセージ配信サービスでも、ほとんどがexactly once(必ず1回の配信)ではなく、at least once(少なくとも1回の配信)を保証しています。

また、コンシューマーの失敗時に再送する場合のように、同じメッセージが複数回配信されることを許容する必要があります。そのため、コンシューマー側では以下のいずれかの対応が必要になります。

  1. 冪等な処理にする
  2. メッセージの重複を確認し、2回目以降の処理を行わない

1の冪等とは、1回目の実行結果と2回目以降の実行の結果が変わらない性質のことです。例えば、利用停止中のカードの停止は冪等な操作になります。しかし、冪等にできない操作もあります。例えば、カード利用時にユーザーへ通知する操作などは、複数回通知されることになります。したがって、そのようなメッセージハンドラは重複したメッセージを検出し破棄する必要があります。2のメッセージの重複確認はコンシューマー側で処理済みのメッセージを永続化しておき実行時に確認するなどの手段があります。

freeeカード Unlimited では、必ず同一のトランザクションでメッセージを処理し受信したメッセージを重複排除用のテーブルに保存しています。このテーブルには、再送の場合と同じように送信元のサービス名とOutboxテーブルのIDをユニーク制約としています。これによって、同じメッセージを処理してコミットするとユニーク制約によってロールバックされるようになります。もちろん、外部のサービスへのネットワーク通信などロールバックできない処理には注意しなければなりません。

メッセージの重複排除
メッセージの重複排除

順序に依存しない

メッセージとそのメッセージハンドラを設計する際に、メッセージの処理の順序に依存しないようにする必要があります。確かに、メッセージブローカーが提供するキューによってはFIFO(先入れ先出し)のキューを提供しており、メッセージの受信の順番が送信の順番と一致するように保証されます。しかし、メッセージキューの順序が保証されたとしても、システム全体で保証されるわけではありません。

例えば、それぞれのメッセージが複数のスレッドから送信される場合は、順序を保つために互いに実行順序を制御しなければなりません。また、受信側も同様にメッセージを受け取ってからDBにコミットする順番を制御する必要が生じます。

先に送られたメッセージが後にコミットされる
先に送られたメッセージが後にコミットされる

順序を意識する必要がある場合は、メッセージ自体に順序を示す情報(タイムスタンプやシーケンス番号など)を持たせ、コンシューマー側で調整することも可能です。freeeカード Unlimited でもマイクロサービスの境界を決める際に、全てのメッセージを洗い出して順序に依存しない非同期通信が可能かを確認しながら設計しました。

フォーマットの統一

同期・非同期に関わらずサービス間で共通のインターフェースを使って通信する必要があります。同期通信の場合と同様に非同期通信でも、Protocol Buffers(protobuf)を使ってメッセージのフォーマットを共通化しています。前回の記事*5で紹介したようにfreeeカード Unlimitedではモノレポを採用しており、protobufを同じリポジトリ内で管理しています。

例えばサービスAでカードが利用され、その明細をユーザーが確認できるように以下のようなメッセージでサービスBに連携します。

message MessageA {
  int64 company_id = 1;
  google.protobuf.Timestamp date = 2;
  string merchant_name = 3;
  uint32 amount = 4;
}

サービスAで以下のようにprotobufが生成した構造体をJSONにして送信します。

import (
    ...
    "google.golang.org/protobuf/encoding/protojson"
)

m := &MessageA{...} // protobufから生成されたメッセージの構造体
data, err := protojson.Marshal(m) // proto.MessageをJSON形式の[]byteに変換

そして、サービスBで以下のようにメッセージをJSONからデシリアライズします。

import (
    ...
    "google.golang.org/protobuf/encoding/protojson"
)


m := &MessageA{}
err := protojson.Unmarshal(data, m) // []byteのメッセージを指定のproto.Messageに変換

このようにサービス間で通信する場合は、メッセージのフォーマットの共有方法やシリアライズ・デシリアライズのプロトコルを決める必要があります。

まとめ

非同期通信はマイクロサービス間を疎結合にする有効な手段です。一方で、関係するコンポーネントが増え通信の失敗の箇所が増えます。それらを想定して対策したりリカバリー可能にする必要があります。freeeカード Unlimited の開発もここでは全て言及できませんでしたが、並行処理でどのようにコンシューマーを実装するか、どのようなアラートの設定にすべきかなど試行錯誤する機会はたくさんありました。そして、リリース後の通信量や要件の変更に応じて改善をしていかなければなりません。しかし、アーキテクチャを考える上でサービス間の通信は避けて通れない論点なので非常にチャレンジングなプロジェクトだと思っています。

次の連載記事は、freeeカード Unlimited の開発開始時に金融チームに異動したtabachainさんの「EMから再度エンジニアに戻り新規プロダクト開発に挑戦して学んだこと」になります!


金融チームでは、一緒に「freeeカード Unlimited」を開発する仲間を募集しています。 ベンチャー企業であるfreeeの中でも更にスタートアップ色が強い金融チームで、スモールビジネスの資金繰りにイノベーションを起こしましょう! https://freeecommunity.force.com/jobs/s/detail/a4l2r000000CaUpAAK