はじめに
モチベーションクラウド、性能改善チームの江上です。
性能改善チームは、大手の企業様でもモチベーションクラウドを利用できるように、アプリケーションのスピード改善を行うチームです。
このチームができて、約一年。その間に、マイクロサービス化や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
した瞬間から意識していただけるといいと思います。
良い開発ライフを