SmartHR Tech Blog

SmartHR 開発者ブログ

Active Recordともっと仲良くなって自然に優しいコードを書くぞ

こんにちは。SmartHRでRails顧問業をしています @willnetです。最近は主にリファクタリングをしています。

SmartHRのバックエンドは基本的にRubyで書かれています。しかし入社してくるバックエンドエンジニアは必ずしもRubyやRailsを長年使ってきた人だけではなく、前職では他言語を使っていてRuby(Rails)はほとんど使ったことがないという人もいます。

webアプリケーションを作る、という点ではどの言語でも抑えるべき点は同じですが、RubyやRailsに特化した考え方や書き方もありますよね。SmartHRではそれを効率よく習得してもらうために読書会を開催したり、社内のドキュメントツールに知見を書いて共有したりしています。

僕も社内のドキュメントツールにActive Recordの付き合い方ついて書いたところ、評判が良く「テックブログにしたら?」と言われたので今回一般公開しようと思った次第です。以下、ドキュメントツールの内容を転載したもの*1になります。

これはなに

Active Recordは便利なライブラリですが、Active Recordの気持ちにならないと無駄なオブジェクトを生成してしまったり、無駄なクエリを発行してしまったりと自然(メモリやDB)に優しくないコードを実行してしまいます。この文章は、Active Recordがどのように動くかを理解して、もっとエコなコードを書けるようにすることを目的にしています。

前提知識

クエリが発行されるタイミングを理解する

Active Recordは、そのデータが本当に必要になったときにはじめてクエリを発行するようになっています(遅延実行)。例えば次のようなコードはクエリを発行しません。

class UsersController < ApplicationController
  def index
    @users = User.where(status: 'active')
  end
end

ビューでインスタンス変数を評価するタイミングではじめてクエリが発行されます。

<% @users.each do |user| # ここでクエリが発行される! %>
  <%= user.name %>
<% end %>

遅延実行により、例えばビューで@user.eachがなくなった場合に無駄なクエリを発行することがなくなります(そもそもクエリを実行しないActiveRecord::Relationオブジェクトを作るのはやめたほうがいいですが)。

@usersに入っているオブジェクトはActiveRecord::Relationというクラスのオブジェクトで、eachなどのメソッドを実行したときにクエリを発行します。クエリを発行して得られたモデルオブジェクトの配列は、ActiveRecord::Relationオブジェクト内の@recordsに格納され、かつクエリを発行したか否かのフラグが@loadedで管理されています。これにより上記の@users.eachを複数回実行したとしても、クエリが発行されるのは1回だけですむようになっています。

もし任意の時点で@usersに対応するクエリを発行したいぞ、というのでれあればloadメソッドを使うとそのタイミングでActiveRecord::Relationオブジェクトの@recordsにクエリ結果が格納されます。モデルの配列が欲しい場合はto_aメソッドが使えます。

注意点

rails console環境だとUser.where(status: 'active')でもクエリを発行します。これはIRB(pry)では戻り値をレシーバとしてinspectメソッドを実行し、得られた文字列を表示するという仕様があるからです。ActiveRecord::Relationオブジェクトはinspectメソッドを実行するとクエリを発行します。コード的にはこのあたり

Active Recordのオブジェクトは重い

Active Recordについて考えるときには、クエリの内容や発行回数だけでなくActive Recordオブジェクトそのものの生成も抑える必要があります。

Active Recordのオブジェクトはそれなりに大きいので、たくさん作るとメモリを大量に消費します。しかし、具体的な数値がないと想像しづらそうなので手元で試してみました。環境はRuby 3.0.2、Rails 6.1.4.1です。

development環境ではconfig.eager_loadがfalseであり定数の自動読み込みが有効なので、サーバ立ち上げたタイミングと、なにかしらのコントローラやモデルにアクセスしたときでメモリ消費量が変わってしまいます。これだとメモリ消費量を計測しづらいのでconfig.eager_loadをtrueにしています。ついでにconfig.cache_classesもtrueにして定数のリロードも発生しないようにしています(本来はRAILS_ENVをproductionで動かすと開発用のgemなどが除外された状態になり、さらに正確に計測できるのでオススメです。今回は簡易的に2つの設定をproductionに寄せるだけにしています)

rails new activerecord-is-heavy
cd activerecord-is-heavy
bin/rails g scaffold user name
bin/rails db:migrate
rails runner '100000.times { |i| User.create!(name: "なまえ#{i}") }'
code config/environments/development.rb # ↓の二行の設定をtrueに変更しておく
# config.cache_classes = true
# config.eager_load = true
rails s

↑でメモリ消費量を調べた所、RSSの値は次のようになりました。

  • rails s直後 93724
  • localhost:3000 にアクセスした 97448
  • localhost:3000/users にアクセスした 239976

10万のかんたんなARオブジェクトを作ると140MBほど消費するようです。

たくさんのオブジェクトを作っても、その後GCされるので問題ないのでは?

問題はあります。たくさんのメモリを確保した後にそれがGCされても、Ruby(正確にはglibcのメモリアロケータ)はほとんどOSにメモリを返しません。また、確保したメモリ中にまばらにオブジェクトが配置されるメモリの断片化も発生します。これらの事象の結果として、実際に使っているメモリよりも、遥かに大きい容量を確保した状態になってしまいます。なので一度にたくさんのメモリを使用するのはなるべく避けましょう。

くわしくは この記事(英語)が参考になります。

実例

ここからは具体的なプラクティスを書いていきます。

find_each を使おう

たくさんのActive Recordのオブジェクトを扱うときには、一度にすべてをロードするのではなく、少しずつロードして処理するようにするのがメモリの節約になります。Railsにはそのためのメソッドとしてfind_eachが用意されています。

次のようにすると、Personを1000件取得するクエリを発行してブロック内を1000回実行(もし1000件以上レコードがある場合)し、また1000件取得するクエリを発行してブロック内を実行する、となります。この方式だと適宜GCが実行されるので、Rubyは一度にたくさんのメモリを確保する必要がありません。

Person.find_each do |person|
  person.do_awesome_stuff
end

バッチ処理など、一度にたくさんのオブジェクトを扱わざるを得ないときにはfind_eachを使って少しずつモデルオブジェクトを取得しましょう。

length, size, countの違いを理解して使い分ける

対象となるモデルの数を数えるメソッドとして、length, size, countがあります。これらはすべて挙動が異なり、使い方によってクエリ発行回数が変わるのですべての挙動を覚えておくことが望ましいです。

user = User.where('age > ?', 18)
user.count #=> 常にクエリが発行される(select count(*) from users where ...)
user.size #=> ロード済みであればその数を返し、未ロードであればcountと同じくクエリを発行する
user.length #=> 未ロードであればロードして、その数を返す

大まかには次のような戦略を取るのが良いでしょう。

  • 件数だけが取得できればよく、モデルオブジェクトが不要→count
  • このあとモデルオブジェクトの配列を使用するので、無駄にクエリを発行したくない→length
  • ケースバイケースでいろんな使われ方をするので柔軟に対応しておきたい→size
# lengthの例
@post = Post.find(params[:id])
@comment_count = @post.comments.length # ここでCommentモデルがloadされる
@post.comments.each { ... } # Commentモデルはload済みなのでここではクエリが発行されない

sizeやlengthを使うときの注意事項として「レコード数ではなくオブジェクトの数を返す」ということがあります。次のようにhas_manyの関連の先でbuildを使うと、レコードとしては保存されていない状態のモデル数がsizeやlengthに反映されます。

invitation = current_user.invitations.build
current_user.invitations.size    # => 1
current_user.invitations.length # => 1
current_user.invitations.count  # => 0

もし、レコードとして保存されているモデルの数を知りたい場合はcurrent_user.invitations.countでクエリを発行するか、current_user.invitations.count {|invitation| invitation.persisted? }のように、モデルに保存状態を問い合わせて数を数える必要があります(後者のcountはActive RecordのcountメソッドではなくてEnumerable#countです。)。

要素が存在するかどうかを調べる

対象となるモデル(レコード)が存在するかどうか知りたい、というケースで使えるメソッドはたくさんあります。上で紹介したlength, size, countが0かどうかチェックする方法の他に、次のようなメソッドが使えます。

  • present?
  • blank?
  • any?
  • empty?
  • none?
  • exists?

それぞれ挙動が違うので注意が必要です。基本的な挙動は先程のlength, size, countと同じなので3つに分類します。

  • lengthタイプ→present?, blank?
  • sizeタイプ→any?, empty?, none?
  • countタイプ→exists?

また、sizeタイプであるempty?が英語的に適切だけどselect count(*)はしたくない、という場合にload.empty?とすることもあるようです。多彩な書き方がありますね…(覚えるのが大変)。

ref: "empty?" docs: Suggest "load.empty?" over "length.zero?" [ci skip] · rails/rails@9a00d89

僕はお仕事柄いろんな会社のコードを見るのですが、モデルが存在するかどうかだけわかれば良くて、モデル自体は不要なケースでもPost.find_by(...)のようにモデルを返すメソッドが使われているのをよく見かけます。少量のモデルオブジェクトを無駄に作ってもサービスの速度に影響はないのですが、塵も積もれば山となる、の精神で気にしていってもらえると嬉しいなあ、と個人的に思っています。

Active Recordオブジェクトを生成せずに値だけ取得する

ユーザの名前だけほしい、というときにUserオブジェクトを生成するのはメモリの無駄です。Active Recordにはpluckというメソッドがあり、これはモデルオブジェクトを生成せずに引数のカラム情報だけを取得してくれるので、特定のカラムの値だけほしいときは積極的に活用しましょう。

Person.pluck(:name)
# SELECT people.name FROM people
# => ['David', 'Jeremy', 'Jose']

Person.pluck(:id, :name)
# SELECT people.id, people.name FROM people
# => [[1, 'David'], [2, 'Jeremy'], [3, 'Jose']]

サブクエリを利用する

公開している(openedカラムがtrueである)投稿に紐付いているコメントを全件取得するような処理はどのように書くと良いでしょうか。例えば次のように書くことができます。

Post.where(opened: true).map(&:comments)

しかし今回のケースではPostのオブジェクトは不要だとします。先程紹介したpluckを使うとどうでしょうか。

Comment.where(post_id: Post.where(opened: true).pluck(:id))

これでPostオブジェクトが生成されなくなりました。しかしPostのpluckとCommentの取得で2回クエリが発行されてしまいます。次のように書くと1回のクエリでCommentを取得でき効率的なので活用していきましょう。

Comment.where(post_id: Post.where(opened: true).select(:id))
#=> SELECT "comments".* FROM "comments" WHERE "comments"."post_id" IN (SELECT "posts"."id" FROM "posts" WHERE "posts"."opened" = "true")

eager loadingでN+1を避ける

次のようなコードを書くと、最大で11回のクエリが発行されます。これをN+1問題といいます。N+1はパフォーマンスに悪影響を与えるため、気付き次第修正する必要があります。

class Post < ApplicationRecord
  belongs_to :auhtor, class_name: 'User'
end

posts = Post.limit(10)
author_names = posts.map do |post|
  post.auhor.name # ここで毎回クエリが発行される
end

RailsにはN+1を回避するためのメソッドが用意されています。

posts = Post.includes(:author).limit(10)
author_names = posts.map do |post|
  post.auhor.name
end

このように書くと、事前に必要なクエリを投げてくれます。今回のケースではPostとUser(author)それぞれに1回ずつクエリが発行されます。

"事前に必要なクエリを投げる"方式は2種類あります。preload方式とeager_load方式です。どちらを使っても結果(事前に関連先をロードしてくれる)は一緒ですが発行されるクエリが異なります。より効率の良いクエリを選択したいですね。RailsはN+1回避用のメソッドとしてincludesメソッドを紹介していますが、これは状況に応じてpreload方式とeager_load方式を半自動的に切り替えるメソッドです。includesを使う場合は、それがどちらの形式になっているか把握しつつ使いましょう。明示的にpreloadもしくはeager_loadメソッドを使っても良いです。

includes, preload, eager_loadの違いについては↓が参考になります(Rails 4.1のころの記事ですが今でも有効です)。

ActiveRecordのjoinsとpreloadとincludesとeager_loadの違い - Qiita

個人的には「基本的にpreloadを使い、JOINが必要なときだけeager_loadにする」という戦略をとっています。ほとんどのケースではこれで問題ないと思っています(が、違うぞ!という意見あったら教えて下さい)。

N+1を検出する

N+1は避けたほうが良い、というのがわかっていてもN+1ができてしまうことはよくあります。作ってしまったN+1を検出して修正していきましょう。

例えば本番環境でNewRelicなどのAPM(Application Performance Management)ツールを使うと、実際に本番環境でどのようなクエリが発行されているのかを見ることができます。ここからN+1になっているところを見つけて直す、というのはよくやります。

しかしできれば本番環境にデプロイする前に見つけたいですよね。開発環境でデータがそれなりにある状態でlog/development.logを眺めると、N+1が起きている箇所で同じクエリがたくさん発行されているのを見てとれます。とはいえデータを用意するもの大変だし、ログをみるのもめんどくさいので自動で検知してほしい!という場合はbulletというgemが便利です。設定すると、N+1が起きていそうな箇所で通知をしてくれます。が、100%検知できるわけではない(のと、検知したけど実際にはN+1ではないケースもある)ので、補助的に使うと良いでしょう。

Rails6.1からは、includes(やpreloadeager_load)なしで関連の関連を呼び出すとエラーにする機能が入っています。これも検討の余地があると思います。

ref: 「Rails 6.1で新しく入る機能について」iCARE Dev Meetup #12 の登壇内容 https://icare.connpass.com/event/183716/

もともとN+1がなかったところや、N+1を解消したはずのところにN+1が入ってしまう、ということもあります。これを防ぐためにテストでN+1がないことを確認することができます。n_plus_one_controlというgemを使うと、条件を変えて複数回テストを実行し、発行されるクエリの回数が同じであることを保証してくれます。興味がある人はgemのREADMEを眺めてみてください。

さいごに

社内のドキュメントツールには、この他にも有益な知見がたくさん掲載されています。それを読んでみたい!と思った方は下記の採用サイトからお気軽にエントリーください(\( ⁰⊖⁰)/)

hello-world.smarthr.co.jp

*1:そのままの転載ではなく、公開にあたって細かい文章の見直しがたくさん入っています