QueryRecorder

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

spec/support/query_recorder.rb9c623e3e에서 구현되었습니다.

일반적으로, 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로 전달된 경우, 실패 메시지는 추가 쿼리가 있는 위치를 가장 긴 공통 접두어에 맞추어 표시하여 유사한 쿼리를 그룹화합니다.

T매 경우에는 N+1 스펙에서 캐시를 데우는 세 번째 요청을 포함하도록 작성되었습니다. 이 경우 캐시를 데우는 추가 요청을 하는 대신 두 요청(제어 및 테스트)을 하는 것이 좋습니다. 그리고 N+1 스펙에서 cached queries를 무시하도록 테스트를 구성하세요.

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

Cached queries

기본적으로 QueryRecorder는 cached queries를 계산에서 무시합니다. 그러나 캐시 문제를 숨길 수 있는 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으로 쿼리를 저장합니다. 각 항목은 다음 필드를 가진 해시입니다.

    • count: 이 file_name:line_number:method_name에서 쿼리가 호출된 횟수
    • occurrences: 각 호출의 실제 SQL
    • backtrace: 각 호출의 스택 트레이스 (두 가지 옵션 중 하나가 활성화되어 있는 경우)

    QueryRecorder#find_query를 사용하여 쿼리를 file_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 환경 변수를 설정하여 테스트를 실행하십시오. 예를 들어:

    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'
        --> ...
    

관련 항목