はじめに
先日スターフェスティバル様と合同勉強会を行ってきました。
その際に登壇した内容を記事として公開いたします。
資料について
Debeziumを活用したRDBMSイベントソーシングの仕組み-結果生合成を担保するマイクロサービスの設計パターンのお話- Speaker Deck
問題解決ドメイン
NetprotectionsではNP後払いをはじめとする複数の後払い決済のプロダクトを提供しております。
各後払い決済プロダクトは複数の業務ドメインにより構成されております。
その中でも決済を開始する前に、加盟店様と契約を行うまでのプロセス全般を担う取引先ドメインのお話を行います。
アーキテクチャ
まずはじめに、一般的にアーキテクチャは下記の3種類の構成に分けることができます。
1. モノリス
1つのモジュールにたくさんのロジックが記述され1つのデータベースに依存する一枚岩でつながっているアーキテクチャパターン。
2. モジュラモノリス
適切な粒度でモジュールに分割され、複数のモジュールが一つのデータベースに依存するパターン。
3. マイクロサービス
モジュールごとに独立したデータベースを保持し、デプロイラインも完全に分離され疎結合につながったパターン。
選定したアーキテクチャパターン
今回取引先チームでは、小さなチームでプロダクト全体を保守しやすいモジュラモノリスを採用しました。
※一部可用性を向上させるためにマイクロサービスに分離してるものも存在しますが本題の蛇足になるため割愛を行います。
主要ドメインのアーキテクチャ構成
取引先ドメインを申請サービス、審査サービス、契約サービスと三つの関心領域に分離しました。
システム保守がしやすいように1サービスの中で複数モジュールに分割し、サービスごとにデータスキーマを保持する構成になっております。
スキーマをサービスごとに分けることにより、どのサービスがどのテーブルのオーナーシップを握っているのかの境界線を引くことができ、データ設計における凝集度の向上を行うことが可能になりました。
ソースコードもエンジニアの認知負荷を上げず生産性を向上させるために、申請・審査・契約とそれぞれの単位でモノリポで管理し、モジュール単位で独立してデプロイできるようにしております。
Sagaパターン
サービス全体の主要な処理は基本的には非同期処理をベースに構成しております。
非同期処理を実現する上でSagaパターンと呼ばれるマイクロサービスアーキテクチャの設計パターンを導入しました。
長時間のテーブルロックを防ぎ、トランザクションを最小単位に分離し処理が完了すれば、次のサービス処理に数珠繋ぎで繋げていく設計パターンです。
Sagaパターンの種類
オーケストレーションパターン
オーケストレーションパターンは中央集権的なオーケストレータが設置され、どのサービスに連携するのかの判断を行われ処理が各種サービスに割り振られていきいます。
コレオグラフィパターン
それに対しコレオグラフィと呼ばれるパターンは、中央集権的な旗振りを行うオーケストレータが存在せず各種サービスは処理が終わった後にどのサービスに連携するのかを知っており、処理が数珠繋ぎになっていくことで自律的に処理が実行されていく構成です。
今回導入したSagaパターン
今回のアーキテクチャ選定において後述で説明するTransactional Outboxパターンと親和性の高いオーケストレーション型のSagaパターンを導入しました。
イベントソーシング/CQRS
イベントソーシング/CQRSと呼ばれるデータ書き込み/読み込みのスケーラビリティを保つ設計パターンを導入しております。
イベントソーシング
イベントソーシングとは唯一信頼できるデータはドメインイベントという考え方をベースにしてます。
業務データは様々なシステムからミュータブルにデータ変更が行われる可能性があるため、どの時点でどういったデータを保持していたのかの変更履歴を追うことが困難になります。
ドメインイベントがログデータとして永続的なデータストアで管理され、業務データを書き換えた事実データとして唯一信頼できるイミュータブルなデータとして扱います。
CQRS(Command Query Responsibility Segregation)
CQRSと呼ばれる書き込み(コマンド)と読み込み(クエリ)の責務を完全に分離する設計パターンも取り入れてます。
一般的に読み込みと書き込みは同一データベースに対して行うことが多いと思いますが、読み込みのクエリが重たい場合書き込み完了までに書き込み対象テーブルのロックが長時間かかってしまうこともあります。
サービスがスケール度にクエリ処理がどんどん重たくなり、DBパフォーマンスが処理全体のスループット限界になってしまうリスクにもつながります。
通常であれば業務データの書き込みは業務データベースに対して直接行いますが、書き込み内容をドメインイベントの事実ログとしてイベントストアに書き込みを行います。
イベントストアに書き込まれた内容は業務データ更新プロセスによって、非同期で業務データベースに保存されます。
こうすることで業務データの参照を行う計算処理の際に利用するデータベースと、その結果を書き込む先のデータベースを完全に分離することが可能になり、複雑なクエリを処理する業務データベースはRDSリードレプリカを利用した水平スケーリングを行うことが容易になります。
分散トランザクション VS Transactional Outbox
分散トランザクションの落とし穴
前述したイベントソーシングを実装するにあたり、Transactional Outboxパターンと呼ばれる設計パターンを導入しました。
通常の非同期処理はメッセージブローカー(イベントストア)に直接書き込みを行うことになるのですが、
業務データベースとイベントストアの両方に書き込むと分散トランザクションになり2フェーズコミットが発生します。
イベントストアへの書き込みトランザクションが成功した後に、業務DBのトランザクションが失敗すると
本来その処理で業務DBに書き込みが行われるはずが書き込みが行われず、次の工程の処理に進んでしまうことになります。
Transactional Outbox
Transactional Outboxとは、業務データベースと同じデータベースにOutboxテーブルと呼ばれるテーブルを作成し、業務データと同じアトミックなコミットでイベントを書き込む方式です。
これによりRDBMSのACID特性を活かし、処理の整合性を担保しながら次の工程の処理に進めることが可能になります。
Debeziumを利用したTransactional Outboxパターンの実装
Debeziumを利用することで、outboxテーブルに記述された内容をメッセージブローカーのKafkaに自動連携を行うことが可能になります。
DebeziumはRDBMS(Postgres)⇄Kafka間のKafka Connectとして稼働することができ、PostgreSQLのChangeLog(=WAL)を監視し、監視したデータをもとにKafkaに連携を行います。
これによってイベントの依頼者(=Producer)はイベント書き込み時にメッセージブローカーの存在を一切気にすることがなく、業務データと一緒にイベントデータを同一トランザクション内で書き込むことが可能になります。
Outboxパターンを利用したSagaの実装
Outboxパターンを導入することで、Sagaのオーケストレータパターンの実装を組むこと容易になります。
イベント依頼履歴テーブルに刻まれたイベント内容をDebeziumがWALとして吸い上げ、その情報をKafkaのタイムライン用トピックに連携を行います。
タイムライントピックに格納されたイベントログデータの中にどの具象トピックに配信するかが記述されているため、その情報をもとにオーケストレータの役割を行う振り分けConsumerが具象トピックに振り分け処理を行います。
なので本Sagaパターンはコレオグラフィ要素も少し含んでおり、各種サービス処理が次にどのサービスに連携するのかを知識として持っておりそれをオーケストレータに通知して振り分けてもらうようなハイブリッド型の構成とも言えます。
タイムラインスキーマの存在
RDBMS(PostgreSQL)の各種業務スキーマから分離させ、タイムラインスキーマと呼ばれるイベント内容を統合的に管理するスキーマを用意しました。
イベント依頼履歴テーブルはOutboxテーブルとしてイベントソーシングが行われるテーブルとして機能させ、
イベント消費履歴テーブルは依頼内容に対し、Consumerによって消費されたレコードがイミュータブルに挿入されていきます。
イベント依頼履歴とイベント消費履歴テーブルについて
イベント消費履歴が刻まれることで、Idempotent Consumerパターンと呼ばれるConsumer処理の冪等性を担保する設計が可能になり、Consumerの重複処理により不正なデータが生成することを防ぐことも可能になります。
一般的にマイクロサービスアーキテクチャは処理の独立性が担保されたサービスにより処理が実行されていくため、処理全体の統制を行うことができず愚直にログを追わないといけなくなるためデバッグが困難になります。
一方でOutboxパターンを導入し、イベント依頼と消費を統合的に管理することで
誰が(Who)いつ(When)何を(What)どこに(Where)依頼し(Produce)、
誰が(Who)いつ(When)何を(What)どこから(Where)消費(Consume)したのかがサービス全体で追従できる設計にすることが可能となりました。
AWSアーキテクチャについて
最終的なAWSアーキテクチャは下記のような構成がベースとなりました。
全体的に垂直スケーリングではなく水平スケーリングが可能になるような設計にしております。
- アプリケーションレイヤー
- Kafka ConsumerをECS(Fargate)を稼働させることで処理の水平スケーリングが可能
- データレイヤー
- データベースはRDSで稼働させ、読み込み処理はリードレプリカを利用することで水平スケーリングが可能
MSK
メッセージブローカーのApache Kafkaのマネージドサービスとして利用し
またDebeziumを稼働させるKafka ConnectのマネージドサービスとしてMSK Connectを利用しました。
ECS(Fargate)
Kafka Consumerを起動するサービスとしてECS(Fargate)を利用しました。
RDS
データベースはノーマルRDS(×Aurora)を利用しております。
障害発生時の対応について
リカバリ手順
- 障害要因を特定&対応
- Consumerを停止
- ConsumerのOffSetをKafka上で戻す
- Consumerを再開
運用をしていると想定外のエラーが発生することがあります。
その際には上記手順で実行しリカバリを行います。
KafkaはConsumerがメッセージを取得した通知を受け取ると、Consumerが正常に処理ができなのか否かに関わらず、トピックのOffSetをインクリメントして次のメッセージを受信することが可能になるため、Consumer側で処理が失敗したとしてもどんどん次のメッセージ処理が進んでいくことにいなります。
そのため冪等性を担保するIdempotent Consumerパターンを組んでないと、リカバリの際に正常に処理されたメッセージを再受信し重複して実行されることになるため、冪等性が担保されてる前提のリカバリ手順になります。
本日のまとめ
今回の勉強会では大きく三つの設計パターンを導入しアーキテクチャを構築したお話を行いました。
全体のアーキテクチャ設計において処理の整合性担保と水平スケーリングによるスケーラビリティ性を意識した設計パターンの導入を行いました。
Sagaオーケストレータパターン
結果生合成を担保するために分散トランザクションを避けた設計パターンを採用。
オーケストレータ層を作ることで、OutBoxテーブルの増加を抑えることが可能に。
イベントソーシング/CQRSパターン
イベントの書き込みは永続データストアに格納することでイベントが揮発せず安定した非同期処理を実現。
参照処理と書き込み処理の責務を完全に分離することで柔軟なパフォーマンス性の高いシステムに。
Transactional Outboxパターン
イベントの書き込みはDBのローカルトランザクションを利用。
他の業務テーブルと単一コミットでイベント書き込みが可能になり脱2フェーズを実現。
最後に
決済サービスの取引先ドメインのアーキテクチャのお話を行いました。
取引先ドメイン外に公開する機能はマイクロサービス化してRDBMSが単一障害点にならないように対外影響を抑えた工夫や、勉強会の中では登場してないモダンな技術の導入も行っているのですが、今回の説明の蛇足になるため割愛しております。
また別のテーマでお話を行おうかと思います。