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

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



## 1. 一括処理のvalidationで遅い

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


```ruby:
class ModelA < ApplicationRecord
  belongs_to :model_b
  validates :model_b, presence: true
  validates :name, length: { maximum: 25 }
end
```

```ruby:
model_as.each do |a|
  a.save
end
```

```sql:
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メソッドで一括登録・更新を行う

```ruby:
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
```

```ruby:
errors = ModelAValidator.new(model_as).validate
if errors.blank?
  ModelA.import(model_as)
end
```

```sql:
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の影響で、関連レコードを削除するたびに、クエリが発行されるため遅くなってしまう

```ruby:
class ModelA < ApplicationRecord
  has_many :model_c, dependent: :destroy
  after_save :update_model_d_attribute
end
```
```ruby:
model_as.each do |a|
  a.destroy
end
```

```sql:
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などのメソッドを用いる

```ruby:
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
```
```ruby:
ModelA.bulk_destroy_all(model_as.map(&:id))
```

```sql:
delete model_as where id in (xxxxx, yyyy);
delete model_cs where model_a_id in (xxxxx, yyyy);
```

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

```ruby:
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を絞る

```ruby:
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のインスタンスではなく純粋な配列にする


```ruby:
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な関連を持つレコードを取得するときに、何もしていないとその度にクエリが発行されるため遅くなってしまう

```ruby:
class ModelA < ApplicationRecord
  has_many :model_c
end
```

```ruby:
ModelA.all.each |model_a|
  model_a.model_c.name
end
```

```sql:
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を用いてクエリをまとめる

```ruby:
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
```

```sql:
select * from model_as;
select * from model_cs where model_a_id in (xxx, yyy);
```

```ruby:
ModelA.all.eager_load(:model_cs) each |model_a|
  model_a.model_c.name
end
```

```sql:
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検索に時間がかかり遅くなってしまう

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

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

```ruby:
class AddIndexToModelA < ActiveRecord::Migration
  def change
    add_index :model_as, :name
  end
end
```
```ruby:
ModelA.where(name: "sss")
```

```sql:
ModelA Load (3.7ms) select * from model_as where name = "sss"; 
# 1.3%ほどに
```

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