TL;DR
-
preload済みのアソシエーションでも、.orderなどクエリメソッドをチェーンすると新しい relation が作られ、preload のキャッシュを使わずに再クエリされる - 再クエリで取り直したレコードには、ネストして preload していたアソシエーションも紐付かないため、N+1 が二重に発生する
- ソート対象の件数が少ないなら、SQL ではなく Ruby 側の
sort_byでソートすると preload を活かせる
背景
商品(Product)に複数のカテゴリ(Category)が中間テーブル(Categorization)経由で付く、よくある構成を例にします。
class Product < ApplicationRecord
has_many :categorizations
has_many :categories, through: :categorizations
end
class Categorization < ApplicationRecord
belongs_to :product
belongs_to :category
end
カテゴリの表示順を登録時の入力順にしたい、という要件のために categorizations に position カラムを追加し、表示時に position 順でソートするよう変更しました。
APIレスポンスを組み立てるシリアライザ相当のコードで、変更前はこうだったものを:
def categories
product.categorizations.map do |categorization|
{ name: categorization.category.name }
end
end
こう変更しました。
def categories
product.categorizations.order(:position).map do |categorization|
{ name: categorization.category.name }
end
end
.order(:position) を1つ足しただけです。商品の詳細ページでは意図通りに動きました。しかし、商品の一覧APIで N+1 が発生しました。
一覧側では preload していた
一覧系のエンドポイントでは、このシリアライザに渡す前に categorizations とその先の category を preload していました。
products = Product.where(...).preload(categorizations: :category)
変更前の product.categorizations.map は、preload でロード済みのレコード(キャッシュ)をメモリ上でそのまま列挙するので、一覧全体で categorizations の取得は1クエリ、categories も1クエリで済んでいました。
.order をチェーンすると何が起きるか
product.categorizations が返すのは ActiveRecord::Associations::CollectionProxy です。これに .order(:position) を呼ぶと、ロード済みのキャッシュとは別の、未ロードの ActiveRecord::Relation が返ります。
product = Product.preload(categorizations: :category).find(1)
product.categorizations.loaded?
#=> true(preload 済み)
product.categorizations.order(:position).loaded?
#=> false(新しい relation。列挙した時点で SQL が発行される)
未ロードの relation なので、.map を呼んだ時点で商品ごとに SQL が発行されます。
さらに問題なのは、この再クエリで取得し直した Categorization には preload(categorizations: :category) で読み込んでいた category が紐付いていないことです。ループ内で categorization.category.name にアクセスすると、categorization ごとにさらにクエリが飛びます。
商品 N 件・商品あたりカテゴリ M 件のときのクエリ数を比べるとこうなります。
| 変更前(preload が効く) | 変更後(.order で再クエリ) |
|
|---|---|---|
| categorizations の取得 | 1 | N |
| categories の取得 | 1 | N × M |
実際のログはこんな見た目になります。preload の IN クエリが出ているのに、その後で同じテーブルへのクエリが商品ごとに繰り返されるのが特徴です。
-- preload 分(ここまでは意図通り)
SELECT `categorizations`.* FROM `categorizations` WHERE `categorizations`.`product_id` IN (1, 2, 3, ...)
SELECT `categories`.* FROM `categories` WHERE `categories`.`id` IN (10, 11, 12, ...)
-- .order を付けたことによる再クエリ(商品ごと × カテゴリごとに繰り返される)
SELECT `categorizations`.* FROM `categorizations` WHERE `categorizations`.`product_id` = 1 ORDER BY `categorizations`.`position` ASC
SELECT `categories`.* FROM `categories` WHERE `categories`.`id` = 10 LIMIT 1
SELECT `categories`.* FROM `categories` WHERE `categories`.`id` = 11 LIMIT 1
SELECT `categorizations`.* FROM `categorizations` WHERE `categorizations`.`product_id` = 2 ORDER BY `categorizations`.`position` ASC
...
これは .order に限った話ではなく、where / order / reorder / limit など、relation を返すクエリメソッドをチェーンした時点で同じことが起きます。preload のキャッシュが使われるのは、ロード済みの CollectionProxy をそのまま列挙する場合だけです。
対処: Ruby 側でソートする
SQL でソートするのをやめて、Ruby 側(メモリ上)でソートするようにしました。
def categories
product.categorizations.sort_by { |categorization| [categorization.position, categorization.category_id] }.map do |categorization|
{ name: categorization.category.name }
end
end
-
preload している経路(一覧): ロード済みの配列に対して Enumerable の
sort_byが呼ばれるだけなので、追加クエリなし。変更前と同じクエリ数に戻る -
preload していない経路(詳細など): categorizations のロードに1クエリ + メモリソート。ソートキーが NOT NULL の整数カラムであれば、結果は SQL の
ORDER BYと同じになる
メモリソートを許容できると判断した根拠は、商品あたりのカテゴリ数に上限(例: 5件)があることです。ソート対象が高々数件であれば、メモリソートのコストは無視できます。逆に、件数の上限がない・大きいアソシエーションでは、この方法は取らないほうがよいです。
sort_by に切り替えるときの注意点
- ソートキーに
nilが混ざるとArgumentError(comparison of Array with Array failed)で落ちます。対象カラムが NOT NULL であることを確認しておくと安心です - Ruby の
sort_byは安定ソートではないので、同値のときの並びを保証したい場合はタイブレークのキーまで明示します(上の例ではcategorization.category_id)。これは SQL のORDER BYでも同じで、指定したキーで同値になる行の順序はもともと未保証です
採らなかった対処: アソシエーションにスコープを付ける
has_many にスコープとして order を定義する方法もあります。
has_many :categorizations, -> { order(:position) }
これなら preload のクエリ自体に ORDER BY が効くため、N+1 にはなりません。ただし、すべての categorizations / categories(through 経由)の呼び出しに影響が波及します。スコープの order は relation の先頭に残るため、別の観点でソートしたい箇所(例: product.categories.order(products_count: :desc))では、チェーンした order がタイブレーク扱いになってしまい、reorder への書き換えが必要になります。
影響範囲が読みにくくなるのを避けて、今回は「表示順が求められる読み取り口で明示的にソートする」方針にしました。
どうやって気付いたか
この N+1 は自分では気付けず、コードレビューで「一覧系のエンドポイントは preload しているので、.order に変えると preload が効かなくなるのでは」と指摘してもらいました。開発環境のログを確認したところ、実際に上記のような再クエリが商品ごとに発行されていました。
まとめ
- preload のキャッシュが使われるのは、ロード済みのアソシエーションをそのまま列挙するときだけ
-
.orderを1つチェーンしただけでも、新しい relation になって再クエリが発生し、ネストの preload も失われる - 件数上限が小さいアソシエーションのソートは、Ruby 側の
sort_byにすると preload を活かしたまま並び替えられる - 「詳細ページでは動くのに一覧で遅い」ときは、呼び出し元の preload とシリアライザ側のクエリメソッドの組み合わせを疑う
