スロークエリ対策と対処法について

はじめに

本記事はRuby on Rails(以下Rails)を用いたWebアプリケーション開発におけるスロークエリの対策方法について記述します。

RailsにはDBとのアクセスを容易にしてくれる強力なライブラリであるActive Recordが採用されています。

おそらくActive RecordなしではRailsはここまで強力なWebフレームワークとはならなかったでしょう。

実際に他のO/Rマッパーを採用しているLaravelやDjangoと比べても、RailsのActive Recordは非常に完成されていると感じます。

しかし、このようにとても便利なActive Recordですが、裏側で発行されるSQLやDBアクセスの仕組みについてよく理解しておかないとスロークエリの原因となり、パフォーマンスを悪化させてしまいます。

小規模なアプリ開発ならそれでいいんですが、大規模なアプリになってくると、みるみるうちにパフォーマンスが悪くなりとても遅いアプリケーションが出来上がってしまいます。

そこで、今回はスロークエリの原因を紹介し、その解決方法をご紹介していきたいと思います。

実行環境

この記事では以下の環境で動作確認をしています。

  • Ruby 2.7.1
  • Ruby on Rails 6.0.1
  • MySQL 5.7

いくつかのActive Recordのアンチパターン

Active Recordにはいくつかの一定のアンチパターンのようなものが存在します。

アンチパターンはチームで開発を行い、既存のプロジェクトのコードリーディングなどをしているとある一定の頻度で登場してきます。

基本的にそのアンチパターンに引っかかったものは、スロークエリの原因になります。

代表的なものは下記になります。

基本的にそのアンチパターンに引っかかったものは、スロークエリの原因になると思って大丈夫でしょう。

  • N+1問題
  • all each パターン
  • 不必要なActive Recordオブジェクト生成

それぞれ上の方から解説していきたいと思います。

N+1問題

N+1問題はRailsエンジニアなら誰しも聞いたことのあるワードだと思います。

スロークエリ改善の第一歩はここから始まります。

結論からいうとincludesとpreloadとeager_loadを適切に使いましょうということですね。

参考記事:ActiveRecordのjoinsとpreloadとincludesとeager_loadの違い(https://qiita.com/k0kubun/items/80c5a5494f53bb88dc58)

ただ、少し気をつけたいのがN+1問題はとりあえずincludesで解決しようと思っていると少し落とし穴にはまります。

includesを盲目して使用するのではなく、状況に応じてpreloadとeacger_laodも使い分けましょうという話になります。

join先のテーブルが必要なければpreloadを使用し、

users = User.preload(:posts)

join先のテーブルでwhere句などを使用するならeager_loadを使用しましょう。

users = User.eager_load(:posts).where('posts.titie = ? ', 'サンプル')

また、玄人はincludesの使用は基本的に避け、preloadとeager_loadを状況によって使い分けるみたいな実装をすることが多いらしいです。

また、よくありがちなスロークエリの原因として、eager_loadingしたモデルに対してexists?やcountを呼び出してしまうことも挙げられます。

users = User.eager_load(:posts).where('posts.created_at = ?', DateTime.new(2020, 01, 01))
users.exists?

  User Exists? (0.2ms)  SELECT 1 AS one FROM "users" LEFT OUTER JOIN "posts" ON "posts"."user_id" = "users"."id" WHERE (posts.created_at = '2020-01-01 00:00:00') LIMIT ?  [["LIMIT", 1]]
=> true

exists?は実際にSQLへアクセスし、該当するレコードが存在するかを確認しにいくので、せっかくのeager_loadingが無駄になってしまいます。

レコードの存在有無だけを確認したいならpresent?を使うといいでしょう。

users = User.eager_load(:posts).where('posts.created_at = ?', DateTime.new(2020, 01, 01))

users.present?
=> true

countも同様です。SQLでcountを用いたSELECT文を発行してしまいます。

users = User.eager_load(:posts).where('posts.created_at = ?', DateTime.new(2020, 01, 01))
users.count

   (2.0ms)  SELECT COUNT(DISTINCT "users"."id") FROM "users" LEFT OUTER JOIN "posts" ON "posts"."user_id" = "users"."id" WHERE (posts.created_at = '2020-01-01 00:00:00')
=> 1

sizeで代用しましょう。

users = User.eager_load(:posts).where('posts.created_at = ?', DateTime.new(2020, 01, 01))

users.size
=> 1

eachをallで展開する

これもよくやりがちな実装だと思います。

バッチ処理などでこのような処理を見かけることが多いです。

例えばある一定の期間のユーザーフォロワー数を計測してTOP100をランキング形式にするバッチがあったとします。

followed_ranking = []
User.all.each do |user|
  next if user.deleted # 論理削除されていればtrueになる
  followed_count = user.followed.where(created_at: >= Time.current - 1.month).size
  followd_ranking << { name: user.name, followd_count: followed.count }
end

followed_ranking.sort_by! { |key| key[:followed_count]}

基本的にallが処理に入ってきたときは一度そのコードを疑ってかかりましょう(私のサーバーサイドの師匠がいつも口酸っぱくいっていた言葉でもあります)。

N+1問題が入り込む余地がありつつ、上述の実装だとUserの全件をメモリに展開してから、ひとつひとつの処理をeachで行っていくため、メモリ消費が激しくなります。

レコード数が数百件などの小規模アプリではあまり気にしなくてもよいことですが、数十万件などの大規模アプリになってくると処理速度に顕著に関わってきます。

メモリが多く消費されるということはそれだけで、スロークエリの原因になります(この場合はスロークエリとは言わないかもしれませんが)。

上記のような処理はallのかわりにfind_eachを使いましょう。また、ついでにモデルをプリロードしてあげます。

followed_ranking = []
User.find_each.eager_load(:relatiionthips) do |user|
  next if user.deleted # 論理削除されていればtrueになる
  followed_count = user.followed.where(created_at: >= Time.current - 1.month).size
  followd_ranking << { name: user.name, followd_count: followed.count }
end

followed_ranking.sort_by! { |key| key[:followed_count]}

find_eachはレコードを1000件取得ずつ取得し、その取得したレコードを1件ずつ処理してきてくれます。

1000件取得し終わったらまた、次の1000件を取得してきて・・・の繰り返しとなります。

ちなみにレコード展開数を指定したかったらその姉妹メソッドであるfind_in_batchesを使用しましょう。メソッドの引数で、レコード数を指定できます。

find_eachはfind_in_batchesを単にラップしているだけですので、両者はそれぞれ同じような動きをします。(ただ展開したレコードをオブジェクトで取得するか配列で取得するかなどの細かな違いはあります)

参考記事:http://cloudcafe.tech/?p=2446

また、eachの中でif文を使って処理の条件分岐をしている場合はたいてい改善の余地があります。

User.where(deleted: false).find_each...

のような形で、そもそものeachの対象を絞り込んでから行いましょう。

followed_ranking = []
User.find_each.eager_load(:relatiionthips).where(deleted: false) do |user|
  followed_count = user.followed.where(created_at: >= Time.current - 1.month).size
  followd_ranking << { name: user.name, followd_count: followed.count }
end

followed_ranking.sort_by! { |key| key[:followed_count]}

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

上述のN+1問題や、eachパターンほどではないですが、これも気をつけないとやってしまいがちな実装となります。

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

上述のmapを使用したやり方は不必要なActive Recordオブジェクトを生成してしまい、パフォーマンスがあまりよくありません。

Active Recordオブジェクトは膨大な数のモジュールやメソッドをラップしているので、生成コストが高く、それだけでメモリを費やしてしまいます。

Active Recordオブジェクトを生成しなくてもよいやり方があるならば、そちらのやり方を考えましょう。

user_names = User.pluck(:name)

pluckを使用することで、不要なオブジェクト生成を回避できました。

その他Railsのスロークエリ改善策

その他にもスロークエリを回避するいくつかの方法があるので、ご紹介します。

キャッシュを使う

Rails標準のキャシュ、もしくはRedisなどを用いたキャッシュによってスロークエリをクエリキャッシュしてしまうという手があります。

config.cache_store = :mem_cache_store
config.cache_store = :redis_cache_store, { url: 'redis://redis:6379/0/cache' }

両方ともアプリの設定を変えるだけなので、手軽に実装できると思います。

もちろんRedisを使用する場合は、サーバーへの導入や設定が必要になります。

重い処理は非同期にする

また、重い処理は非同期処理にしてしまうという手もあります。

その際は非同期を実現できるgemのSidekiqやDelayed jobの導入を検討するといいかもしれません。

おわりに

今回は様々なRailsでのスロークエリの原因やその解決法を紹介しました。

冒頭でも説明しましたが、Active Recordは便利な半面、その挙動を把握してあげないと、思わぬところでパフォーマンスの悪化を招いてしまいます。

とはいえ、Active Recordはとても便利なもので、私達のDXを豊かなものにしてくれています。

私もActive Recordと親友になれるようにこれからも勉強していきたいと思います。