zenet_logo

-株式会社ゼネット技術ブログ-

N+1を回避したのに性能が悪い!? includes/preload依存の落とし穴と解決策

はじめに

システム事業部堤田です。

Kaigi on Rails 2025に参加してきました。1カ月以上経過していますが、発表の中で最近自身も似たような経験をしたなという内容があった為、その内容の紹介と共に、自身が体験した事象の振り返りをしたいと思います。まずは、発表内容の紹介です。

タイトルは「そのpreloadは必要?見過ごされたpreloadが技術的負債として爆発した日」です。

kaigionrails.org

内容は弊社ブログにて、他の社員が紹介しておりますので、そちらを参照ください。

【下書きプレビュー】 初めてのKaigi on Rails2025に参加したことで意味はあったのか - Zenet Tech Blog > 面白かった発表:そのpreloadは必要?見過ごされたpreloadが技術負債として爆発した日

こちらの発表を聞きながら、先述した通り私の方でもサーバ障害を引き起こしかねない状況に遭遇したことを思い出しました。同じく、N+1関連の処理とその具体的な処理を考慮せずに実装した為、発生したことだったということもあり、せっかくの機会ですので、私が体験した事象も共有しようと思います。このことが、今後の誰かの糧になれば幸いです。 表示数を減らす

N+1問題を解消しようと雑にincludesを用いた際に起こった事象とそれだけでは解決できないメモリ負荷、処理時間への影響について

先に事象の経緯と結論をまとめた内容は下記となります。

  • 事象の経緯
    • N+1問題は解消できているのに、処理が遅く、削除処理がいつまでたっても終わらない。
    • dependent: :destroy 利用時のdestroyメソッド実行時の処理速度の悪さに気づく。
    • destroy_alldelete_all処理での実行されるSQLの違いを理解する。
  • 結論
    • 削除処理をdelete_allで行うことで10分以上かかっていた処理が一瞬で終わるように改修できた。

問題の処理を説明する前に関係するテーブルとそのER図を紹介します。簡単にイメージできれば良いので、中身については触れていません。1対多の関係にあるテーブル構成でdependent: :destroyのオプションがついているというところが肝となっています。

関連モデル

  • UserBlogに対して1対多のアソシエーションを持つ。一人のUserは複数ブログを執筆するというイメージ。dependent: :destroyとなっており、Userがdestroyされると、紐づくBlogもdeleteされる。
  • BlogCommentに対して1対多のアソシエーションを持つ。一つのBlogは複数のコメントがあるというイメージ。dependent: :destroyとなっており、Blogがdestroyされると、紐づくCommentもdeleteされる。

この時に、Userが持つ全てのBlogの全てのCommentを削除しようとしてその事象は起きました。下記に問題のコード(1)と、N+1問題を解消するためのコード(2)、最終的なコード(3)を記載します。

    user.blogs.destroy_all # 1. 元の処理(N+1問題がある)
    user.blogs.includes(:comments).destroy_all # 2. N+1問題は解消したが、実行速度に問題のあるコード
        
     # 3. 最終的なコード
    Comment.where(:blogs => user_ids).delete_all
    Blog.where(:id => user_ids).delete_all

やりたいこととしては、Userに紐づくBlogとそのBlogに紐づくCommentを数珠繋ぎのようにして全て削除したいというための処理でした。 N+1の問題と供に、dependent destroyとdestroy_all、delete_allの違いが影響します。

1の問題点

user.blogs.destroy_all の問題点は純粋に、N+1問題があることです。実際にこの処理を実行すると下記のようにSQLが発行されます。

  TRANSACTION (0.3ms)  BEGIN
  Blog Load (1.5ms)  SELECT `blogs`.* FROM `blogs` WHERE `blogs`.`user_id` = 1
  Comment Load (1.9ms)  SELECT `comments`.* FROM `comments` WHERE `comments`.`blog_id` = 1
  Comment Destroy (1.1ms)  DELETE FROM `comments` WHERE `comments`.`id` = 3
  Comment Destroy (0.5ms)  DELETE FROM `comments` WHERE `comments`.`id` = 4
  Blog Destroy (0.7ms)  DELETE FROM `blogs` WHERE `blogs`.`id` = 1
  Comment Load (0.5ms)  SELECT `comments`.* FROM `comments` WHERE `comments`.`blog_id` = 2
  Comment Destroy (0.5ms)  DELETE FROM `comments` WHERE `comments`.`id` = 5
  Blog Destroy (0.6ms)  DELETE FROM `blogs` WHERE `blogs`.`id` = 2
  Comment Load (1.3ms)  SELECT `comments`.* FROM `comments` WHERE `comments`.`blog_id` = 3
  Comment Destroy (1.6ms)  DELETE FROM `comments` WHERE `comments`.`id` = 1
  Comment Destroy (1.0ms)  DELETE FROM `comments` WHERE `comments`.`id` = 2
  Blog Destroy (0.8ms)  DELETE FROM `blogs` WHERE `blogs`.`id` = 3
  TRANSACTION (0.5ms)  COMMIT

コミットまでの処理で実行されたクエリを整理すると、下記のような結果となり、SELECTの回数がblogs 1回に対して、commentsが N回で 見事にN+1を達成させています。

Model SELECT DELETE
User - -
Blog 1 N
Comment N N * M

※transactionは1 begin, 1 commit

私が最初に用意したコードはこちらのコードでした。ただ、rubocopにかけるとN+1問題があると指摘されまして、修正した版が2つ目のコードでした。

2の問題点

これでN+1問題が解消したと思ってよしよしと思いながらコミットしていたのですが、問題に気付いたのは負荷性能試験で大量のデータを用いてこのコードを実行した時でした。実行にめちゃくちゃ時間がかかったのです。

実は、1つ目のコードでも同じことが言えるのですが、こちらの問題点はデータ量が増加するにつれ、処理時間がとてつもなく長くなることです。 N+1問題は回避出来ていますが、dependent: :destroyを付けていると、blogsのdeleteが、blogsの数だけ実行され、commentsのdeleteについては、blogsの数 * commentsの数だけ実行されてしまいます。blogsがN回、commentsのdelete処理がN * M回実行されるようになっていることがログから分かります。

  Blog Load (0.6ms)  SELECT `blogs`.* FROM `blogs` WHERE `blogs`.`user_id` = 9 # BLOG SELECT 1Comment Load (0.4ms)  SELECT `comments`.* FROM `comments` WHERE `comments`.`blogs` IN (107, 108, 109, 110, 111) # COMMENT SELECT 1回

  TRANSACTION (0.2ms)  BEGIN  # この塊のTRANSACTION処理が、N回実行される
  Comment Destroy (0.8ms)  DELETE FROM `comments` WHERE `comments`.`id` = 90 # COMMENT DELETE M回
  Comment Destroy (0.8ms)  DELETE FROM `comments` WHERE `comments`.`id` = 91 # 
  Blog Destroy (0.6ms)  DELETE FROM `blogs` WHERE `blogs`.`id` = 107 # BLOG DELETE 1回
  TRANSACTION (7.6ms)  COMMIT  # 
  ...
Model SELECT DELETE
User - -
Blog 1 N
Comment 1 N * M

※ transactionは1 begin, N commit

この時、サンプルで取った実行結果の平均では、CommentとBlogのdelete1回に約0.7msずつ、TRANSACTION処理(BEGINとCOMMMIT)に、約12.5msで実行されていました。式にまとめると実行にかかる秒数は (0.7ms + 12.5ms) * N + 0.7ms * M * N として計算できます。 NとMが少ない場合は気になりませんが、データの桁数が増えてくると実行時間が爆増することになることが分かります。雑に計算してBLOGとCOMMENTの件数がどちらも100程度であれば、約10秒ほどで処理が終わりますが、100,000程度になると10万秒かかるようになります。

1の問題の後、rubocopから受けた指摘を修正して、N+1問題は解消できたのでもう大丈夫だと思っていましたが、実際の処理を考えるとN+1問題以外にも考慮するべき点があったという教訓でした。N+1問題さえ解消していれば良いというところと、rubocopの指摘さえ解消していれば良いという両方の部分があり、実際の動きについて検討すらしていませんでした。

このことから、実感としてデータ量が少ないテーブル(1:多)に対してdependent destroyを付けるのは処理性能・時間の観点からすると、避けるべき実装なのだということが分かりました。 1:1のテーブルや1:多でも、紐づく数に制限があるのであれば、destroyの際のコールバック処理が有効に作用しますが、紐づくデータ量に際限がないのであれば、dependent destroyのオプションを付けることは、十分にメリットとデメリットを考慮した上で行うべきだということが分かります。

3の場合

1,2の問題点を考慮してそれぞれのモデルのデータをdelete_allで削除するということにしたというのが、最終的な結論に至りました。 delete_allで削除するようにすれば、N+1問題も回避できて、SELECTもDELETEも1回ずつとなり、非常に高速に処理が終わります。

 Blog Pluck (0.5ms)  SELECT `blogs`.`id` FROM `blogs` WHERE `blogs`.`user_id` = 9
 Comment Delete All (7.3ms)  DELETE FROM `comments` WHERE `comments`.`blogs` IN (117, 118, 119, 120, 121)
 Blog Delete All (8.1ms)  DELETE FROM `blogs` WHERE `blogs`.`id` IN (122, 123, 124, 125, 126)
Model SELECT DELETE
User - -
Blog 1 1
Comment 0 1

※ transactionは0 begin, 0 commit。TRANSACTIONが必要であれば別途明示的に用意する。

では、1対多のテーブルの削除方法として、常にdelete_allでよいのかというとやはりそうではありません。 delete_allでの削除となりますと、当然、削除時にActiveRecordによるbefore_destroyafter_destroyなどのdestroyのcall backが効きません。

ですので、実装の際は、call backの利便性を取るか、処理性能を取るかという2択を迫られるというものになります。実際問題、処理性能に問題がある場合は利便性は無視せざるを得ない状況がほとんどだと思われますが。。そのような場合には、バッチ処理などを利用して処理速度と利便性の両立を目指す、という動きになるかと思います。エンジニアの力量(と時間)が問われますね。

補足 dependent: :delete_allについて

このオプションを見つけた際は、has_manyアソシエーションに対して設定していると、destroyで削除した際に、belongs_toの関係にあるモデルの削除対象のidをいい感じに集約して3の場合のように、delete_allしてくれるのかな?と期待していましたが、残念ながらそうではありませんでした。しかし、dependent: :destroyに比べると速度は向上していましたので、どこかで活用できるポイントはありそうです。

dependent: :delete_allのオプションを利用している場合、User:Post(1:多), Post:Comment(1:多)の関係があった際に下記のようになりました。User.first.blogs.includes(:comments).destroy_allを実行した際のクエリログです。

  TRANSACTION (0.2ms)  BEGIN
  User Load (0.6ms)  SELECT `users`.* FROM `users` ORDER BY `user`.`id` ASC LIMIT 1
  Blog Load (0.5ms)  SELECT `blogs`.* FROM `blogs` WHERE `blogs`.`user_id` = 1
  Comment Load (0.6ms)  SELECT `comments`.* FROM `comments` WHERE `comments`.`blog_id` IN (1, 2, 3)

  Comment Destroy (0.6ms)  DELETE FROM `comments` WHERE `comments`.`blog_id` = 1
  Blog Destroy (0.5ms)  DELETE FROM `blogs` WHERE `blogs`.`id` = 1

  Comment Destroy (0.6ms)  DELETE FROM `comments` WHERE `comments`.`blog_id` = 2
  Blog Destroy (0.6ms)  DELETE FROM `blogs` WHERE `blogs`.`id` = 2

  Comment Destroy (0.3ms)  DELETE FROM `comments` WHERE `comments`.`blog_id` = 3
  Blog Destroy (0.4ms)  DELETE FROM `blogs` WHERE `blogs`.`id` = 3

Commentの削除をBlogのidを用いて削除することと、Blogの削除もBlogのidの数だけ削除となり、両方でN回ずつの削除が実施されました。

Model SELECT DELETE
User 1 -
Blog 1 N
Comment 1 N

※ transactionは0 begin, 0 commit。TRANSACTIONが必要であれば別途明示的に用意する。

CommentのDELETEがN * M回 から N回に減っていますので、1や2つ目の処理に比べれば大幅に処理時間は減ります。雑に計算してBLOGとCOMMENTの件数がどちらも100程度であれば、約0.1秒ほどで処理が終わり、100,000程度になると100秒程度かかるようになりますね。 しかし、それでもデータ量に応じて処理時間が延びてしまう問題は残ります。サービス運用を継続していく中で100秒程度時間がかかるというのはなかなか許容できない問題だと思いますので、利用する際は将来のデータ量も想定して利用する必要があるなと感じました。

まとめ

Kaigi on Railsでの発表内容から似たような体験をしたものとして共有させて頂きました。RailsのDB操作をする際に、気を付けるべき部分はN+1問題だけではなく、Kaigi on Railsでの発表の通り、メモリの問題や、今回のブログの内容のように処理時間等もあるというところで、今後の誰かの糧になっていただけますと幸いです。 rubocopはコーディング規約に則ったコード解析をするツールですので畑違いですが、願わくばAIの発展によって危険性を孕むコードとして、事前に指摘がくるような未来になれば良いなぁと思いました。AIレビュー時に実データへの影響も含めて指摘できるようになればと。すごい速度で進化していっている業界ですので、実は遠くない未来なのかなとも思っていますが、それまでは人間が頑張る部分として、問題点の認識と対応をしていきたいなと思います。

参考