ども、@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 がもちろんあるので、紹介しておこう。
これを使うと、ローカルで起動した時にログとして以下のように記載される。
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 アプリを軽くスピードのあるものにしていくのに必須と言えよう。
それなりにユーザー数が増えてくると、否応なしにこの問題が出てくる。何かしらで契約している監視サービスから通知が来るようになる。その時に "ヒ〜〜" となるのではなく、冷静に対応できるようにしておこう。