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)。
用法用量に気をつけて、少しずつ使う機会を探っていこうと思います。