QueryRecorder
QueryRecorder는 테스트에서 N+1 쿼리 문제를 감지하는 도구입니다.
spec/support/query_recorder.rb에 구현되어 있으며, 9c623e3e 커밋을 통해 추가되었습니다.
일반적으로 Merge Request는 쿼리 수를 증가시키지 않아야 합니다. N+1
쿼리를 피하기 위해 .includes(:author, :assignee)
와 같은 것을 추가하는 경우, QueryRecorder를 사용하여 이를 테스트로 강제하는 것을 고려해보세요. 그렇지 않으면, 추가 모델을 접근하게 하는 새로운 기능이 조용히 문제를 다시 발생시킬 수 있습니다.
작동 방식
이 테스트 스타일은 ActiveRecord에 의해 실행된 SQL 쿼리의 수를 계산하는 방식으로 작동합니다. 먼저 제어 수를 측정하고, 데이터베이스에 새 레코드를 추가한 후 카운트를 다시 실행합니다. 쿼리 수가 현격하게 증가했다면 N+1
쿼리 문제가 존재하는 것입니다.
it "N+1 데이터베이스 쿼리를 피합니다", :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
원하신다면, 기대값과 제어값을 QueryRecorder
인스턴스로 가질 수 있습니다:
it "N+1 데이터베이스 쿼리를 피합니다" 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 스펙이 세 가지 요청을 포함하도록 작성될 수 있습니다: 첫 번째 요청은 캐시를 워밍업하고, 두 번째 요청은 제어를 설정하며, 세 번째 요청은 N+1 쿼리가 없는지 검증합니다. 캐시를 워밍업하기 위해 추가 요청을 만드는 것보다 두 개의 요청(제어 및 테스트)을 선호하고 테스트를 구성하여 N+1 스펙에서 캐시된 쿼리를 무시하도록 설정하세요.
it "N+1 데이터베이스 쿼리를 피합니다" do
# 워밍업
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
매처를 사용하세요:
it "N+1 데이터베이스 쿼리를 피합니다", :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
: 각 호출의 스택 추적(다음 두 가지 옵션 중 하나가 활성화된 경우)
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
로 정렬됩니다. -
-
특정
QueryRecorder
인스턴스의 호출 스택 추적을 보려면ActiveRecord::QueryRecorder.new(query_recorder_debug: true)
를 사용합니다. 출력은test.log
파일에 저장됩니다. -
모든 테스트에 대해 호출 스택 추적을 활성화하려면
QUERY_RECORDER_DEBUG
환경 변수를 사용하세요.이를 활성화하려면,
QUERY_RECORDER_DEBUG
환경 변수가 설정된 상태로 스펙을 실행하세요. 예를 들어:QUERY_RECORDER_DEBUG=1 bundle exec rspec spec/requests/api/projects_spec.rb
이렇게 하면
test.log
파일에 QueryRecorder로의 호출이 기록됩니다. 예를 들어: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::Recorder Redis에서
N+1
호출 테스트하기