在 Rails 请求规范中捕获 N+1 查询,仅用 11 行 Ruby
Source: Dev.to

N+1 查询是 Rails 应用中最常见的性能陷阱之一。Bullet 和 Prosopite 等工具可以帮助检测它们,但会增加依赖和配置开销。有时你只想要一种轻量、透明的方式,在请求规范中直接断言查询次数。
下面展示如何仅使用 Rails 本身提供的功能来实现。
技术要点
Rails 通过 ActiveSupport::Notifications 在 sql.active_record 事件下对每个 SQL 查询进行监控。我们可以在代码块的执行期间订阅该事件,并收集所有触发的查询。
在 spec/support/query_counter.rb 中创建文件:
# spec/support/query_counter.rb
module QueryCounter
def capture_queries(&block)
queries = []
callback = lambda do |_name, _start, _finish, _id, payload|
queries << payload[:sql] unless payload[:sql].match?(/SCHEMA|BEGIN|COMMIT|SAVEPOINT|RELEASE/)
end
ActiveSupport::Notifications.subscribed(callback, "sql.active_record", &block)
queries
end
end
RSpec.configure do |config|
config.include QueryCounter, type: :request
end
这里的关键方法是 ActiveSupport::Notifications.subscribed,它在块执行期间临时订阅回调,并在结束后自动取消订阅。正则过滤器会排除噪音——模式检查和事务控制语句——因此你只会得到应用代码实际触发的查询。
在规范中使用它
假设你有一个返回帖子及其评论的端点。一个天真的实现可能会先加载帖子,然后为每条评论单独发起查询——典型的 N+1 问题。下面是捕获这种情况的方法:
# spec/requests/posts_spec.rb
RSpec.describe "Posts API", type: :request do
let!(:posts) { create_list(:post, 5, :with_comments) }
let(:target_id) { posts.last.id }
it "avoids N+1 queries when fetching post details" do
queries = capture_queries { get "/api/posts/#{target_id}" }
comment_queries = queries.count { |q| q.include?("comments") }
expect(comment_queries).to eq(1)
end
end
该测试在 capture_queries 中发起请求,然后过滤收集到的 SQL 字符串,以统计有多少查询涉及 comments 表。如果有人移除了 includes 调用或引入了惰性加载,此测试将会失败。
为什么这样有效
-
它是明确的。 与其依赖后台工具来提醒你,你是在做直接断言。意图对阅读规范的任何人都很清晰。
-
它测试完整堆栈。 因为这在请求规范中运行,你正在实际调用控制器、序列化器以及任何预加载——而不是对孤立查询的单元测试。
-
它提供原始 SQL。
queries数组包含实际的 SQL 字符串,所以当测试失败时,你可以准确检查触发了哪些查询。这使调试变得直接:queries = capture_queries { get "/api/posts/#{target_id}" } puts queries # see every query that ran -
没有依赖。 这使用
ActiveSupport::Notifications,它是 Rails 的一部分。无需安装、配置或保持更新。
限制
这种方法刻意保持简约。它不会自动检测 N+1 查询——你必须知道要对哪些地方进行断言。它也不提供像 expect { }.to execute_queries(at_most: 3) 这样的 RSpec 匹配器。而且除非你为特定的端点编写了测试,否则它不会提醒你出现回归。
对于需要在每个请求中自动检测的项目,像 Prosopite 或 Bullet 这样的工具更为合适。但对于关键端点的有针对性断言,12 行 Ruby 代码加上清晰的测试已经足够。
采用技巧
- 从最慢的端点开始。 为您知道对性能敏感的端点的请求规范添加
capture_queries。 - 对特定表进行断言。 统计所有查询非常脆弱——模式更改可能会添加或删除联接。按表名过滤(如上面的
comments示例)使测试更具弹性。 - 在开发期间使用它。 在临时测试中将控制器动作包装在
capture_queries中,以查看在优化之前实际发生了什么。
最好的性能工具是您实际会使用的工具。有时是功能完整的 gem,有时只是 spec/support 中的 12 行代码。
感谢阅读!我目前正在为自己的问题探索解决方案。我最近创建了 shaicli.dev 和 frost。请查看——我很想知道你的想法!