🍲

ActiveRecordのメモリ集計をやめてSQL一発にしたら、最大29秒かかっていた集計処理が0.3秒まで早くなった話

に公開

はじめに

Bitfanのオーナー向けダッシュボードの機能で『オーナー向け解約アンケート集計機能』というのがあります。今回、こちらの集計機能のパフォーマンス改善した事例を紹介します。
集計機能自体はAPIで用意しており、Railsで作られたものになります。

課題:解約アンケートの集計結果に時間がかかる場合がある

ある日、New RelicのTransaction Traceを確認していたところ、この集計APIのエンドポイントが、非常に遅くなっていることに気づきました。

また、直近のリクエストみると時間がかかっている場合とそうでない場合がありました。

この時点で解約アンケートのデータ量が多いファンクラブほど処理時間がかかる課題が発覚しました。

Rubyメモリ上でのループ処理とN+1

原因を探るため、コードを確認しました。 データ構造は以下のようになっています。

※ 解約アンケート回答時に、選択肢は複数選択可能な仕様です

value_dic = exit_surveys.map { |survey| 
  # ここでループごとにクエリ発行 & オブジェクト生成が発生
  survey.exit_survey_answers.where("fixed_at >= ? AND fixed_at <= ?", query_start, query_end.end_of_day).map { |answer| 
    answer.exit_survey_answer_choices.map { |choice| choice.exit_survey_choice_id } 
  }
}.flatten
.group_by { |v| v }
.map { |key,values| [key, values]  }
.to_h

このコードには2つの大きな問題がありました。

  1. 多重のN+1問題: exit_surveys のループ内でクエリが走るだけでなく、それぞれの回答(ExitSurveyAnswer)から選択肢(ExitSurveyChoice)を取得する際にも都度クエリが発行されていました。 これにより、回答数に比例して発行されるSQLが爆発的に増えていました。
  2. 大量のオブジェクト生成: 全ての回答データ(ExitSurveyAnswer)と選択肢データ(ExitSurveyChoice)を一度ActiveRecordオブジェクトとしてメモリ上に展開し、Rubyのmapgroup_byで集計していました。

これにより、回答数が増えれば増えるほど、DBアクセス回数とメモリ消費量が膨れ上がり、処理速度が低下していました。

解決策:SQLで集約して一発で取得する

ActiveRecordオブジェクト経由頑張って処理しなくても、RailsのActiveRecordにはjoins, group, countといったメソッドがあり、これらを利用してデータベースから集計した結果にリファクタリングしました。

exit_survey_ids = exit_surveys.map(&:id)

# 選択肢(ExitSurveyChoice)テーブルを起点に、回答(ExitSurveyAnswer)テーブルをJOINして一括集計
value_counts = ExitSurveyAnswerChoice
  .joins(:exit_survey_answer)
  .where(exit_survey_answers: {
    exit_survey_id: exit_survey_ids,
    fixed_at: query_start..query_end.end_of_day
  })
  .group(:exit_survey_choice_id) # 選択肢IDごとにグルーピング
  .count # 件数をカウント

この変更によって以下改善が行われました。

  • 発行されるSQLは1回
  • 大量のActiveRecordオブジェクトを生成せず、結果(IDとカウント数のHash)だけ取得

約100倍の高速化

リリース後のNew Relicを確認した結果です。

リリース後に同じファンクラブの解約アンケートの集計結果を見ていますが、改善前まは最大29秒程でしたが、0.3秒と約100倍の高速化に成功しました。

まとめ

今回の改善では、ActiveRecordの便利なメソッドチェーンを多用してRuby側で集計していたものをSQLで集計するように修正しました。

  • Rubyの map や each の中で関連データを取得していないか?
  • ただ数を数えるためだけに、大量のオブジェクトを new していないか?

これらを意識するだけで、Railsアプリケーションのパフォーマンスは劇的に改善することを再確認できました。今後もボトルネックを見つけ次第、適切なアプローチで改善を続けていきたいと思います。

SKIYAKI Tech Blog

Discussion