QueryRecorder
QueryRecorder는 테스트에서 N+1 queries problem를 감지하는 도구입니다.
spec/support/query_recorder.rb의 9c623e3e를 통해 구현되었습니다.
Merge Request 상태와 성능는 추가 쿼리 수를 증가시켜서는 안 됩니다. 예를 들어 .includes(:author, :assignee)
와 같은 것을 추가하여N+1
쿼리를 피하려고 하는 경우, 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 "N+1 데이터베이스 쿼리를 피합니다", :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
은count
에 따라 정렬된data
를 기반으로 한 정렬된 배열을 반환합니다. -
-
특정
QueryRecorder
인스턴스의 콜백 트레이스를 보고 싶은 경우ActiveRecord::QueryRecorder.new(query_recorder_debug: true)
를 사용하십시오. 출력물은test.log
파일에 저장됩니다. -
모든 테스트에 대해 콜백 트레이스를 활성화하십시오. 이를 사용하려면 스펙을
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'
참고 자료
-
Bullet
N+1
쿼리 문제를 찾기 위한 도구 - 성능 지침
- Merge Request 성능 지침 - 쿼리 카운트
- Merge Request 성능 지침 - 캐시된 쿼리
-
RedisCommands::Recorder Redis에서
N+1
호출을 테스트하기 위한 도구