マイペースなRailsおじさん

Ruby、Ruby on Rails、オブジェクト指向設計を主なテーマとして扱います。だんだん大きくなっていくRuby on Rails製プロダクトのメンテナンス性を損なわない方法を考えたり考えなかったりしている人のブログです。

n+1クエリの自動テストが書けるgem、n_plus_one_control

N+1問題

N+1問題は、ループ内の処理を通過するたびにSQLが発行されるというパフォーマンス上の問題です。Railsにおいては、includesメソッドを使って、preloadあるいはeager loadを使って回避できます。

こちらの記事がわかりやすいです

【Ruby on Rails】N+1問題ってなんだ? - Qiita

検出方法

N+1問題の検出には、bulletがよく使われます。このgemは、N+1なクエリが発行されるとメッセージを出力してくれます。

n_plus_one_controlを使って自動テストで検出する

n_plus_one_controlというgemは、自動テストで検出することができるようにしてくれます。

Squash N+1 queries early with n_plus_one_control test matchers for Ruby and Rails — Martian Chronicles, Evil Martians’ team blog github.com

だいだいの仕組みは、複数レコードを出力する処理を複数回実行し、発行クエリの差分を見ることで無駄なクエリが発行されていないかをチェックするものです。 bulletでは検出できないパターンも検出できるようです。

n_plus_one_controlを使ってみる

対象のコード

モデル

ユーザーが部署のレコードを持つモデルを用意します(普通逆だけど)

class User < ApplicationRecord
  has_one :department
end
class Department < ApplicationRecord
  belongs_to :user
end

コントローラー

とくにひねりはなく普通に全レコード取得します。

class UsersController < ApplicationController
  def index
    @users = User.all
  end
end

ビュー

user.departmentが複数回走るので、ここで毎回クエリが発行されます。

<h1>Users</h1>

<table>
  <thead>
    <tr>
      <th>Name</th>
      <th>Department</th>
    </tr>
  </thead>

  <tbody>
    <% @users.each do |user| %>
      <tr>
        <td><%= user.name %></td>
        <td><%= user.department.name %></td>
      </tr>
    <% end %>
  </tbody>
</table>

テストする

n_plus_one_controlは、複数のレコードを参照する処理を複数回実行するので、任意の数のレコードを生成する方法を知っている必要があります。populateに、任意の数nを受け取って、n個のユーザーと部署レコードを生成するコードを書いておきます。

あとは、テスト対象の処理が走るページを参照する処理をassert_perform_constant_number_of_queries(populate: populate)で囲めば準備完了です。

require 'test_helper'

class UsersControllerTest < ActionDispatch::IntegrationTest
  setup do
    @user = users(:one)
  end

  test 'test_no_n_plus_one_error' do
    populate = lambda do |n|
      n.times do
        User.create(
          name: 'テスト',
          department: Department.new(name: 'テスト部署')
        )
      end
    end

    assert_perform_constant_number_of_queries(populate: populate) do
      get users_url
    end
  end
end

実行結果

$ rails test
# Running:

F

Failure:
UsersControllerTest#test_test_no_n_plus_one_error [/home/tanaka/n_plus_one_app/test/controllers/users_controller_test.rb:18]:
Expected to make the same number of queries, but got:
  5 for N=2
  6 for N=3
Unmatched query numbers by tables:
  departments (SELECT): 4 != 5

いいかんじに失敗してくれました。n=2のときに、5回クエリが発行されているので、n=3でも5回クエリが発行されることを期待したが、6回発行されたという旨が表示されました。すごい!

なおす

includesを使って解消します。

class UsersController < ApplicationController
  def index
    @users = User.all.includes(:department)
  end
end

これで再度テストします

$ rails test
Running via Spring preloader in process 7072
Run options: --seed 18633

# Running:

.

Finished in 1.235619s, 0.8093 runs/s, 0.8093 assertions/s.
1 runs, 1 assertions, 0 failures, 0 errors, 0 skips

テストが通るようになりました。

大事なところで使うといいかも

n+1クエリって、改修の中でいつの間にか生まれていくという印象が強くあります。大きな機能追加のときは気をつけるんですが、細かい改修だと見落としてしまったり…。なので、この手の自動テストが書けるというのは非常に役立ちそうです。

n+1をどれだけ徹底して潰すかにもよりますが、パフォーマンスに気を使う処理の場合は、このようなテストは大変有効に働くのでは無いでしょうか。逆に、あまりどこでも使ってしまうと「早すぎる最適化」に陥ってしまうかもしれません(「早すぎる最適化は諸悪の根源」について - Qiita)。

用法用量に気をつけて、少しずつ使う機会を探っていこうと思います。