ボクココ

個人開発に関するテックブログ

Rails での N+1 の見つけ方と dependent 指定の選択に関して

ども、@kimihom です。

Rails アプリを作っていて「遅くなったな」と感じる場合、真っ先に原因となりうるのが N+1 なコードを書いてしまっていることだろう。その時の理想の対応は何か、考える機会があったのでブログとしてまとめておく。

モデル例

まずわかりやすいようにこういうクラスを作ったとしよう。

**Company**
id
name

has_many :users, dependent: :delete_all | destroy | nullify

**User**
id
name
company_id

belongs_to :contract

実際に使う時のサンプルコード

company = Company.create(name: "サンプル株式会社")
user = company.users.create(name: "斎藤太郎")

N+1 の概念

今日の内容は N+1 問題なので、まずこれに関して説明しよう。上記の関係があったときに、たとえば以下のようなコードを書いたとしよう。

users = User.all

users.each do |u|
  puts users.company.id
end

# User Load (0.6ms)  SELECT "users".* FROM "users"
# Contract Load (1.1ms)  SELECT "contracts".* FROM "contracts" WHERE "contracts"."id" = $1 LIMIT $2  [["id", 1], ["LIMIT", 1]]
# Contract Load (1.1ms)  SELECT "contracts".* FROM "contracts" WHERE "contracts"."id" = $1 LIMIT $2  [["id", 1], ["LIMIT", 1]]
# Contract Load (1.1ms)  SELECT "contracts".* FROM "contracts" WHERE "contracts"."id" = $1 LIMIT $2  [["id", 1], ["LIMIT", 1]]
# Contract Load (1.1ms)  SELECT "contracts".* FROM "contracts" WHERE "contracts"."id" = $1 LIMIT $2  [["id", 1], ["LIMIT", 1]]

このコードだけで NG というのは、プログラマー初期のエンジニアだと気付きにくいだろう。上記のように"明らかに"そう見えるのは実際には少ない。さらに厄介なのは、ローカル環境だとデータがほとんどないがために、見つけづらいということがある。

該当のコードがこっそり隠れていたりする。たとえば Rails の Helper の奥底に書いてあったりすると、これかよ!って突っ込みたくなるだろう。

この場合の解決方法は以下となる。

users = User.all.includes(:company)

users.each do |u|
  puts users.company.id
end

#  User Load (4.1ms)  SELECT "users".* FROM "users"
#  Company Load (1.4ms)  SELECT "companies".* FROM "companies" WHERE "companies"."id" = $1  [["id", 1]]

上記の件に限っていうと、実はもっとシンプルなコードがある。既に外部IDはその User テーブルに保存しているのである。 id だけ欲しいという場合に限定的に使えるものとなる。

users = User.all

users.each do |u|
  puts users.company_id
end

#  User Load (4.1ms)  SELECT "users".* FROM "users"

さて、最初のうちは「こんなの気付きづらいよ」と思う方が多いだろう。そのために N+1 を自動で検出してくれる gem がもちろんあるので、紹介しておこう。

Bullet

これを使うと、ローカルで起動した時にログとして以下のように記載される。

user: honkimi
GET /users
USE eager loading detected
  User => [:company]
  Add to your query: .includes([:company])
Call stack
  ***/user.rb:98:in `gen_id'

dependent の記載に関して

一番最初のモデルにて、以下の記述をしておいた。

has_many :users, dependent: :nullify | :delete_all | :destroy

これはそれぞれ動作が全く異なってくるので、しっかりと理解しておきたい項目となる。この dependent の定義は、"Company を消した場合、それに紐づく User をどうする?" の対応となる。

では以下のケースを考えてみよう。

company = Company.create(name: "サンプル株式会社")
user = company.users.create(name: "斎藤太郎")
user2 = company.users.create(name: "斎藤次郎")

dependent 自体を指定しなかった場合

User は消されずに残り、id はかつて存在していた company id が残る。

  • user.company_id は 1 が返ってくる。
  • user.company は nil が返ってくる。

destroy の場合

User は全て削除される。それぞれの User で定義していた before_destroy などのモデルで定義したコールバックが呼ばれる。 削除するときに1つ1つのユーザーでこの before_destroy で定義したメソッドが呼ばれるので、それに紐づくユーザー数が1000人いたらどうなるか、想像しておこう。 時間のかからない処理をコールバックでさせることが必須であることを覚えておこう。決して、どこかの Web APIを呼んで削除させる とかやらないように気をつけよう。

class User
  before_destroy :delete_info

  def delete_info
    # ..
  end
end

delete_all の場合

User 全て削除される。before_destroy などのモデルで定義したコールバックは呼ばない。当然挙動は速くなるけど、本来やりたかった コールバック処理が呼ばれないという認識がないと問題を起こしがちである。

ちゃんと理解した上で delete_all を選択するようにしよう。

nullify

User の contract_id は nil として登録される。

  • user.company_id は nil が返ってくる。
  • user.company は nil が返ってくる。

この場合、該当するユーザーのそれぞれの company_id を nil にアップデートする処理が必要となる。これも、ユーザー数が10万超えてくるくらいで、急に「なんか削除処理でタイムアウトするぞ・・・」ということが発生してくる。

終わりに

Rails アプリを軽くスピードのあるものにしていくのに必須と言えよう。

それなりにユーザー数が増えてくると、否応なしにこの問題が出てくる。何かしらで契約している監視サービスから通知が来るようになる。その時に "ヒ〜〜" となるのではなく、冷静に対応できるようにしておこう。