「ある日突然人に〇〇を説明することになったら説明できるか?」を時々自分に試すんですが、N+1問題についてあわあわしてしまったので、ちゃんと自分の言葉にしてみます。ついでに、そういうアウトプットの時には「#とは」カテゴリーに分類して、自分の蓄積を見返せるようにすることにしました。過去のエントリは面倒なので未対応です。
N+1問題とは?
DBのクエリの問題で、本来は1クエリで済ませられる処理をN個分余計にクエリを発行してしまっている状態。 クエリの数が多い分処理が遅くなり、Nの数が多ければ多いほどそれが顕著になる。
N+1問題が発生する状況
データ的には親子関係にあるレコードや1対多などの関連の関係にあり、あるテーブルのレコードを参照するとき同時に関連レコードも参照したい場合に起こり得るようです。
N+1問題の例
Ruby on Railsの場合で書いてみます。
# app/models/user.rb class User < ApplicationRecord has_many :posts, dependent: :destroy end # app/models/post.rb class Post < ApplicationRecord belongs_to :user end # app/controllers/users_controller.rb class UsersController < ApplicationController def index @users = User.all end end
# app/views/users/index.html.erb <% @users.each do |user| %> <p>Name: <%= user.name%></p> <ul>posts <% user.posts.each do |post| %> <li><%= post.body %></li> <% end %> </ul> <% end %>
この状態で/users
にアクセスした時のログは以下のようになります。このケースではusersレコードは5つある状態です
ここではSELECT users.* FROM users
でusersテーブルに対するSELECTで1回、それに加えてSELECT posts.* FROM posts WHERE posts.user_id = 1
のようなpostsテーブルに対するSELECTが5回、合計6回のSELECTが走っています。N+1に当てはめると、1はusersのSELECT、今回のNはpostsテーブルへの5回のSELECTが具体的な値となります。
N+1問題を解消する
発行するSQLを効率のよいものに変えることが必要になります。 方法はいくつかありますが、Railsの場合やりたいことによって適切なメソッドが異なります。私の場合それは頭に入ってなくて何回も同じ記事を参照しているので、それを貼っておきます。
N+1問題の解消例
先に書いたN+1問題が発生するコードをこのように変えてみます。
# app/controllers/users_controller.rb class UsersController < ApplicationController def index - @users = User.all + @users = User.includes(:posts) end end
この場合のログは以下のようになります。ここではSELECTは2回走っていて、最初で対象となるuser.idを抽出する、2回目でそのuser_idを持つpostsを抽出しています。JOINとかして1クエリにすることも可能ですが、詳しいところはこの記事に任せます。
N+1問題に気づく方法
N+1問題に気づくORMの提供するメソッドがどのようなSQLを発行しているかを確認し、その妥当性を判断する必要があります。
今の自分では3つ方法があるだろうと考えます。
1. ログを見る
最も簡単な方法として例に挙げたようにログを見る方法が挙げられます。 最も手早くできる方法です。しかし、それでは人間の目でログをさらっていき、SQLが適切かどうかの判断を下す必要がり、巨大なアプリケーションや流.量の多いログでそれをやるには限界があるなど、コスパが悪そうではあります。
2. 検知ツールを導入する
私は日頃Ruby on Railsで開発しているのでbulletというgemにはお世話になっています。ここでは簡単な説明にとどめますが、READMEにあるように、N+1問題が発生している箇所があるとログに出力して教えてくれます。この例だと、Post.includes([:comments])
みたいにするといいよって教えてくれています。便利〜。
2009-08-25 20:40:17[INFO] USE eager loading detected: Post => [:comments]· Add to your query: .includes([:comments]) 2009-08-25 20:40:17[INFO] Call stack /Users/richard/Downloads/test/app/views/posts/index.html.erb:8:in
each' /Users/richard/Downloads/test/app/controllers/posts_controller.rb:7:in
index'
Rails、というかActive Record以外のORMでもN+1を検出するツールがあるそうで、以下の記事にいくつか紹介されていました。 medium.com
記事が削除されたり見れなくなってしまった時のために該当部分引用しておきます。
・spring-hibernate-query-utils, a library for Java, makes your tests fail if N+1 queries are detected
・The Database Machine, an ORM for PHP, implements a smart eager loading mechanism
・laravel-query-detector, a laravel package (in PHP), detects N+1 queries on your development environment
・nplusone, a python library for detecting the n+1 queries problem in Python ORMs, including SQLAlchemy, Peewee, and the Django ORM
3. その他:コードを見たときに脳内でSQL変換する
多分これはできる時できない時があって必須ではないと思うのですが、「これだとクエリはどうなるかな?」という観点を常時持って実装を読み書きしていれば、ログを見る前に気づけることもあるのかなというのは体感としてあります。
全体的な所感
「N+1問題とは何か」を改めて言葉にするにあたり、「なぜN+1問題が発生するのか」を考えました。いくつかの記事では「ORMを使うと発生する 」というようなことも言われていました。確かに生でSQLを書く場合はあまりこういうことは起きないかなあと思いました。とすると「ORMがDBの複雑さを隠蔽してくれているというのに甘えず、このコードでどのようなSQLが走っているのか、それは大量にレコードがあった時にどうなるのかということを常に考えましょう。そのためにそして対策が”気をつけましょう”に止まらないようにすでにある便利なツールを便利に利用したり、少しずつでもORMがやっていることを理解したり、DBそれ自体の良い使い方を知ってよく使って行きましょう」ということがN+1問題から得られる心構えかなと思いました。