- どのように動作するか
- キャッシュされたクエリ
- RequestStoreの使用
- コントローラ仕様の代わりにリクエスト仕様を使用します。
- 失敗を見たことのないテストを信用してはいけません。
- クエリのソースを見つける
- こちらもご覧ください
クエリーレコーダー
QueryRecorder は、テストからN+1 クエリの問題を検出するためのツールです。
原則として、マージリクエストはクエリカウントを増加させるべきではありません。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'
こちらもご覧ください
-
弾丸
N+1
クエリの問題を見つけるために - パフォーマンスのガイドライン
- マージリクエストのパフォーマンスガイドライン - クエリ数
- マージリクエストのパフォーマンスガイドライン - キャッシュクエリ
-
RedisCommands::RecorderRedis の
N+1
呼び出しのテスト用