クエリーレコーダー

QueryRecorder は、テストからN+1 クエリの問題を検出するためのツールです。

spec/support/query_recorder.rbに実装されています

原則として、マージリクエストはクエリカウントを増加させるべきではありません。N+1 クエリが増えるのを避けるために.includes(:author, :assignee) のようなものを追加する場合は、QueryRecorder を使ってテストすることを検討してください。これがないと、追加のモデルにアクセスさせるような新機能が無言で問題を再導入する可能性があります。

どのように動作するか

このテストでは、ActiveRecord が実行した SQL クエリの数をカウントします。最初にコントロールカウントを行い、次にデータベースに新しいレコードを追加してカウントを再実行します。クエリの数が大幅に増えていたら、N+1 クエリの問題があります。

it "avoids N+1 database queries" do
  control = ActiveRecord::QueryRecorder.new { visit_some_page }
  create_list(:issue, 5)
  expect { visit_some_page }.to issue_same_number_of_queries_as(control)
end

必要であれば、期待値とコントロールの両方をQueryRecorder インスタンスとして持つことができます:

it "avoids N+1 database queries" do
  control = ActiveRecord::QueryRecorder.new { visit_some_page }
  create_list(:issue, 5)
  action = ActiveRecord::QueryRecorder.new { visit_some_page }

  expect(action).to issue_same_number_of_queries_as(control)
end

例として、カウントの間に5つのイシューを作成することができ、N+1の問題が存在する場合、クエリカウントが5増加することになります。

場合によっては、クエリ数が実行の間に無関係な理由でわずかに変化することがあります。この場合、issue_same_number_of_queries_as(control_count + acceptable_change) をテストする必要があるかもしれませんが、これは可能であれば避けるべきです。

このテストが失敗し、コントロールがQueryRecorder として渡された場合、失敗メッセージは、最も長い共通の接頭辞でクエリをマッチさせ、類似のクエリをグループ化することで、余分なクエリがどこにあるかを示します。

場合によっては、N+1仕様は3つのリクエストを含むように書かれています。1つ目はキャッシュを温めるリクエスト、2つ目はコントロールを確立するリクエスト、3つ目はN+1クエリがないことを検証するリクエストです。キャッシュを温めるために余分なリクエストをするよりも、2つのリクエスト(コントロールとテスト)を優先し、N+1仕様のキャッシュクエリを無視するようにテストを設定してください。

it "avoids N+1 database queries" do
  # warm up
  visit_some_page

  control = ActiveRecord::QueryRecorder.new(skip_cached: true) { visit_some_page }
  create_list(:issue, 5)
  expect { visit_some_page }.to issue_same_number_of_queries_as(control)
end

キャッシュされたクエリ

デフォルトでは、QueryRecorder はキャッシュされたクエリを無視します。しかし、N+1クエリがステートメントキャッシュによって隠蔽されることを避けるために、全てのクエリをカウントした方が良いかもしれません。そのためには、:use_sql_query_cache フラグを設定する必要があります。skip_cached 変数をQueryRecorder に渡し、issue_same_number_of_queries_as matcher を使用してください:

it "avoids N+1 database queries", :use_sql_query_cache do
  control = ActiveRecord::QueryRecorder.new(skip_cached: false) { visit_some_page }
  create_list(:issue, 5)
  expect { visit_some_page }.to issue_same_number_of_queries_as(control)
end

RequestStoreの使用

RequestStore /Gitlab::SafeRequestStore は、リクエストの間データをメモリにキャッシュすることで N+1 クエリを回避するのに役立ちます。しかし、テストではデフォルトで無効になっており、 N+1 クエリのテストでは偽陰性になる可能性があります。

テストでRequestStore を有効にするには、 必要に応じてrequest_store ヘルパーを使用します:

it "avoids N+1 database queries", :request_store do
  control = ActiveRecord::QueryRecorder.new(skip_cached: true) { visit_some_page }
  create_list(:issue, 5)
  expect { visit_some_page }.to issue_same_number_of_queries_as(control)
end

コントローラ仕様の代わりにリクエスト仕様を使用します。

コントローラレベルでN+1テストを記述する場合は、リクエスト仕様を使用します。

コントローラは例ごとに一度しか初期化されないため、N+1 テストを書く際には コントローラ仕様を使用すべきではありません。これは、その後の “リクエスト” のクエリが (たとえばメモ化のために) 減少するような、誤った成功につながる可能性があります。

失敗を見たことのないテストを信用してはいけません。

N+1クエリのテストを追加する前に、まずそのテストがあなたの変更なしで失敗するかどうかを確認すべきです。これは、テストが壊れているか、間違った理由でパスしている可能性があるからです。

クエリのソースを見つける

クエリのソースを見つける方法は複数あります。

  • QueryRecorder data 。これは、file_name:line_number:method_name によってクエリを格納します。各エントリは以下のフィールドを持つhash です:

    • countこのfile_name:line_number:method_name からのクエリが呼び出された回数。
    • occurrencesそれぞれの呼び出しの実際のSQL
    • backtrace各コールのスタックトレース(以下の2つのオプションのいずれかが有効になっている場合)。

    QueryRecorder#find_query により、file_name:line_number:method_name およびcount 属性でクエリをフィルタリングできます。例えば

     control = ActiveRecord::QueryRecorder.new(skip_cached: false) { visit_some_page }
     control.find_query(/.*note.rb.*/, 0, first_only: true)
    

    QueryRecorder#occurrences_by_line_method は、data に基づいて、count でソートされた配列を返します。

  • ActiveRecord::QueryRecorder.new(query_recorder_debug: true) を使用して、必要な特定のQueryRecorder インスタンスのコールバックトレースを表示します。出力はファイルtest.log に格納されます。

  • QUERY_RECORDER_DEBUG 環境変数を使用して、すべてのテストのコール・バックトレースを有効にします。

    これを有効にするには、QUERY_RECORDER_DEBUG 環境変数を設定して spec を実行します。例えば

     QUERY_RECORDER_DEBUG=1 bundle exec rspec spec/requests/api/projects_spec.rb
    

    これは、QueryRecorderへの呼び出しをtest.log ファイルに記録します。例えば

      QueryRecorder SQL: SELECT COUNT(*) FROM "issues" WHERE "issues"."deleted_at" IS NULL AND "issues"."project_id" = $1 AND ("issues"."state" IN ('opened')) AND "issues"."confidential" = $2
         --> /home/user/gitlab/gdk/gitlab/spec/support/query_recorder.rb:19:in `callback'
         --> /home/user/.rbenv/versions/2.3.5/lib/ruby/gems/2.3.0/gems/activesupport-4.2.8/lib/active_support/notifications/fanout.rb:127:in `finish'
         --> /home/user/.rbenv/versions/2.3.5/lib/ruby/gems/2.3.0/gems/activesupport-4.2.8/lib/active_support/notifications/fanout.rb:46:in `block in finish'
         --> /home/user/.rbenv/versions/2.3.5/lib/ruby/gems/2.3.0/gems/activesupport-4.2.8/lib/active_support/notifications/fanout.rb:46:in `each'
         --> /home/user/.rbenv/versions/2.3.5/lib/ruby/gems/2.3.0/gems/activesupport-4.2.8/lib/active_support/notifications/fanout.rb:46:in `finish'
         --> /home/user/.rbenv/versions/2.3.5/lib/ruby/gems/2.3.0/gems/activesupport-4.2.8/lib/active_support/notifications/instrumenter.rb:36:in `finish'
         --> /home/user/.rbenv/versions/2.3.5/lib/ruby/gems/2.3.0/gems/activesupport-4.2.8/lib/active_support/notifications/instrumenter.rb:25:in `instrument'
         --> /home/user/.rbenv/versions/2.3.5/lib/ruby/gems/2.3.0/gems/activerecord-4.2.8/lib/active_record/connection_adapters/abstract_adapter.rb:478:in `log'
         --> /home/user/.rbenv/versions/2.3.5/lib/ruby/gems/2.3.0/gems/activerecord-4.2.8/lib/active_record/connection_adapters/postgresql_adapter.rb:601:in `exec_cache'
         --> /home/user/.rbenv/versions/2.3.5/lib/ruby/gems/2.3.0/gems/activerecord-4.2.8/lib/active_record/connection_adapters/postgresql_adapter.rb:585:in `execute_and_clear'
         --> /home/user/.rbenv/versions/2.3.5/lib/ruby/gems/2.3.0/gems/activerecord-4.2.8/lib/active_record/connection_adapters/postgresql/database_statements.rb:160:in `exec_query'
         --> /home/user/.rbenv/versions/2.3.5/lib/ruby/gems/2.3.0/gems/activerecord-4.2.8/lib/active_record/connection_adapters/abstract/database_statements.rb:356:in `select'
         --> /home/user/.rbenv/versions/2.3.5/lib/ruby/gems/2.3.0/gems/activerecord-4.2.8/lib/active_record/connection_adapters/abstract/database_statements.rb:32:in `select_all'
         --> /home/user/.rbenv/versions/2.3.5/lib/ruby/gems/2.3.0/gems/activerecord-4.2.8/lib/active_record/connection_adapters/abstract/query_cache.rb:68:in `block in select_all'
         --> /home/user/.rbenv/versions/2.3.5/lib/ruby/gems/2.3.0/gems/activerecord-4.2.8/lib/active_record/connection_adapters/abstract/query_cache.rb:83:in `cache_sql'
         --> /home/user/.rbenv/versions/2.3.5/lib/ruby/gems/2.3.0/gems/activerecord-4.2.8/lib/active_record/connection_adapters/abstract/query_cache.rb:68:in `select_all'
         --> /home/user/.rbenv/versions/2.3.5/lib/ruby/gems/2.3.0/gems/activerecord-4.2.8/lib/active_record/relation/calculations.rb:270:in `execute_simple_calculation'
         --> /home/user/.rbenv/versions/2.3.5/lib/ruby/gems/2.3.0/gems/activerecord-4.2.8/lib/active_record/relation/calculations.rb:227:in `perform_calculation'
         --> /home/user/.rbenv/versions/2.3.5/lib/ruby/gems/2.3.0/gems/activerecord-4.2.8/lib/active_record/relation/calculations.rb:133:in `calculate'
         --> /home/user/.rbenv/versions/2.3.5/lib/ruby/gems/2.3.0/gems/activerecord-4.2.8/lib/active_record/relation/calculations.rb:48:in `count'
         --> /home/user/gitlab/gdk/gitlab/app/services/base_count_service.rb:20:in `uncached_count'
         --> /home/user/gitlab/gdk/gitlab/app/services/base_count_service.rb:12:in `block in count'
         --> /home/user/.rbenv/versions/2.3.5/lib/ruby/gems/2.3.0/gems/activesupport-4.2.8/lib/active_support/cache.rb:299:in `block in fetch'
         --> /home/user/.rbenv/versions/2.3.5/lib/ruby/gems/2.3.0/gems/activesupport-4.2.8/lib/active_support/cache.rb:585:in `block in save_block_result_to_cache'
         --> /home/user/.rbenv/versions/2.3.5/lib/ruby/gems/2.3.0/gems/activesupport-4.2.8/lib/active_support/cache.rb:547:in `block in instrument'
         --> /home/user/.rbenv/versions/2.3.5/lib/ruby/gems/2.3.0/gems/activesupport-4.2.8/lib/active_support/notifications.rb:166:in `instrument'
         --> /home/user/.rbenv/versions/2.3.5/lib/ruby/gems/2.3.0/gems/activesupport-4.2.8/lib/active_support/cache.rb:547:in `instrument'
         --> /home/user/.rbenv/versions/2.3.5/lib/ruby/gems/2.3.0/gems/activesupport-4.2.8/lib/active_support/cache.rb:584:in `save_block_result_to_cache'
         --> /home/user/.rbenv/versions/2.3.5/lib/ruby/gems/2.3.0/gems/activesupport-4.2.8/lib/active_support/cache.rb:299:in `fetch'
         --> /home/user/gitlab/gdk/gitlab/app/services/base_count_service.rb:12:in `count'
         --> /home/user/gitlab/gdk/gitlab/app/models/project.rb:1296:in `open_issues_count'
    

こちらもご覧ください