ActiveRecord の select メソッドで SQL 発行回数を減らす

こんにちは、ryohei515です。

Ruby on Rails で、ActiveRecord::QueryMethodsselect メソッドがありますが、使ってますか?
私はあまり使ってませんでした。
というのも、大抵のケースで pluck を使うほうがパフォーマンス的にメリットが大きいと思い、そちらを多く使っていたからです。
しかし、最近 select を使ったほうがメリットがあるケースもあることを知ったので、ご紹介します。

select の基本的な使い方

例えば、本を検索するには

books = Book.where('title like ?', "%#{query}%")
=> #<ActiveRecord::Relation [#<Book id: 1, title: "hoge", created_at: ...>, #<Book id: 2, title: "fuga", created_at: ...>]

みたいに取得できます。

しかし、業務では以下のパフォーマンスの懸念より、この書き方を避けることができないかを考えることが多いです。

  1. ActiveRecord のインスタンスの生成コスト
  2. 取得したデータのメモリ使用量
  3. 不要な項目のDBからの取得(SQL実行コスト)

そこで、必要な項目だけを2次元配列で取得できる pluck を使うことを考えます。
これなら上記の懸念全てに対し効果があり、必要最低限の処理にできるためです。

books = Book.where('title like ?', "%#{query}%").pluck(:id, :title)
=> [[1, 'hoge'], [2, 'fuga']]

ただ、Book のインスタンスメソッドを使いたいといったことがあったりします。 pluck だと Array となってしまいそれが満たせないので、代わりに select を利用することを考えます。
select を使えば、ActiveRecord のインスタンスの生成コストはあるものの、SQL の段階で不要な項目を DB から取ってこないようにできるので、全体的なコストはselectなしのときより落とせます。

books = Book.where('title like ?', "%#{query}%").select(:id, :title)
=> #<ActiveRecord::Relation [#<Book id: 1, title: "hoge">, #<Book id: 2, title: "fuga">]

この程度の認識しかなかったため、パフォーマンス優位であろう pluck を積極的に使い、select を使うことはあまりありませんでした。
しかし、select のほうがパフォーマンス的にも優位になる場合があることを知ったので、以下に記載します。

where の対象を select で設定する

例えば図書館のアプリとして、以下のようなテーブル構成があったとします。 returned_date が返却日を示しており、1冊の本がレンタルされた履歴は全て rentals に保存されます。

ここで、「過去に1度だけレンタルがされたことがある本」を取得したいとなったとします。

SQL における HAVING で集計結果を抽出条件とすることができるため、これを使いつつ ActiveRecord で取得しようとすると、以下のような書き方をしていました。

books = Book.where(id: Rental.group(:book_id).having("count(*) = 1").pluck(:book_id))

該当の book_id の一覧を pluck で配列として取得し、それを抽出条件に使う形です。
しかし、SQL的には book_id の一覧取得と books の取得により、2回実行する結果になってしまいます。

SELECT
  "rentals"."book_id"
FROM
  "rentals"
GROUP BY
  "rentals"."book_id"
HAVING
  COUNT(*) = 1
;

-- 取得結果が [1, 2, 3] だったすると
SELECT
  "books".*
FROM
  "books"
WHERE
  "books"."id" IN (1, 2, 3)
;

SQL で書くなら1度の SQL で以下のように書けるのにと思っていましたが、

SELECT
  "books".*
FROM
  "books"
WHERE
  "books"."id" IN (
    SELECT
      "rentals"."book_id"
    FROM
      "rentals"
    GROUP BY
      "rentals"."book_id"
    HAVING
      COUNT(*) = 1
  )
;

pluckselect に変えるだけで、上記の SQL が実行され、1度のみ実行を満たすことができます!

book_ids = Book.where(id: Rental.having("count(*) = 1").group(:book_id).select(:book_id))

ActiveRecord::Relation のオブジェクトは実際に中身が必要になるまでは DB アクセスしない(遅延評価)ため、pluck だと Array で取得する必要があるから即時に実行されるが、select ならばその時点での実行を待ってくれてこのような SQL を組み立ててくれるのだと思います。

終わりに

whereActiveRecord::Relation を渡すことができることを知らなかったです。
ただ複雑な SQL だったりすると、1度にまとめることが必ずしもパフォーマンス向上につながらないこともあるので、どちらが有効かを比較して活かしていきたいなと思いました。

インゲージではエンジニアを募集中です!