こんにちは! マネーフォワード クラウド経費 というサービスでサーバサイドエンジニアをやっている 福岡拠点 (九州・沖縄支社) の野田 (@quanon_jp) と申します。 最近は 狩猟 に勤しむ日々で、ハンターランクは 333 を超えました 💪
先日、クラウド経費で大規模な事業者 (ユーザ) 様の環境で実用に耐えないほどのパフォーマンスの劣化が発生しました。 そのときに行ったパフォーマンス改善についてお話します。
例
モデル構造と問題点
ユーザ (User) モデルとプロジェクト (Project) モデルがあり、1 人のユーザに対して複数のプロジェクトを割り当てることができます。 その割り当てをプロジェクト割り当て (ProjectAssignment) モデルで表現します。
# app/models/user.rb class User < ApplicationRecord has_many :project_assignments, dependent: :destroy has_many :projects, through: :project_assignments end
# app/models/project.rb class Project < ApplicationRecord has_many :project_assignments, dependent: :destroy has_many :users, through: :project_assignments end
# app/models/project_assignment.rb class ProjectAssignment < ApplicationRecord belongs_to :user belongs_to :project # プロジェクト割り当てを作成したり削除したりする場合に、 # コールバックで他のモデルを更新している。 after_create :update_other_model1 after_destroy :update_other_model2 end
この構造ではユーザのレコード数 x プロジェクトのレコード数だけ、プロジェクト割り当てのレコード数が増大します。いわゆる組み合せ爆発です。 例えば大量のプロジェクトがあり、新規ユーザに対して大量のプロジェクトを割り当てる場合、一度に大量のプロジェクト割り当てレコードを作成することになります。 ここでネックになるのがプロジェクト割り当てモデルのコールバックです。 プロジェクト割り当てを 1 件作成したり削除したりする場合に他のモデルを更新する処理があります。 ここでプロジェクト割り当てを一度に大量に一括作成した場合、このコールバックも膨大な回数呼ばれてしまうことになります。 これがパフォーマンスを大幅に劣化させる原因となっていました。
改善
ユーザのレコード数やプロジェクトのレコード数が大量になりうる場合はこのデータ構造自体を改善すべきでしょう。 しかし現実的な問題として、運用中のアプリケーションでの抜本的な見直しはコストがかかるので、まずはコールバックを改善するアプローチを取ることにしました。
最初にプロジェクト割り当てモデルからコールバックを削除します。
そしてプロジェクト割り当てを一括更新 (一括作成あるいは一括削除) するための専用のサービスクラスを用意します。
コールバックで行っていた他のモデルの更新処理はそのサービスクラス内で一括して行います。
ここで、サービスクラス内ではパフォーマンスを向上させるために可能な限り insert_all
, update_all
, delete_all
など、ActiveRecord オブジェクトを介さずに直接 SQL を発行できるメソッドを利用するように工夫します (ただし、アソシエーションの dependent
オプションの都合で delete_all
が使えない場合は destroy_all
を使います) 。
# app/models/project_assignment.rb class ProjectAssignment < ApplicationRecord belongs_to :user belongs_to :project end
# app/services/update_project_assignments.rb class UpdateProjectAssignments attr_reader :user, :added_project_ids, :removed_project_ids def self.call(user:, added_project_ids: [], removed_project_ids: []) new( user: user, added_project_ids: added_project_ids, removed_project_ids: removed_project_ids ) .send(:call) end def initialize(user:, added_project_ids: [], removed_project_ids: []) @user = user @added_project_ids = added_project_ids @removed_project_ids = removed_project_ids end private def call ApplicationRecord.transaction do create_projects destroy_projects end end def create_projects return if added_project_ids.blank? (added_project_ids - existing_project_ids) .map { |project_id| { user_id: user.id, project_id: project_id, created_at: now, update_at: now } } .then { |attributes_list| ProjectAssignment.insert_all!(attributes_list) } update_other_models1 end def destroy_projects return if remove_projects_ids.blank? user.project_assignments.where(project_id: remove_projects_ids).destroy_all update_other_models2 end def update_other_models1 OtherModel1.where(project_id: added_project_ids).update_all(...., update_at: now) end def update_other_models2 OtherModel2.where(project_id: remove_projects_ids).update_all(...., update_at: now) end def now @now ||= Time.current end def existing_project_ids @existing_project_ids ||= user.project_assignments.pluck(:project_id) end end
プロジェクト割り当てを作成、あるいは削除する場合は、1 件の場合でも複数件の場合でもこのサービスクラスを介するようにします。
# プロジェクト割り当てを一括作成する場合。 UpdateProjectAssignments.call(user: user, added_project_ids: [*1_001..2_000]) # プロジェクト割り当てを一括削除する場合。 UpdateProjectAssignments.call(user: user, removed_project_ids: [*1..1_000]) # プロジェクト割り当てを 1 件だけ作成する場合。 UpdateProjectAssignments.call(user: user, added_project_ids: [1_001]) # プロジェクト割り当てを 1 件だけ削除する場合。 UpdateProjectAssignments.call(user: user, removed_project_ids: [1]
計測
実際のデータの規模を想定して「あるユーザで 5,000 件のプロジェクトを割り当て (プロジェクト割り当ての一括作成)、その後それらの割り当てをすべて解除する (プロジェクト割り当ての一括削除)」という処理の時間を計測します。 改修前後で時間を比較してみました。 計測には Benchmark.realtime を使います。 また、SQL のログ出力の時間を無視するために Rails.logger.silence を使います。
# 改修前 Benchmark.realtime do Rails.logger.silence do # プロジェクト割り当てを一括作成する。 # 改修前のコードではモデルのコールバックを実行する必要があるために insert_all は使えない。 projects_ids.each { |projects_id| user.project_assignments.create!(projects_id: projects_id) } # プロジェクト割り当てを一括削除する。 user.project_assignments.where(project_id: projects_ids).destroy_all end end #=> 43.485299999825656 # 改修後 Benchmark.realtime do Rails.logger.silence do # プロジェクト割り当てを一括作成する。 UpdateProjectAssignments.call(user: user, added_project_ids: project_ids) UpdateProjectAssignments.call(user: user, removed_project_ids: project_ids) end end #=> 1.2879900000989437
約 43.5 秒から約 1.3 秒と改修前の 3% の時間にまで、つまり 33 倍 (1 / (1.3/43.5) ≒ 33.46) の速さにパフォーマンス改善できました 😉
まとめ
モデルのコールバックは便利ですが、他モデルの更新などを行うとパフォーマンスの劣化に繋がります (モデル間の結合度を高める原因にもなります) 。 今回はコールバックを削除して、専用のサービスクラスに処理を集約することでパフォーマンスの大幅な改善ができました。 このようにモデルをまたいだ処理を解きほぐすことで改善できる箇所が他にもありそうなので、さらなる改善に繋げられればと思っています。
本件がみなさまの Rails アプリケーションの改善の一助になればと思います。
マネーフォワードでは、エンジニアを募集しています。 ご応募お待ちしています。
【サイトのご案内】 ■マネーフォワード採用サイト ■Wantedly ■京都開発拠点
【プロダクトのご紹介】 ■お金の見える化サービス 『マネーフォワード ME』 iPhone,iPad Android
■ビジネス向けバックオフィス向け業務効率化ソリューション 『マネーフォワード クラウド』
■だれでも貯まって増える お金の体質改善サービス 『マネーフォワード おかねせんせい』