Ruby
 Computer >> コンピューター >  >> プログラミング >> Ruby

ActiveRecordのパフォーマンス改善:N+1クエリ問題というアンチパターンを理解する

AppSignalでは、アプリケーションのパフォーマンス向上を開発者の皆様にサポートしています。私たちは数十億ものリクエストを処理する膨大な数のアプリケーションを監視しており、その経験から、Rubyとパフォーマンスに関する知見をブログ記事としてお届けできればと考えています。本記事で取り上げる「N+1クエリ問題」は、Railsアプリケーションで非常によく見られるアンチパターンです。

RailsのActiveRecordをはじめとする多くのORMには、遅延読み込み(Lazy Loading)が組み込まれています。これにより、関連するレコードへのクエリを実際に必要となる瞬間まで延期できます。どの関連をロードすべきかという判断をビュー側に委ねることで、コードを暗黙的かつシンプルに保てるのがメリットです。

しかし、N+1クエリ問題はよく発生しながらも比較的発見しやすいパフォーマンス上のアンチパターンです。これは、関連レコードごとに個別のクエリが実行されるため、大量の関連データをデータベースから取得する際に大きなオーバーヘッドを生み出します。

👋 ちなみに、この記事が気に入った方は、Ruby(on Rails)のパフォーマンスに関する記事を他にも公開しています。ぜひ「Rubyパフォーマンス監視チェックリスト」もご覧ください。

ActiveRecordにおける遅延読み込み

ActiveRecordは暗黙的な遅延読み込みを採用しており、リレーションの操作を容易にしています。ここでは、Webショップを例に考えてみましょう。各Product(商品)は任意の数のVariants(バリエーション)を持つことができ、バリエーションには商品の色やサイズなどの情報が含まれます。

# app/models/product.rb
class Product < ActiveRecord::Base
  has_many :variants
end

ProductsController#showは特定の商品の詳細画面を表示するアクションです。ここではProduct.find(params[:id])で商品を取得し、@product変数に代入します。

# app/controllers/products_controller.rb
class ProductsController < ApplicationController
  def show
    @product = Product.find(params[:id])
  end
end

対応するビューでは、コントローラーから受け取った@productに対してvariantsメソッドを呼び出し、商品のバリエーションをループ処理で表示します。

# app/views/products/show.html.erb

<%= @product.title %>

    <%= @product.variants.each do |variant| %>
  • <%= variant.name %>
  • <% end %>

ビュー内で@product.variantsを呼び出すと、Railsはデータベースへクエリを発行し、バリエーションを取得してくれます。コントローラーで実行した明示的なクエリとは別に、Railsのログを確認すると、バリエーションを取得するためのもう一件のクエリが実行されていることがわかります。

Started GET "/products/1" for 127.0.0.1 at 2018-04-19 08:49:13 +0200
Processing by ProductsController#show as HTML
  Parameters: {"id"=>"1"}
  Product Load (1.1ms)  SELECT  "products".* FROM "products" WHERE "products"."id" = ? LIMIT ?  [["id", 1], ["LIMIT", 1]]
  Rendering products/show.html.erb within layouts/application
  Variant Load (1.1ms)  SELECT "variants".* FROM "variants" WHERE "variants"."product_id" = ?  [["product_id", 1]]
  Rendered products/show.html.erb within layouts/application (4.4ms)
Completed 200 OK in 64ms (Views: 56.4ms | ActiveRecord: 2.3ms)

このリクエストでは、商品とそのすべてのバリエーションを表示するために2件のクエリが実行されました。

  1. SELECT "products".* FROM "products" WHERE "products"."id" = 1 LIMIT 1
  2. SELECT "variants".* FROM "variants" WHERE "variants"."product_id" = 1

ループ内での遅延読み込み

ここまでは遅延読み込みがうまく機能していました。暗黙的なクエリを使えば、たとえば「このビューではもうバリエーションを表示しない」と判断したときに、コントローラーから該当コードを削除し忘れる心配もありません。

次に、ProductsController#indexで、すべての商品とそれぞれのバリエーションの一覧を表示したい場面を考えます。先ほどと同じ要領で、遅延読み込みを使って実装できます。

# app/controllers/products_controller.rb
class ProductsController < ApplicationController
  def index
    @products = Product.all
  end
end
# app/views/products/index.html.erb

Products

<% @products.each do |product| %>

<%= product.title %>

    <% product.variants.each do |variant| %>
  • <%= variant.description %>
  • <% end %>
<% end %>

最初の例とは異なり、今回は単一の商品ではなく商品リストを取得します。ビューでは各商品をループ処理し、その都度バリエーションが遅延読み込みされます。

動作はしますが、ここに落とし穴があります。クエリ数がN+1になってしまうのです。

N+1クエリ問題とは

最初の例では、単一の商品とそのバリエーションを表示するビューを描画しました。クエリを2件実行したため、クエリ数は2でした。一方今回の例では、データベースから全商品(この例では3件)とそれぞれのバリエーションを取得しましたが、クエリは4件実行されています。

Started GET "/products" for 127.0.0.1 at 2018-04-19 09:49:02 +0200
Processing by ProductsController#index as HTML
  Rendering products/index.html.erb within layouts/application
  Product Load (0.3ms)  SELECT "products".* FROM "products"
  Variant Load (0.2ms)  SELECT "variants".* FROM "variants" WHERE "variants"."product_id" = ?  [["product_id", 1]]
  Variant Load (0.2ms)  SELECT "variants".* FROM "variants" WHERE "variants"."product_id" = ?  [["product_id", 2]]
  Variant Load (0.1ms)  SELECT "variants".* FROM "variants" WHERE "variants"."product_id" = ?  [["product_id", 3]]
  Rendered products/index.html.erb within layouts/application (5.6ms)
Completed 200 OK in 36ms (Views: 32.6ms | ActiveRecord: 0.8ms)
  1. SELECT "products".* FROM "products"
  2. SELECT "variants".* FROM "variants" WHERE "variants"."product_id" = 1
  3. SELECT "variants".* FROM "variants" WHERE "variants"."product_id" = 2
  4. SELECT "variants".* FROM "variants" WHERE "variants"."product_id" = 3

最初のクエリは、コントローラー内のProduct.allという明示的な呼び出しによってすべての商品を取得するものです。それ以降のクエリは、ビューで各商品をループ処理する過程で遅延的に実行されています。

この例でのクエリ数はN+1になります。Nは商品数であり、追加される1件は全商品を取得した最初のクエリです。つまり、この例ではまず1件のクエリを実行し、その結果1件ごとにもう1件ずつクエリを実行していることになります。この例では N = 3 なので、クエリ数は N + 1 = 3 + 1 = 4 となります。

商品が3件程度であれば大きな問題にはならないかもしれませんが、クエリ数は商品数に比例して増加していきます。N+1クエリであることがわかっていれば予測も簡単です。たとえば商品が100件あれば、クエリ数は101件(N + 1 = 100 + 1 = 101)になると計算できます。

関連の事前読み込み(Eager Loading)

現状のように商品数に応じてクエリ数が増えていくのではなく、ビューごとのクエリ数を固定値にしたいところです。そのためには、ビューを描画する前に、コントローラー側でバリエーションを明示的にプリロード(事前読み込み)すればよいのです。

# app/controllers/products_controller.rb
class ProductsController < ApplicationController
  def index
    @products = Product.all.includes(:variants)
  end
end

ActiveRecordのincludesクエリメソッドを使うと、関連するバリエーションが商品と一緒にロードされます。事前にどのバリエーションをロードすべきかがわかるため、要求されたすべての商品のバリエーションを1件のクエリでまとめて取得できるのです。

Started GET "/products" for 127.0.0.1 at 2018-04-19 10:33:59 +0200
Processing by ProductsController#index as HTML
  Rendering products/index.html.erb within layouts/application
  Product Load (0.3ms)  SELECT "products".* FROM "products"
  Variant Load (0.4ms)  SELECT "variants".* FROM "variants" WHERE "variants"."product_id" IN (?, ?, ?)  [["product_id", 1], ["product_id", 2], ["product_id", 3]]
  Rendered products/index.html.erb within layouts/application (5.9ms)
  Completed 200 OK in 45ms (Views: 40.8ms | ActiveRecord: 0.7ms)

バリエーションをプリロードすることで、将来的に商品数が増えたとしても、クエリ数は2件に抑えられます。

  1. SELECT "products".* FROM "products"
  2. SELECT "variants".* FROM "variants" WHERE "variants"."product_id" IN (1, 2, 3)

遅延読み込みか、事前読み込みか?

ほとんどの場合、関連レコードを1件のクエリでまとめて取得する方が、遅延読み込みよりも大幅に高速です。

このサンプルアプリケーションでも、商品3件・各商品にバリエーション10件という小さなデータセットでも、データベースパフォーマンスの差は測定可能でした。商品一覧の平均では、事前読み込みの方が遅延読み込みより約12.5%高速です(0.7ms 対 0.8ms)。商品10件になると差は59%に拡大し(1.22ms 対 2.98ms)、商品1,000件では差はほぼ80%に達します。事前読み込みでは58.4msに対し、遅延読み込みでは約290.12msかかったのです。

遅延読み込みされた関連は、コントローラーを更新せずに済む分、ビューにおいて柔軟性が高いという利点があります。しかし、経験則として良いのは、「データのロードはコントローラー側で行い、その結果をビューに渡す」という設計です。

ビューからの遅延読み込みが適しているのは、単一のモデルオブジェクトとその関連を表示するビュー(最初の例のProductsController#showなど)です。また、同一のコントローラーから、必要なデータの異なる複数のビューを描画するようなケースでも有用でしょう。

まとめ:Eagerに生きよう

猫たちには反論されるかもしれませんが、時には怠惰(lazy)よりも熱心(eager)である方が得になるのです。本記事では、ActiveRecordの遅延読み込みについて掘り下げ、それがパフォーマンス問題につながる状況――つまりN+1クエリ問題――を具体例とともに解説しました。

要点をまとめると以下の通りです。

  • 開発ログやAppSignalのイベントタイムラインを常にチェックし、遅延読み込みによって不要なクエリが発生していないか確認する
  • 特に処理するデータ量が増加する場面では、レスポンスタイムを継続的にモニタリングする

この記事が役に立った方は、パフォーマンスと監視に関する他の記事もぜひご覧ください。「Russian Dollキャッシング」や「条件付きGETリクエスト」に関する記事などがおすすめです。

  1. Windows PCが遅いときの対処法!パフォーマンスを改善する3つの簡単ステップ

    パソコンの動作が遅くなっていませんか?これは世界中の何百万人ものユーザーが抱える悩みであり、Microsoft自身もWindowsのアップデートを重ねるたびに解決に苦労している課題です。しかし幸いなことに、たとえかなり古いパソコンでも、いくつかの簡単な手順を実行するだけで速度を取り戻すことができます。パソコンを高速化する鍵は、動作を遅くしている原因を一つずつ取り除くことです。実際のところ、すべてのパソコンは十分な性能とリソースさえあれば高速に動作できます。多くのパソコンが遅くなるのは、Windowsに頻繁に発生するいくつかの典型的な問題が原因なのです。以下の手順で、これらの問題を素早く解決しま

  2. Windows 11で最高のパフォーマンスを引き出す方法【初心者向け最適化ガイド】

    Windows 11は一般ユーザー向けに正式リリースされ、数多くの新機能が搭載されました。スタートメニューをお気に入りのアプリに合わせてカスタマイズでき、UIの変更によってこれまでとはひと味違う快適な操作体験が得られます。 また、ここ数か月の間にMicrosoftによるメモリ管理やディスク使用量の改善、CPU使用率やバッテリー駆動時間への対応などが進められ、OSのパフォーマンスにもさまざまな改良が加えられています。 しかしその一方で、Windows 11がスムーズかつ高速に動作するのは比較的新しいPCであり、セキュリティ要件を満たしている古いパソコンでは体感できるほどの改善が見られないのが実情