MyReferチームのバックエンドエンジニアの中山です。 本記事では弊社が運営するMyシリーズで導入したCQRSアーキテクチャについてご紹介します。
サービスについて
弊社では、リファラル採用を促進する「MyRefer」、潜在的な求職者のタレントプールを構築しアプローチを可能にする「MyTalent」、採用ブランディングを支援する「MyBrand」など、複数のサービスを運営しています。
これらのサービスに共通する機能(認証、応募など)は「Myシリーズ管理」という共通のサービスに集約されており、各サービスからAPIを通じて連携しています。
なお、各サービスはデータベース(DB)レベルで分離されており、サービス間のデータ参照は各サービスが提供するAPI経由で行われます。
データ参照の課題
このようにサービスが分離されている構成でデータを参照する場合、以下のような課題が発生します。
- SQLのJOINのようなテーブル結合ができず、各サービスから必要なデータを個別に取得しメモリ上で突き合わせる必要があるため、APIが複雑化する
- 複数のサービスをまたいでデータを取得するため、計算量やネットワークの使用量が増加し、レイテンシーや実行コストが高くなる
タイムラインAPIの例
例えば、MyReferのUI上で「応募」「イベント」「求人の公開」などのコンテンツを時系列順に一覧表示するタイムラインAPIを考えてみます。 このタイムラインに含まれるデータは、
- 「応募」「イベント」:Myシリーズ管理サービスで管理
- その他の情報:MyReferで管理
というように、それぞれ異なるサービスで管理されています。
このような構成の場合、タイムラインAPIでは以下の処理が必要になります。
- Myシリーズ管理から、登録時刻でソートされた応募データをAPI経由で取得
- 同様に、実施時刻でソートされたイベントデータをAPI経由で取得
- MyReferから、その他の必要なデータを時系列順に取得
- 上記すべてのデータをメモリ上でマージし、ソートしてレスポンスを生成
サービスが分離されていなければ、SQLのJOINやUNIONなどでデータを一括で取得することが可能ですが、今回の場合はこれらの方法が使えず、データの所有権を持つサービスからAPIを使ってデータを収集する必要があります。
さらに、「時系列順に並べる」という要件があるため、件数を絞って取得することが難しく、Myシリーズ管理から取得するデータは全件取得が前提となります。そのため、データ量が多い場合には、ネットワーク負荷やレスポンスの遅延が問題になることもあります。
また、検索条件の追加やタイムラインに表示するコンテンツの種類が増えるほど、APIの設計・実装はより複雑になり、UI変更のたびに開発工数がかかる点も課題です。
CQRSの導入
この課題を解決するために、CQRS(Command Query Responsibility Segregation)パターンを導入しました。
CQRSは、データの書き込み(コマンド)と読み取り(クエリ)を異なるモデル・ストレージに分けるアーキテクチャパターンです。コマンドとはCreate / Update / Deleteといった「システムの状態を変更する操作」を表し、クエリは「システムの状態を読み取る操作」を表します。コマンド操作によりデータが更新されたらイベントを発行し、Read専用のモデルに反映させます。
それではこのようにデータモデルを分離するとどのようなメリットがあるのでしょうか。
CQRSのメリット
クエリの最適化
クエリ専用のモデルを作成することで、各サービスにデータを問い合わせる必要がなくなり、APIをシンプルに実装できます。先程のタイムラインAPIの例では、Myシリーズ管理に登録されていた応募やイベントのデータが、MyReferのデータベースにRead専用テーブルとして登録されます。これにより、Myシリーズ管理に問い合わせを行うことなくSQLでJOINやUNIONが可能になるため、メモリ上で複雑な操作をすることなく全てのデータを取得することができます。
モデルの最適化
UI上で複雑な検索やソート機能が追加されると、パフォーマンス要件を満たすために、本来は不要なカラムの追加やテーブルの非正規化が行われることがあります。
たとえば、応募の一覧情報を表示する画面では、「応募データ」だけでなく「応募した求人を募集している部署」も表示したいという要件がある場合、本来であればリレーションを辿って取得すべき情報ですが、厳しいパフォーマンス要件に応えるために、応募テーブルに部署IDや部署名を直接持たせるような設計が選ばれることがあります。
このような対応により、多段階のテーブル結合を回避でき、検索・表示の高速化が実現できますが、その一方で、本来ドメイン上は不要だった情報がデータモデルに混在することになります。その結果、要件が増えるたびにモデルが肥大化、複雑化していく可能性があります。
また、弊社では多くのサービスでDDD(ドメイン駆動設計)を採用していますが、UI要件に基づく複雑な検索ロジックがリポジトリ層に実装されることで、ドメイン知識とは直接関係のない処理が混在してしまう問題も生じます。 これにより、リポジトリの責務が曖昧になり、集約をまたいだ参照などが発生しやすくなります。
CQRSを採用することで、書き込み用モデルはドメインに忠実に保ちつつ、読み取り専用モデルはクエリに最適化された形で設計することができます。
ストレージの最適化
読み取りと書き込みで異なるストレージを選択できるのもCQRSの利点です。
たとえば、書き込み側はスキーマレスで柔軟なNoSQLを選択し、読み取り側は複雑なクエリ処理に強いRDBを使うといった構成も可能です。ユースケースに応じて、最適なストレージを選択できる自由度があり、スケーラビリティも向上します。
ただし、上記のようなメリットだけでなく、以下のようなデメリットもあります。
CQRSのデメリット
アーキテクチャの複雑化
書き込み用と読み取り用のデータモデルを分離することで、両者を同期させる仕組みが必要になります。
特に、書き込み後に発行されるイベントの順序保証や重複排除、同期失敗時のリカバリなど、イベント処理に関する設計と運用が複雑になります。
結果整合性の許容
読み取りモデルはイベント経由で更新されるため、リアルタイムではなく結果整合性を前提とした設計になります。
そのため、最新のデータが即時に反映されないことを許容できるユースケースでの採用が前提となります。
以上のようなメリット・デメリットを踏まえ、以下の理由から応募機能などの特定の機能にCQRSを導入しました。
- 複数サービスにまたがるデータの検索・ソートが今後も頻繁に発生する見込みがある
- 大規模データセットでも耐えうる構成が必要
- APIの実装をシンプルに保ちたい
- 結果整合性を許容できるユースケースである
アーキテクチャの実装
CQRSを導入するにあたり、以下の3つを意識して実装を進めました。
- イベントの順序保証と重複排除が可能であること
- アーキテクチャがシンプルであること
- 同期に失敗してもリトライ可能な仕組みがあること
CQRSとセットで語られることの多いイベントソーシングは、今回の実装では採用せず、CQRSの実現に必要な最小限の構成に留めました。 イベントソーシングの詳細については以下のマイクロソフト社のドキュメントが参考になります。
実装したアーキテクチャ
弊社のインフラはAWS上に構築されているため、SNSのFIFOトピックとSQSのFIFOキューを組み合わせたシンプルなイベント駆動のアーキテクチャとしました。
書き込み側(Myシリーズ管理)
Write API がRDBを更新した後、DDDにおけるドメインイベント(例:応募作成イベント)を発行し、SNS FIFOトピックへメッセージを送信します。
SNSのFIFOトピックでは同一グループ内でのメッセージの順序保証と重複排除をサポートしているため、アプリケーション側をよりシンプルに実装することができます。 また、ファンアウト構成とすることで複数のサービスが同じイベントをサブスクライブ可能です。
読み取り側(MyRefer)
SNSトピックから送信されたメッセージは、SQS FIFOキューに配送されます。キューには Lambda関数がトリガーとして設定されており、メッセージを受信するとMyRefer側のRDB(Readモデル)を更新します。
SQSを挟むことで、Lambda処理が失敗した場合でも自動リトライが可能となります。 また、Lambdaの処理が一定回数失敗すると、メッセージはDead Letter Queue(DLQ)に退避されます。CloudWatch Logsのサブスクリプションフィルターにより、DLQへの格納イベントを検知し、Slackに自動通知されるように構成しています。
これにより、エラー発生時はすぐに手動で再実行できるようになっており、運用負荷を最小限に抑えつつ安定した同期処理を実現しています。
他にもDynamoDB Streamを使用して、CDC(Change Data Capture)としてイベントを発行する方法があります。しかし、イベント発行側のデータストアにAuroraを使用しているためDynamoDBベースの構成とは親和性が低く、イベントソーシングを前提としたアーキテクチャではないため、今回は採用を見送りました。 詳しくは以下をご参照ください。 aws.amazon.com
運用してみて
実際にCQRSアーキテクチャを1年間運用してみて、同期の不整合が発生したことはなく、効率的なクエリを実現しています。ただし、同期が必要な分アーキテクチャが複雑になり、アーキテクチャの実装コストが増加した実感はあります。そのため、まずは従来のような各サービスにデータを問い合わせる方式で十分かを検討し、実現が難しい場合はCQRSパターンを採用するのが良いと感じました。 また、今回はイベントストアを設けなかったため、状態遷移が複雑な場合やイベントの履歴が重要となるドメインでは、イベントソーシングの導入を今後検討していきたいと思います。
まとめ
本記事では、MyシリーズにおけるCQRSアーキテクチャの導入背景と実装についてご紹介しました。 サービス間でデータを連携する構成では、複雑なAPI設計やパフォーマンスの課題が発生しやすく、UI要件に応じてデータ取得ロジックが肥大化する傾向があります。これらの課題を解消するために、読み取りと書き込みを分離するCQRSパターンを採用しました。
CQRSの導入により以下のメリットが得られました。
- 複雑なクエリを効率的に処理可能
- ドメインモデルとUI要件の分離
- ストレージ選定の柔軟性とスケーラビリティ
一方で、イベント駆動の構成によるアーキテクチャの複雑化や結果整合性の許容といったトレードオフも存在するため、ユースケースに応じて採用するかを判断したほうが良いと感じました。
最後に
現在、TalentXでは一緒に働く仲間を募集しております。
talentx.brandmedia.i-myrefer.jp
カジュアル面談も行っておりますので、ぜひご応募ください!