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

发布: (2026年2月1日 GMT+8 13:23)
5 min read
原文: Dev.to

Source: Dev.to

Cover image for Catch N+1 Queries in Rails Request Specs with 11 Lines of Ruby

N+1 查询是 Rails 应用中最常见的性能陷阱之一。Bullet 和 Prosopite 等工具可以帮助检测它们,但会增加依赖和配置开销。有时你只想要一种轻量、透明的方式,在请求规范中直接断言查询次数。

下面展示如何仅使用 Rails 本身提供的功能来实现。

技术要点

Rails 通过 ActiveSupport::Notificationssql.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 匹配器。而且除非你为特定的端点编写了测试,否则它不会提醒你出现回归。

对于需要在每个请求中自动检测的项目,像 ProsopiteBullet 这样的工具更为合适。但对于关键端点的有针对性断言,12 行 Ruby 代码加上清晰的测试已经足够。

采用技巧

  • 从最慢的端点开始。 为您知道对性能敏感的端点的请求规范添加 capture_queries
  • 对特定表进行断言。 统计所有查询非常脆弱——模式更改可能会添加或删除联接。按表名过滤(如上面的 comments 示例)使测试更具弹性。
  • 在开发期间使用它。 在临时测试中将控制器动作包装在 capture_queries 中,以查看在优化之前实际发生了什么。

最好的性能工具是您实际会使用的工具。有时是功能完整的 gem,有时只是 spec/support 中的 12 行代码。


感谢阅读!我目前正在为自己的问题探索解决方案。我最近创建了 shaicli.devfrost。请查看——我很想知道你的想法!

Back to Blog

相关文章

阅读更多 »