ねこものがたり

いちにちいっぽ

N+1問題とは

「ある日突然人に〇〇を説明することになったら説明できるか?」を時々自分に試すんですが、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つある状態です

N+1問題が発生しているログ
N+1問題が発生しているログ

ここでは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が具体的な値となります。

usersテーブルのSELECTが1回+postsテーブルのSELECTがn回実行されている
usersテーブルのSELECTが1回+postsテーブルのSELECTがn回実行されている

N+1問題を解消する

発行するSQLを効率のよいものに変えることが必要になります。 方法はいくつかありますが、Railsの場合やりたいことによって適切なメソッドが異なります。私の場合それは頭に入ってなくて何回も同じ記事を参照しているので、それを貼っておきます。

qiita.com

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クエリにすることも可能ですが、詳しいところはこの記事に任せます。

SELECTが2回に減ったログ
SELECTが2回に減ったログ

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:inindex'

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問題から得られる心構えかなと思いました。