背景

Railsアプリケーションでユーザーが持つ「注文(orders)」の一覧を表示する画面を開発していたところ、N+1問題が発生していることに気付きました。具体的には、以下のようなログが確認されました。

Product Load (0.3ms) SELECT `products`.* FROM `products` WHERE `products`.`id` = ? LIMIT 1
OrderItem Load (0.0ms) SELECT `order_items`.* FROM `order_items` WHERE `order_items`.`order_id` = ? LIMIT 1
Product Load (0.3ms) SELECT `products`.* FROM `products` WHERE `products`.`id` = ? LIMIT 1
OrderItem Load (0.0ms) SELECT `order_items`.* FROM `order_items` WHERE `order_items`.`order_id` = ? LIMIT 1
...

このようなクエリが、ユーザーが保有する注文数に比例して繰り返し発行されていました。これにより、データベースへの負荷が高まり、ページの表示速度が遅くなる問題が発生しました。

N+1問題とは?

N+1問題とは、データベースから親データ(例: 注文)を取得する際に、関連付けられた子データ(例: 注文アイテムや商品)を個別に取得するためにN回の追加クエリが発生する問題です。

以下のコードを例にすると、各orderに関連するorder_itemsproductsを取得するために追加クエリが発行されます。

<% @orders.each do |order| %>
  <tr>
    <td><%= order.id %></td>
    <td><%= order.user.name %></td>
    <td>
      <% order.order_items.each do |item| %>
        <%= item.product.name %> (x<%= item.quantity %>)
      <% end %>
    </td>
  </tr>
<% end %>

解決策

解決策1: includesで関連データを事前ロードする

ActiveRecordincludesメソッドを使って関連データを事前に一括取得することで、N+1問題を防ぎます。

修正後のコントローラー

以下のように、必要な関連データをincludesで指定します。

def index
  @orders = current_user.orders.includes(order_items: :product)
end

これにより、Railsは以下のようなSQLを発行します。

SELECT "orders".* FROM "orders" WHERE "orders"."user_id" = 1
SELECT "order_items".* FROM "order_items" WHERE "order_items"."order_id" IN (1, 2, 3)
SELECT "products".* FROM "products" WHERE "products"."id" IN (10, 11, 12)

修正後のビュー

ビューの記述は変更不要です。事前ロードされたデータをそのまま利用できます。

<% @orders.each do |order| %>
  <tr>
    <td><%= order.id %></td>
    <td><%= order.user.name %></td>
    <td>
      <% order.order_items.each do |item| %>
        <%= item.product.name %> (x<%= item.quantity %>)
      <% end %>
    </td>
  </tr>
<% end %>

解決策2: デリゲートを活用する

モデルでdelegateを使用して、関連データを簡潔に扱えるようにします。

モデルの修正

class OrderItem < ApplicationRecord
  belongs_to :product
  delegate :name, to: :product, prefix: true
end

ビューの修正

これにより、order_item.product.nameorder_item.product_nameに短縮できます。

<%= item.product_name %>

解決策3: キャッシュの活用

頻繁に変更されないデータ(例: 商品名や価格)に対してキャッシュを活用することで、データベースアクセスをさらに削減できます。

フラグメントキャッシュ

<% @orders.each do |order| %>
  <% cache(order) do %>
    <tr>
      <td><%= order.id %></td>
      <td><%= order.user.name %></td>
      <td>
        <% order.order_items.each do |item| %>
          <%= item.product.name %> (x<%= item.quantity %>)
        <% end %>
      </td>
    </tr>
  <% end %>
<% end %>

効果の確認

修正後、以下のようにクエリの数が大幅に削減されていることを確認しました。

修正前

Order Load (0.3ms) SELECT * FROM orders WHERE user_id = 1
OrderItem Load (0.2ms) SELECT * FROM order_items WHERE order_id = 1
Product Load (0.1ms) SELECT * FROM products WHERE id = 10
OrderItem Load (0.2ms) SELECT * FROM order_items WHERE order_id = 2
Product Load (0.1ms) SELECT * FROM products WHERE id = 11
...

修正後

SELECT "orders".* FROM "orders" WHERE "orders"."user_id" = 1
SELECT "order_items".* FROM "order_items" WHERE "order_items"."order_id" IN (1, 2, 3)
SELECT "products".* FROM "products" WHERE "products"."id" IN (10, 11, 12)

クエリ数が削減され、ページのレンダリング時間が改善されました。

結論

  • N+1問題はRailsアプリケーションでよく発生する問題ですが、includesdelegateを活用することで簡単に解決できます。それらは Rails 3.0 ~ から提供された機能のようですので、今使っているアプリケーションはほとんど使えるはずと思います。
  • 必要に応じて、キャッシュを利用することでさらなるパフォーマンス向上が期待できます。

定期的にログを確認し、N+1問題を早期に発見・解決することで、アプリケーションのパフォーマンスを保つことができます。