1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

preload 済みのアソシエーションに .order をチェーンすると再クエリになる

1
Posted at

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 とシリアライザ側のクエリメソッドの組み合わせを疑う
1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?