14
14

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

DBパフォーマンスを意識したRailsの書き方まとめ

Last updated at Posted at 2022-02-26

DBパフォーマンスを意識したRailsの書き方まとめ

Railsではデータセットが巨大化したときのデフォルトのパフォーマンスがかなり低下するケースがあります。

そんなときに気をつけた方が良いポイントを記載します。

1. N+1

N+1は簡単に言うと必要以上に SQL が走るせいでパフォーマンスが低下する問題です。

悪い例

tweets_controller.rb
class UsersController < ApplicationController
  def index
    @tweets = Tweets.all
  end
end
index.html.erb
<% @tweets.each do |tweet| %>
  <%= tweet.user.name %> # ここでN+1が起きている
<% end %> 

この書き方だと、tweetsの数だけSQLが発行されるのでパフォーマンスが落ちます。

良い例

tweets_controller.rb
class TweetsController < ApplicationController
  def index
    @tweets = Tweets.includes(:user)
  end
end

includesを利用して事前にクエリキャッシュをすることで、N+1解決できます。

参考:
https://qiita.com/hirotakasasaki/items/e0be0b3fd7b0eb350327
https://qiita.com/massaaaaan/items/4eb770f20e636f7a1361

2. countではなくてsizeを使用する

Active Recordのメソッドで、データの件数を取得するためにはcount,length, sizeの3つのメソッドがあります。

この挙動の違いを理解せずに利用するとパフォーマンスに影響が生まれます。

悪い例

以下のコードはRailsビューで非常によく見かけます。

irb(main):001:0> users = User.all #usersテーブルのレコードを全件取得

irb(main):002:0> users.count #取得したレコードの件数を取得
   (0.6ms)  SELECT COUNT(*) FROM `users`
=> 3

irb(main):003:0> users.count #取得したレコードの件数を再度取得
   (1.9ms)  SELECT COUNT(*) FROM `users`
=> 3

countを実行すると毎回データベースに対してクエリを投げていられます。

テーブルの全行数が多ければ多いほど、パフォーマンス影響が大きくなります。

良い例

irb(main):001:0> users = User.all

irb(main):002:0> users.length #取得したレコードの件数を取得
  User Load (0.3ms)  SELECT `users`.* FROM `users`
=> 3

irb(main):003:0> users.length #取得したレコードの件数を再度取得
=> 3

lengthを利用すると、初回のlength実行時にデータをメモリに保存しているので、2回目からクエリが発行されません・

参考:https://himakuro.com/rails-count-length-size-guide

3.安易にallで取得した結果をループ処理しない

allでは取得全件をメモリに展開するという特徴があります。

取得件数が少なければそこまで問題になりませんが、取得件数が大きい場合はメモリ圧迫されてしまいメモリエラーになるケースがあるので注意が必要です。

悪い例

User.all.each do |user|
  # 何かuserのオブジェクトを使用して処理をするコード
end

仮にUserデータが10,000件取得された場合、10,000件が全てメモリ解放されてしまいメモリ消費が激しくなります。

良い例

User.find_in_batches(batch_size: 1000) do |users|
  # 何かuserのオブジェクトを使用して処理をするコード
end

もし仮にUserデータが10,000件取得されたとしても、find_in_batchesを利用すれば1000ずつ処理されます。

つまり、10,000 / 1000 = 10回の処理に分けるイメージです。

ちなみに、大量にデータを挿入する場合はBULK INSERTをすると

参考:https://qiita.com/Marusoccer/items/af75b387cdb17eef91c6

4.不必要なActive Recordオブジェクトの生成

Active Recordオブジェクトは膨大な数のモジュールやメソッドをラップしているので、無駄な作成は避けるべきです。

悪い例

user_names = User.all.map(&:name)

Userのオブジェクトを生成して、そこからActiveRecord::AttributeMethods関連のメソッドを呼び出しています。

良い例

user_names = User.pluck(:name)

pluckメソッドを使うとオブジェクトの生成を行わずにテーブルの値を取得することができます。

pluckメソッドはActiveRecordインスタンスの配列ではなく指定されたカラムの取得値配列を返すメソッドです。

5. メモ化する

メモ化とはメソッドが最初に呼び出されたときに戻り値がキャッシュされ、それ以降、同じスコープ内でメソッドが呼び出されるたびにキャッシュされた値が返されることを意味します。

悪い例

 def user
    @user = User.find(params[:id])
 end

毎度、UserテーブルにアクセスしているのでDB負荷が高い

良い例

 def user
    @user ||= User.find(params[:id])
 end

@userがnillの場合 User.find(params[:id])が@userに代入されます。
しかし、二回目同じメソッドを呼び出した時にUser.find(params[:id])この部分の処理が走らないので余計なDBアクセスを省略することができます。(一回目処理が走った時に、@userにキャッシュされているので)

6. 一括更新するときは一括更新にすべき

バルクインサートでは1回のトランザクションでインサートすることができるため、トランザクションを発行して何回もインサート処理をループさせるよりもコンテキストスイッチの回数が減るため実行速度が速いです。

一括更新するときはできる限りバルクインサート処理に寄せる形で実装する方がベターです。

悪い例

class Entry < ApplicationRecord
end

now = Time.current

# この書き方だと毎回insert処理クエリが走るのでパフォーマンスがあまり良くない。
Entry.create(title: 'foo1', body: 'bar1', created_at: now, updated_at: now)
Entry.create(title: 'foo2', body: 'bar3', created_at: now, updated_at: now)

良い例

DBアクセスが少なく済むようにこのように一つのINSERT文にまとめらる方がベターです。

class Entry < ApplicationRecord
end

now = Time.current
Entry.insert_all([
  { title: 'foo1', body: 'bar1', created_at: now, updated_at: now },
  { title: 'foo2', body: 'bar3', created_at: now, updated_at: now },
])

# 内部的にはBulkInsert処理になる
#=> Entry Bulk Insert (7.3ms)  INSERT INTO "entries"("title","body","created_at","updated_at") VALUES ('foo1','bar1','2020-01-25 17:22:50.533182', '2020-01-25 17:22:50.533182'),('foo2','bar2','2020-01-25 17:22:50.533182', '2020-01-25 17:22:50.533182')

7. 重い処理は非同期にする

コードの書き方ではないですが、重い処理は同期処理ではなく非同期処理にすることを検討しましょう。

高負荷処理がかかる実装はバックグランド処理に移動させることで、リクエストタイムアウトエラーやメモリエラーを事前に防ぐことができます。

8. 大量レコードを扱う時はメモリ圧迫とオブジェクト作成を抑えるよう意識する

大量レコードをメモリに乗せすぎない、オブジェクト作成ではなくHashをできる限り利用するように意識するの大事。

9. 大きいテーブルをJoinしない

レコード数が多いテーブルをJoinは処理が重くなるのでできる限り避けましょう。

10.大量データのCSVダウンロード処理はタイムアウトなども考慮する

大量データのCSVダウンロード処理はタイムアウトも考慮しましょう。
考慮しないとタイムアウトエラーになるケースがあります。

パフォーマンスが悪くなっている場所を見つけよう

パフォーマンスを意識して書いたとしても、人間なので抜け漏れはあります。

ですので、パフォーマンス検知ライブラリを導入して、パフォーマンスが落ちている場所に気づけるような仕組みを作っておきましょう。

rack-mini-profilerを導入

rack-mini-profilerは手軽にRuby on Railsのパフォーマンス計測ができるgemです。

HTMLレンダリングした時のパフォーマンスを確認したいときに利用するのがオススメ

bulletを導入

bulletはN+1問題をアラート表示してくれるgemです。N+1は気をつけていても発生してしまうケースがあるので事前に導入しておくことをオススメします。

New Relicを導入

アクション実行時にどの処理にどれだけ時間が掛かったかをメトリクス収集してくれる
https://newrelic.com/jp/ruby/rails

Benchmarkモジュールを利用したベンチマークの実行

Ruby標準のBenchmarkモジュールを利用したベンチマークの実行をしてみてパフォーマンスチェックするのも良い方法です。

実際の利用方法はこちらが参考になります。

まとめ

他にも気をつけた方が良いポイントなどがあれば、ご指摘いただければ嬉しいです!

14
14
1

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
14
14

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?