2012年9月23日日曜日

to_sql のうまい使い方(2)

前回の記事で、通常の ActiveRecord のクエリではパフォーマンスがよくないという場合に、うまくARELのよさを生かしつつ find_by_sql で高速なクエリを実行するための方法を紹介しました。
to_sql と find_by_sql でクエリを高速化する方法としてもうひとつ適用例があります。

本題に入る前に ActiveRecord が持っているあるパフォーマンス上の問題を紹介します。もしかすると最近の rails では改善されているのかもしれませんが、確認せずに書いてしまいます。その問題とは、joins を使ったテーブルに対しては eager loading が行われない、というものです。たとえば

users = User.joins(:emails).where("emails.address like '%@gmail.com'").includes(:emails)

のようにして、gmail のアカウントを持つユーザをとってきたとします。このクエリでは includes によって emails を eager loading するよう指示していることに注意してください。

さて、先頭のユーザが softbank のアドレスも持っているかどうかを調べたいとしましょう。

users.first.emails.any?{|e| e.address =~ /@softbank.ne.jp\Z/}

この時、users.first.emails にアクセスしたときにどうなるでしょうか?includes(:emails) を指定しているのだから eager loading された結果の emails  がすでに存在するはずで、ここではクエリは発生しないはずです。ですが残念ながらそうはなりません。ここで select * from emails where user_id = xxx というクエリが発生してしまいます。いわゆる1+n問題が発生してしまうのです。しかもこれを回避するうまい方法というのもありません。preload_association がまだ生きていたころに association を強制注射してみましたが、eager loading のクエリは実行されるものの、モデルはセットされずに結局 lazy load が発生しました。

この問題に対してはいつも以下のように対処しています。

まず、前回の記事で説明したような方法を使って、select + to_sql でモデルの id のみを取得します。

ids = User.connection.select_rows( User.joins(:emails).where(...).select("users.id").to_sql )

このように、connection.select_rows を使うと id の配列が得られます。(正確には配列の配列だが)。前回は find_by_sql を使いましたが、モデルが不要な場合、select_rows などの下位APIを使って配列など生データのみ取得したほうがはるかに高速です。

そしてその後idだけをつかって

User.where(:id => ids).includes(:emails)

とするときれいなモデルが得られます。

前の記事で、ビジネスロジック上意味のある絞り込み条件(activatedなど)を定義することで再利用性が高まるということを書きましたが、これはここでも適用されます。そのような scope は最初の id を引っ張るところにうまく使い回しできて、実際に必要なデータ(カラムや関連)を取ってくるところは
二つ目のSQLで調整する、という役割分担になります。


0 件のコメント:

コメントを投稿