Rails のモデルのコールバックを削除したら、とある処理を 33 倍速くできた話

こんにちは!
マネーフォワード クラウド経費 というサービスでサーバサイドエンジニアをやっている 福岡拠点 (九州・沖縄支社) の野田 (@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

ビジネス向けバックオフィス向け業務効率化ソリューション 『マネーフォワード クラウド』

おつり貯金アプリ 『しらたま』

お金の悩みを無料で相談 『マネーフォワード お金の相談』

だれでも貯まって増える お金の体質改善サービス 『マネーフォワード おかねせんせい』

金融商品の比較・申し込みサイト 『Money Forward Mall』

くらしの経済メディア 『MONEY PLUS』

Pocket