查询记录器
查询记录器是一个从测试中检测 N+1 查询问题 的工具。
通过 9c623e3e 在 spec/support/query_recorder.rb 中实现
通常情况下,合并请求 不应该增加查询次数。如果你发现自己添加了类似 .includes(:author, :assignee) 的代码来避免 N+1 查询,考虑使用查询记录器通过测试来强制执行这一点。如果没有这样做,一个会导致访问额外模型的新功能可能会悄无声息地重新引入这个问题。
查询记录器如何工作
这种测试方式通过计算 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缓存查询
默认情况下,查询记录器在计数时忽略 缓存查询。然而,计算所有查询可能更好,以避免引入可能被语句缓存掩盖的 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:每次调用的实际SQLbacktrace:每次调用的堆栈跟踪(如果启用了以下两个选项中的任何一个)
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环境变量的情况下运行规范。例如:QUERY_RECORDER_DEBUG=1 bundle exec rspec spec/requests/api/projects_spec.rb这会将查询记录器的调用记录到
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'
另请参阅
- Bullet 用于查找
N+1查询问题 - 性能指南
- 合并请求性能指南 - 查询次数
- 合并请求性能指南 - 缓存查询
- RedisCommands::Recorder 用于测试 Redis 中的
N+1调用