ykamezの技術ブログ

日々学んだことをアウトプットしていきます。Ruby/Rails/グロース/分析/施策提案

ActiveRecord各メソッドのクエリ実行タイミングについて

概要

ActiveRecordの各検索メソッドが実際にクエリを投げ、データベースにアクセスするタイミングについて調べてみた。

書こうと思ったきっかけ

ActiveRecordのどの検索メソッドがDBへのリクエストを発行し、どの検索メソッドがActiveRecord::Relationのオブジェクトを返すのか不明だったので、改めて調べてみた。

結論

まずはじめに結論から書くと、以下の二種類のメソッドに大別される。

  • ActiveRecord::FinderMethodsに実装されているメソッド(find, find_by, take, first, last, exists?) すぐにクエリを発行し、データベースにアクセスし、レコード(Modelのインスタンス or インスタンスの配列)を返す。

  • それ以外の検索メソッド(where, limit, など) ActiveRecord::Relationのオブジェクトを返し、実際にデータが必要になるタイミングまでデータベースにはアクセスしない。遅延評価(Lazy Evaluation)である。

ドキュメント: ActiveRecord::FinderMethods

ソースコード: rails/finder_methods.rb at master · rails/rails · GitHub

そもそもActiveRecord::Relationとは?

クエリを生成するための情報を保持し、メソッドチェーンでつなげることができるため、再利用性が高く、便利なものになっている。 一方で、理解して使わないと、意図していないクエリを組み立ててしまい、パフォーマンスの悪化などを招くこともある。

内部実装としては、arelというライブラリをSQLの生成に用いている(元々は外部のライブラリとして開発され、取り込まれた。)

具体例: はまりがちなトラップ

limitとtakeの使い分け

上でも述べたようにtakeはActiveRecord::FinderMethodsのメソッドであり、データベースにアクセスを行い、レコードを取得する。 そのためlimitと同じようなものだと思って使うと誤った挙動を招くことがある。

rails consoleで実行してみると、limitはデータベースにアクセスをせず、takeはデータベースにアクセスをしていることがわかる。 (※メソッドの後に;をつけることで、即時評価されてしまうことを防いでいる。)

> Book.limit(3);
> Book.take(3);
  Book Load (0.5ms)  SELECT  "books".* FROM "books" LIMIT $1  [["LIMIT", 3]]

例えば以下のようなケースを考えてみる。 2019年4月1日以降に作成されたレコードを更新日時順が新しい順に1000件取得し、さらにそのレコードを分析したいという要件があった時、以下のように書くことで再利用性を高めることができ、limitメソッドを使っているため、実際に評価されるまで、クエリは実行されない。

rel  = Book.where('created_at > ?', "2019-04-01").order(created_at: :desc).limit(1000);

しかし、以下のようにtakeを使ってしまうと、その段階で内部でto_aされてしまうため、クエリが実行されてしまうのと、ActiveRecord::Relationではなくなってしまうため、メソッドチェーンが利用できなくなってしまう。

rel  = Book.where('created_at > ?', "2019-04-01").order(created_at: :desc).take(1000);

もちろん要件によっては、limitではなく、takeを使ったほうが正しいケースもあるが、両者の違いを認識して、正しく使う必要がある。

参考にした記事: - ActiveRecord::QueryMethods#limitとActiveRecord::FinderMethods#take - Qiita

(補足)takeのソースコード

find_take_with_limitの中で、to_aが呼ばれており、このタイミングで評価され、データベースにアクセスしていることがわかる。

    def take(limit = nil)
      limit ? find_take_with_limit(limit) : find_take
    end
(中略)
      def find_take_with_limit(limit)
        if loaded?
          records.take(limit)
        else
          limit(limit).to_a
        end
      end

Ref: rails/finder_methods.rb at master · rails/rails · GitHub


まとめ

ActiveRecordは便利だが、挙動を勘違いして使うと無駄なアクセスが走り、パフォーマンスを低下させる恐れがある。 具体例に関しては、他にも例があげられそうなので、業務などで詰まることがあったら更新していきたい。

ActiveRecord周りの話はパフォーマンスにも大きな影響を与えるので、引き続き調べていきたい。 調べていく過程で面白かった記事も紹介しておく。

参考にした記事