Ruby on Rails

【Rails】パフォーマンスを意識してアプリを高速化する

コントローラーやモデルでの処理でパフォーマンスを意識した書き方ができてなかったので、備忘録にまとめていきたいと思います。

① 繰り返し処理中でARオブジェクトの生成している例

今回はフロントエンドからIDの配列が送られてくるものとします。それに対してイテレートしてカラムデータを取得するというケースを考えます。まずはアンチパターンです。

article_titles = []
params[:article_ids].each do |article_id|
  article = Article.find(article_id)
  article_titles = article_titles.push(article.title)
end

この処理には問題点が2つあります。

  1. ループの都度SQLが発行されている
  2. 無駄にActive Recordのオブジェクトを生成させている

3行目のariticleを取得するSQLがariticle_idsの中のidが増えると比例して走ることになります。これは以下のようにリファクタリングすると、一回のSQLで済みます。

Article.where(id: params[:article_ids])

同じ箇所の3行目でarticleのオブジェクトを生成して、そこからtitleを取得しています。この場合pluckメソッドを使うとオブジェクトの生成を行わずにテーブルの値を取得することができます。pluckメソッドはActiveRecordインスタンスの配列ではなく指定されたカラムの取得値配列を返すメソッドです。

pluckメソッドを使うと次のように書き換えられます。

Article.where(id: params[:article_ids]).pluck(:title)
# (1.8ms)  SELECT `articles`.`title` FROM `articles` WHERE `articles`.`id` IN (1, 2)

② レコードの存在確認でARオブジェクトを生成しているパターン

次に下のような例を考えてみます。
こちらは全subscriptionレコードの中で特定のユーザのものが存在するかをチェックするコードです。

bool = Subscription.all.none? { |s| s.user_id == params[:user_id] }
if bool
  # do_something
end

ここで問題点が2つあります。

  1. subscriptionを全件取得するSQLが走っている
  2. user_idを確かめるためにARのオブジェクトをレコード分作成している

これらはレコード数があまりない場合は大した問題にはならないですが、増えていくと徐々にパフォーマンス上で支障をきたすようになります。

これらは以下のように書き直すことで、①と②両方の問題をなくすことができます。

bool = Subscription.exists?(user_id: cookies.encrypted[:user_id])
if bool
  # do_something
end

exists?メソッドとは、指定した条件のレコードがデータベースに存在するかどうかを真偽値で返すメソッドです。存在すればtrueを存在しなければfalseを返します。

exists?メソッドを使って発行されたSQLを見ると、user_idが386のものがあるかどうか確認しているのが分かります。

Subscription Exists? (1.2ms)  SELECT 1 AS one FROM `subscriptions` WHERE `subscriptions`.`user_id` = 386 LIMIT 1

これにより、すべてのデータを取得するという無駄が省けています。さらにARのオブジェクトを一つも生成していません。

試しにどれくらい影響があるかのベンチマークをとったので、紹介します。

def index
    base_logger = ActiveRecord::Base.logger
    ActiveRecord::Base.logger = nil

    Benchmark.bm do |x|
      x.report('全件比較') do
        Subscription.all.none? { |s| s.user_id == cookies.encrypted[:user_id] } # subscriptionを全件取得し、またARを生成している
      end

      x.report('exists?') do
        Subscription.exists?(user_id: cookies.encrypted[:user_id])
      end
    end

    ActiveRecord::Base.logger = base_logger
  end

上記のコードをsubscriptionレコードの量を増やしながら、どれだけ影響があるかを見ていきました。

usersystemtotalreal
全件比較0.0010220.0000000.001022( 0.001972)
exists?0.0008440.0000000.000844( 0.002199)
subscriptionsの数 2件

usersystemtotalreal
全件比較0.0013580.0000000.001358( 0.003182)
exists?0.0006090.0000000.000609( 0.001791)
subscriptionsの数 100件

usersystemtotalreal
全件比較0.0045830.0000690.004652( 0.014316)
exists?0.0006240.0000000.000624( 0.001739)
subscriptionsの数 1000件

usersystemtotalreal
全件比較0.0463960.0039010.050297( 0.153289)
exists?0.0015680.0000000.001568( 0.004984)
subscriptionsの数 10000件

レコード数が少ないときは両者に差はありませんでしたが、10000件ほどになるとかなりパフォーマンスに差が出てました。ローカル環境でデモしたところ、レコード数が10000件あると30倍以上早くなりました。

補足

少し分かりづらい人のためにSELECT 1 AS one FROM subscriptions;の結果を載せておきます。

SELECT 1 AS one FROM `subscriptions`;

one
---
1
1
1
1
1
1
1

③ レコード数のカウントにSQLを用いているパターン

はじめに例を示します。

@subscriptions = Subscription.all
@subscriptions.size

これにより発行されるSQLは以下のようになります。

Subscription Count (2.1ms)  SELECT COUNT(*) FROM `subscriptions`

COUNT文が走ってしまってますね。レコード数が少なかったり、例のようにテーブルの結合がない場合は速いですが、レコード数が多く、複数テーブルが結合しているとパフォーマンスに影響が出はじめます。

この場合ARオブジェクトでなく、Arrayに変換するとよいです。

@subscriptions = Subscription.all
arr_subscriptions = @subscriptions.to_a
arr_subscriptions.size

# SQL
# SELECT `subscriptions`.* FROM `subscriptions`

レコード数をカウントする場合はできる限りArrayを使う

まとめ

大事な点は以下です。

  • Active Recordのオブジェクトはなるべく生成しない
  • SQLの実行回数や取得レコード数はなるべく少なくする
  • レコード数をカウントする場合はできる限りArrayを使う

これらは常に頭に入れて書いていかないとと思いました。レコード数が少ない内は気になりませんが、データ量が増えてくるとそれなりに大きな差になりました。

分かりにくいやアドバイス等ありましたらコメントくださると幸いです。では!
Twitterもやってますので、フォローしていただけるとうれしいです。

参考

https://tech.recruit-mp.co.jp/server-side/post-19614/

ABOUT ME
sakai
東京在住の30歳。元々は車部品メーカーで働いていてましたが、プログラミングに興味を持ちスクールに通ってエンジニアになりました。 そこからベンチャー → メガベンチャー → 個人事業主になりました。 最近は生成 AI 関連の業務を中心にやっています。 ヒカルチャンネル(Youtube)とワンピースが大好きです!