問題
15個ぐらいのさまざまなクエリパラメータを検索条件として受け付けることができる一覧取得API(「Item取得API」とする)があった。
そのAPIで取得するItemは複数の関連を持っていた。また、関連先の取得時にN+1問題の対策が不十分だったので、取得するデータで必要な関連先を漏れなくincludes
でeager loadした結果、ほとんどのケースでパフォーマンスが改善していた。
しかし、あるクエリパラメータ(q
とする)を使うときだけ極端にAPIのパフォーマンスが悪化するという現象が見られた。
先に結論
よく言われていることですが、関連先の取得方法が複雑になりそうなら、preload
/eager_load
で読み込み方法を明示的に指定して、意図したクエリを作るようにしましょう。
原因
元々のコードは次のようなイメージ*1:
# ItemsController def index relation = current_user.items # クエリパラメータに基づくたくさんのwhere ... if params[:q] relation = relation.where(genre: { q: params[:q] }) end # さらに続く... # 関連先を漏れなくincludes relation = relation.includes( :genre, :sub_items, user: :store, category: { user: :store, :sub_items } ) # JSONにしてrenderして終わり end
q
は関連先テーブルのカラムをWHEREの条件として用いるときのパラメータ。そのパラメータをAPIに渡してwhere
で絞り込みをかけるときだけ、関連先テーブルのカラムでWHEREすることになる。
このとき、Item
取得と同時に関連先のテーブルの情報で絞り込みをかける必要があるので、includes
はLEFT JOINを発行するeager_load
の挙動となる。すると、includes
は指定したすべての関連先を(たとえ他の関連先テーブルのカラムでWHEREするわけではなくても)LEFT JOINで読み込むことになる*2*3。
関連先のテーブルすべてをLEFT JOINすると、テーブルの構造が原因で本来取りたいデータセットの行数の3乗程度の数のデータを取得しようとして、DB側でメモリを使い尽くして*4エラーになっていた。
もう少し詳しい経緯
同じ関連先を複数回includesの引数としていた
モデルItem
の関連先どうしも関連を持っていた。たとえば、Item
が持つsub_items
やcategory
について、それらどうしやuser
へ関連を持つイメージ。これにより、includes
の引数に同じ関連先が複数回出てくることがあった:
relation.includes( :genre, :sub_items, # 1回目 user: :store, # 1回目 category: { :sub_items # 2回目 user: :store, # 2回目 } )
例えばuser
とstore
は関連元Item
のメソッドでuser.store.foo?
のようにアクセスされるのに加えて、Category#bar
でもuser.store.bar?
のようにアクセスされていた。このとき、たとえばcategory
のネストした関連先指定を省くと、Category#bar
の呼び出しが複数回あるときに都度クエリが発行されてしまうので、上のように書かざるをえない*5。
同じテーブルが複数回LEFT JOINされていた
上記のように関連先を指定していてeager_load
を使うとき、includes
への指定の仕方が2通りあることから、sub_items
は2通りの結合条件でLEFT JOINされる。細かい点は説明しないが、テーブルの構成的に、このように2回LEFT JOINすると本来取りたい行数の2乗の数データが取得されてしまっていた*6。実際の事例ではもう少し込み入った感じになっていて、実際に取りたい行数の3乗程度の行数を取得しようとしていた。
解決法
解決法は簡単で、問題となったクエリパラメータを使うテーブル以外の関連先テーブルはつねにpreload
を明示的に指定するだけ。q
での絞り込み用のカラムを持つ関連先テーブルはincludes
のままにする。
def index relation = current_user.items # ... if params['q'] relation = relation.where(genre: { q: params['q'] }) end # ... # whereで使わない関連先を漏れなくpreload relation = relation.preload( :sub_items, user: :store, category: { user: :store, :sub_items } ) # whereで使われるかもしれないのでincludes relation = relation.includes(:genre) # JSONにしてrenderして終わり end
問題のパラメータを指定しなければpreload
で読み込んでくれるし、必要なときはeager_load
になる。
今回は、テストの規模ではそこまで遅くなることに気付けなかったのと、問題のパラメータ指定時のクエリを見ていなかったのが敗因。単純なケースならincludes
のほうが考えることが少なくて便利だが、関連先の取得方法が複雑になってきたら横着せずにpreload
や(今回は使わなかったが)eager_load
で関連先の読み込み方法を明示的に指定したほうがよい。
*1:出てくるモデルや関連はすべて仮のもの
*2:https://github.com/rails/rails/blob/7b5cc5a5dfcf38522be0a4b5daa97c5b2ba26c20/activerecord/lib/active_record/relation/finder_methods.rb#L379
*3:ちなみに他のクエリパラメータだけ使うときは、関連元テーブルitemsのカラムでWHEREしており、関連先テーブルは複数のクエリで取得するpreloadの挙動になっていた
*4:数十万〜の行数を一発で取ろうとしていた
*5:必ずItemの関連から引くようにするなどより大きい範囲でコードを改善をするのがベターだが、いまはそれができないという仮定
*6:いわゆるproductとproduct variantとoptionのような構成でproductとproduct variantそれぞれにoptionをLEFT JOINしたのに近い cf. https://shopify.dev/docs/admin-api/rest/reference/products/product-variant