Help us understand the problem. What is going on with this article?

SaaSでRails使うなら知らないとまずい!!Railsで一括処理するときに遅いコードと対策5選

はじめに

モチベーションクラウド、性能改善チームの江上です。
性能改善チームは、大手の企業様でもモチベーションクラウドを利用できるように、アプリケーションのスピード改善を行うチームです。
このチームができて、約一年。その間に、マイクロサービス化やDBのチューニング、コードの書き直しなど様々なことを行いました。その中でもこの記事では、Railsのコードに関して「最初からやっていれば、苦労しなかったなぁ」と思う、一括処理するときに気をつけたいRailsのコードの書き方を紹介します。特にSaaSだと企業ごとに大量のデータを処理しないといけないケースも多々ある思いますので、少しでも参考になればと思います。

1. 一括処理のvalidationで遅い

原因
ActiveRecordのvalidationが原因で、saveした回数だけクエリが発行され遅くなってしまう

class ModelA < ApplicationRecord
  belongs_to :model_b
  validates :model_b, presence: true
  validates :name, length: { maximum: 25 }
end
model_as.each do |a|
  a.save
end
select * from model_bs where id = xxxx;
update model_as set xxxxx;
select * from model_bs where id = xxxx;
update model_as set xxxxx;
select * from model_bs where id = xxxx;
update model_as set xxxxx;

対策
一括処理のvalidationは別クラスで行い、activerecord-importのimportメソッドで一括登録・更新を行う

class ModelA < ApplicationRecord
  belongs_to :model_b
end

class ModelAValidator
  def initialize(model_as)
    @model_as = model_as
    @errors = []
  end

  def validate
    validate_name
    validate_model_b
    @errors
  end

  private

  def validate_model_b
    model_b_ids = @model_as.map(:model_b_id)
    present_model_b_ids = ModelB.where(id: model_b_ids).pluck(:id)
   absent_model_b_ids = model_b_ids - present_model_b_ids
    @errors << "model b がないものあるよ" if absent_model_b_ids.count > 1
  end
end
errors = ModelAValidator.new(model_as).validate
if errors.blank?
  ModelA.import(model_as)
end
select * from model_bs where id in (xxxx, yyyy, zzzz);
insert model_as values xxxx on duplicate key update;

2. 一括処理時のcallbackで遅い

原因
ActiveRecordのdependentやafter_saveなどのcallbackの影響で、関連レコードを削除するたびに、クエリが発行されるため遅くなってしまう

class ModelA < ApplicationRecord
  has_many :model_c, dependent: :destroy
  after_save :update_model_d_attribute
end
model_as.each do |a|
  a.destroy
end
delete model_as where id = xxxxx;
delete model_cs where model_a_id = xxxxx;
delete model_as where id = yyyy;
delete model_cs where model_a_id = yyyy;

対策
callbackが走らない delete_allやupdate_allなどのメソッドを用いる

class ModelA < ApplicationRecord
  has_many :model_c, dependent: :destroy
  after_save :update_model_d_attribute

  class << self
    def bulk_destroy_all(ids)
      self.where(id: ids).delete_all
      ModelC.where(model_a_id: ids).delete_all
    end
  end
end
ModelA.bulk_destroy_all(model_as.map(&:id))
delete model_as where id in (xxxxx, yyyy);
delete model_cs where model_a_id in (xxxxx, yyyy);

3. ActiveRecordのオブジェクトを回してて遅い

原因
何も指定しないとActiveRecordのselectは全てのcolumnを取得する
ActiveRecordのオブジェクトは単純な数値や文字列よりかなり大きいのでメモリも逼迫し、遅くなってしまう

result = Benchmark.realtime do
  ModelA.limit(1000).each do |model_a|
    model_a.name
  end
end
puts "処理概要 #{result}s"
#=> 0.33s
#=> ActiveRecordのオブジェクトをメモリに乗せることになるので、メモリも逼迫する

対策

1 selectで取得するcolumnを絞る

result = Benchmark.realtime do
  ModelA.select(:name).limit(1000).each do |model_a|
    model_a.name
  end
end
puts "処理概要 #{result}s"
#=> 0.15s (45%ほどに)

2 pluckでselectするcolumnを絞る&ActiveRecordのインスタンスではなく純粋な配列にする

result = Benchmark.realtime do
  ModelA.limit(1000).pluck(:name).each do |name|
    name
  end
end
puts "処理概要 #{result}s"
#=> 0.005s(1.5%ほどに)

4. n+1がでて遅い

原因
eachの中で、has_manyな関連を持つレコードを取得するときに、何もしていないとその度にクエリが発行されるため遅くなってしまう

class ModelA < ApplicationRecord
  has_many :model_c
end
ModelA.all.each |model_a|
  model_a.model_c.name
end
select * from model_as;
select * from model_cs where model_a_id = xxx;
select * from model_cs where model_a_id = yyy;
select * from model_cs where model_a_id = zzz;

対策
includes, preload, eager_loadを用いてクエリをまとめる

ModelA.all.includes(:model_cs) each |model_a|
  model_a.model_c.name
end
ModelA.all.preload(:model_cs) each |model_a|
  model_a.model_c.name
end
select * from model_as;
select * from model_cs where model_a_id in (xxx, yyy);
ModelA.all.eager_load(:model_cs) each |model_a|
  model_a.model_c.name
end
select distinct model_a.id FROM `model_a` LEFT OUTER JOIN `model_cs`;
select * FROM `model_a` LEFT OUTER JOIN `model_cs` where model_a.id in (xxx, yyy, zzz);

5. DBにindexが貼られてなくて遅い

原因
Rails特有の問題ではないが、適切なindexが貼られていないとDB検索に時間がかかり遅くなってしまう

ModelA.where(name: "sss")
ModelA Load (265.5ms) select * from model_as where name = "sss"; 
#本番のあるテーブルで似たようなクエリをうった結果

対策
migrationにて、DBにindexを貼る

class AddIndexToModelA < ActiveRecord::Migration
  def change
    add_index :model_as, :name
  end
end
ModelA.where(name: "sss")
ModelA Load (3.7ms) select * from model_as where name = "sss"; 
# 1.3%ほどに

最後に

最初に知っていればあとの苦労も減ると思います。特に最後の3つは一括処理が多いかどうかに拘らず大事な観点だと思うので、 rails new した瞬間から意識していただけるといいと思います。
良い開発ライフを

vankobe
リンクアンドモチベーションで、モチベーションクラウドを作っています。 現環境はAWS+Rails+Vueです。 個人的に業務改善やマーケティングも好きなのでSpreadSheetやRedash、BigQueryも実は結構投稿してます
lmi-inc
リンクアンドモチベーションはこれまでコンサルティング事業で培ったノウハウをテクノロジー(モチベーションクラウド)にのせる第2創業期です。
http://www.lmi.ne.jp/
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away