QueryRecorder

QueryRecorder는 테스트에서 N+1 queries problem를 감지하는 도구입니다.

spec/support/query_recorder.rb9c623e3e를 통해 구현되었습니다.

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 쿼리에 대한 테스트를 추가하기 전에 먼저 변경 없이 테스트가 실패하는지 확인해야 합니다. 이는 테스트가 망가져있을 수 있거나 잘못된 이유로 테스트가 통과할 수도 있기 때문입니다.

쿼리 출처 찾기

쿼리 출처를 찾는 여러 방법이 있습니다.

  • QueryRecorderdata 속성을 검사합니다. 이것은 file_name:line_number:method_name에 의해 쿼리를 저장합니다. 각 항목은 다음 필드를 가진 hash입니다.
    • count: 이 file_name:line_number:method_name에서의 쿼리 호출 횟수
    • occurrences: 각 호출의 실제 SQL
    • backtrace: 각 호출의 스택 트레이스 (두 가지 옵션이 활성화되어 있는 경우)

    QueryRecorder#find_queryfile_name:line_number:method_namecount 속성에 따라 쿼리를 필터링할 수 있습니다. 예를 들어:

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

    QueryRecorder#occurrences_by_line_methodcount에 따라 정렬된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'
    

참고 자료