CQRSパターンを用いて統計データを処理するマイクロサービスを作成したお話
目次
- TL;DR
- なぜ統計サービスを作ったのか
- マイクロサービス開発におけるクエリ実装パターン
- API Composition
- CQRS(Command Query Responsibility Segregation、コマンドとクエリの責任分割)
- 統計サービスの設計・実装
- 結論
- 終わりに
こんにちは!株式会社ハイヤールーの新谷(@s_shintani)です。先日のリリースにて問題形式を横断した相対評価がレポート画面で表示されるようになりました(下画像)。その裏側では統計データを処理する新しいマイクロサービスが作成されています。本記事ではマイクロサービス開発において複数のサービスを横断してクエリを実装するパターンを 2 つ紹介した上で、統計サービスの設計と実装について解説させていただきます。
TL;DR
- CQRS パターンを活用することで既存サービスに変更を加えることなくサービス間を疎結合に保つことができた
- 各サービスに分散していた集計処理を一箇所に集約したことで問題形式を横断した統計データの算出が可能になった
- 上記に伴い各サービスで集計処理が不要となりパフォーマンスが向上した
なぜ統計サービスを作ったのか
統計サービス作成以前は Challenge(アルゴリズム形式)や Quiz(選択形式)といった各形式の試験問題をドメインとして扱うマイクロサービスが、試験問題の情報と一緒にスコア分布や各指標ごとの平均値といった統計データを返していました。
しかし既存の設計では以下のような課題がありました。
問題形式ごとにサービスが分離しているので、形式を横断した統計データを算出することができない
試験問題の情報を取得するたびに統計データを算出するため、レスポンス速度が遅くなってしまう
統計データを取得するクエリにパラメータを追加するといった変更に対する拡張性がない
そこで統計データを扱うマイクロサービスを切り出し、全ての問題形式のデータを一箇所に集約することで、形式を横断した統計データの算出を可能にするとともに、パフォーマンスと拡張性を担保するというのが今回の開発に至った大きなモチベーションです。
マイクロサービス開発におけるクエリ実装パターン
統計サービスは全ての問題形式のデータを集計する必要があり、それらのデータは別々のサービスが持っています。
マイクロサービス開発において今回のケースのように複数のサービスからデータを取得する際には、大きく分けて 2 つのパターンによる実装が考えられます。それがAPI CompositionとCQRS(Command Query Responsibility Segregation)です。
API Composition
API Composition では、API Composer と呼ばれるデータの集約を担うサービスが、返却するデータの一部を所有しているサービス(Provider Service)を複数呼び出し、その結果を結合するという方法でクエリを実装します。
HireRoo では、選考をドメインとして扱う Spot と呼ばれるサービスで API Composition を活用しています。1 つの選考には複数の形式の試験問題が含まれており、各形式の試験問題の情報は Challenge(アルゴリズム形式)、Quiz(選択形式)、Project(技術特化形式)、SystemDesign(システムデザイン形式)といったサービスが持っています。また選考に紐づく会社情報は Company サービス、面接官の情報は Auth サービスに存在します。(それぞれのサービスについて詳しく知りたいという方はこちらの記事をご参照ください。)
Spot サービスに対して選考を取得するクエリを投げると、その選考に紐づく情報を持つ各サービスに対して並行してリクエストを送信し、取得結果を Spot サービスが集約してレスポンスとして返却します。
API Composer には取得先のデータの一意の ID 等が保存されており、実データは保存されていないので、Provider Service 側でデータの更新があった場合でも、データの整合性を失うことなく常に最新のデータを取得することができるという利点があります。
その一方で API Composition には以下のような欠点があります。
- オーバーヘッドが増加する
- 可用性が低下する
- サービス同士が密結合になりやすい
オーバーヘッドの増加
API Composition では複数のサービスを呼び出し、各サービスでデータベースクエリを実行します。そのため必要となる計算やネットワークリソースが増加します。特に統計データの集計といった計算量を多く必要とする処理の場合、オーバーヘッドの増加はより深刻な問題となります。
これに対するソリューションとしては Provider Service からのレスポンスを一定期間キャッシュしておくことが考えられますが、キャッシュのロジックを管理する必要があるため、複雑性が増す可能性があります。
可用性の低下
また、クエリの可用性は関連するサービスが増えるにつれて下がっていきます。例えば Challenge サービスが一時的に利用不能になっているとき、Spot サービスから Challenge サービスへのリクエストが失敗するため、選考情報を取得することができなくなります。
サービス間の密結合化
さらに、データを集約する API Composer がデータを保持しているプロバイダサービスに依存するため、サービス間が密結合になりやすいという問題点があります。
例えば、統計サービスが必要なデータを取得するために、Challenge サービスに問題ごとに統計データを返却する API を新しく追加した場合、統計サービスのビジネスロジックが Challenge サービスに入り込んでしまうことになります。
マイクロサービスアーキテクチャでは各サービスの責務が分離していることが望ましく、このような実装がプロバイダサービス側に必要となる場合 API Composition を使用するべきではありません。
以上の問題点を踏まえ、統計サービスでは CQRS を用いたクエリを実装することにしました。
CQRS(Command Query Responsibility Segregation、コマンドとクエリの責任分割)
CQRS はその名の通り関心事の分離を目的としています。コマンド操作(Create、Update、Delete)を持たず、クエリ操作(Get)だけから構成されるクエリサービスを作成します。クエリサービスは他のサービスからパブリッシュされるドメインイベントをサブスクライブして最新の状態に保たれたデータベースを持ち、クライアントはクエリサービスに対してクエリ操作を実行します。
CQRS には次の利点があります。
サービス間を疎結合に保つことができる
クエリに特化した独自のビューを作成できる
関心事の分離
CQRS ではクエリーを実装するサービスとデータを管理するサービスを分割することができます。新たなロジックを既存のサービスに加える必要がないためデグレが起きることもありません。両者のやり取りは Pub/Sub などのメッセージブローカーを経由して行われるため、サービス同士を疎結合に保つことができます。
クエリに特化したビューの作成
クエリサービス側はコマンドサービス側と異なるデータ構造やデータベースも選択できるため、よりクエリに特化した独自のビューを作成することができ、それによりパフォーマンスを最適化することができます。
例えば Challenge サービスでは提出結果と問題を解くのに使用したヒントは別々のテーブルに保存されていますが、統計サービスでは使ったヒントの数を提出結果のフィールドとして定義しています。
また今回の実装では統計サービスも他のサービスと同様に RDB を使用しましたが、NoSQL 等の他のデータベースを選択することも可能です。
統計サービスの設計・実装
統計サービスでは Spot(選考)、Challenge(アルゴリズム形式)、Quiz(選択形式)、Project(技術特化形式)、SystemDesign(システムデザイン形式)の 5 つのサービスのイベントをサブスクライブしています。
各問題形式のサービスは全ての提出の評価が完了したタイミングで評価イベントをパブリッシュします。統計サービス側では以下のようなイベントハンドラが定義されており、評価イベントを受け取るたびにイベントメッセージとして定義されている提出結果を自身のデータベースに保存します。
func (s *statisticsService) HandleChallengeEvent(ctx context.Context, event *eventPb.Event) (*empty.Empty, errors.ServiceError) {
challengeEvent := challengeeventPb.ChallengeEvent{}
if err := proto.Unmarshal(event.Message.Data, &challengeEvent); err != nil {
return nil, errors.NewFromError(err, errors.InvalidRequest)
}
switch challengeEvent.EventType {
// Challengeの評価が終わったときに発火されるドメインイベントを受け取ってビューを更新する
case challengeeventPb.ChallengeEvent_EVENT_TYPE_CHALLENGE_EVALUATED:
if err := s.handleChallengeEvaluated(ctx, challengeEvent.Details.(*challengeeventPb.ChallengeEvent_ChallengeEvaluated)); err != nil {
return nil, err
}
}
return &emptypb.Empty{}, nil
}
例えばアルゴリズム形式の試験の提出結果は以下のようなデータモデルとして統計サービスのデータベースに保存されます。クエリのパフォーマンスを考慮してフィールドには適宜インデックスが貼られています。
type ChallengeScore struct {
SubmissionID int64 `gorm:"primaryKey"`
ChallengeID int64
QuestionID int64 `gorm:"index:idx_challenge_scores_question"`
TotalScore float32
Coverage float32
Performance float32
Readability float32
NumHints int64
ElapsedTime int64
// ...(省略)
}
そしてアルゴリズムの試験問題を解いた候補者の順位を取得する際には、統計サービスが持っているデータベースから必要な情報を取得して算出します。当然 Challenge サービスにリクエストを投げる必要はありません。
func (s *statisticsService) getChallengeRank(query *pb.ChallengeRankQuery) (*pb.Rank, errors.ServiceError) {
// 比較したいアルゴリズム形式の試験データを取得する
target, err := s.db.GetChallengeScore(nil, query.SubmissionId)
if err != nil {
return nil, err
}
// クエリパラメータに応じて母集団を取得する
subset, err := s.db.GetChallengeScoresByQuestion(nil,
target.QuestionID,
target.QuestionVersion,
query.MinTimestamp.AsTime(),
query.MaxTimestamp.AsTime(),
query.CompanyId,
)
if err != nil {
return nil, err
}
// 該当の試験の母集団内における順位を算出する
rank := int64(1)
for _, compared := range subset {
if compared.TotalScore > target.TotalScore {
rank++
}
}
rankScore := 1 - float32(rank)/float32(len(subset))
return &pb.Rank{
Rank: rank,
NumSubset: int64(len(subset)),
RelativeScore: rankScore,
Evaluation: rankScoreToEvaluation(rankScore),
}, nil
}
さらに統計サービスのデータベースには、各形式ごとの試験結果に加え、各形式の試験を束ねる選考情報も保存されているため、例えばアルゴリズム形式と選択問題形式を出題した選考の結果を他の選考として比較して相対的な評価を算出するといったことも可能です。
結論
統計サービスの実装によって、当初の課題であった問題形式を横断した統計データの算出が可能になり、また既存サービスからも集計処理のロジックが取り除かれてシンプルになりました。
他のサービスは責務が切り離されており、インターフェースを介して会話をしているので依存関係がなく、別チームが独立して開発を進めることも可能です。CQRS を用いてサービス間を疎結合にすることでマイクロサービスアーキテクチャの利点を生かすことができたのではないかと思います。
現状の課題としては、選考データの集計に数秒程度時間がかかってしまっている点が挙げられます。これについてはバックグラウンドで集計結果のビューを作成するジョブを起動させておき、その結果をキャッシュするといったソリューションが有効です。場合によってはボトルネックとなっている SQL クエリや集計ロジックを最適化することも考えられます。
統計サービスではパフォーマンスの改善とともに今後も問題ごとの集計データの表示など新しい機能をどんどん追加していきますのでご期待ください。
終わりに
いかがだったでしょうか?本記事では統計サービスの設計や開発のモチベーションに加え、API Composition や CQRS といったクエリ実装パターンについても紹介させていただきました。それぞれ Pros&Cons があるのでユースケースに応じて最適なパターンを選択することがベストです。本記事が少しでも参考になったのであれば幸いです。最後までお読みいただきありがとうございました!