MENU

Rails  中 N+1 问题的研究

October 25, 2025 • Read: 59 • 学习

AI 摘要

正在加载摘要...

为什么 includes 让内存暴涨,而 SQL 子查询却如此高效 —— Rails N+1 查询的真相

本文基于 Akshay Khot 的文章 和本地实验结果,深入剖析 Rails 中 N+1 查询的本质、为什么 includes 会消耗更多内存,以及 SQL 子查询(Subquery)在时间与空间上的取舍。


🧩 什么是 N+1 查询

在 Rails 开发中,N+1 查询是常见的性能陷阱。以 UserPost 两个模型为例:

class User < ApplicationRecord
  has_many :posts
end

class Post < ApplicationRecord
  belongs_to :user
end

控制器中简单地读取文章列表:

def index
  @posts = Post.order(created_at: :desc)
end

在视图中渲染每篇文章及其作者:

<% @posts.each do |post| %>
  <h3><%= post.title %></h3>
  User:<span><%= post.user.name %></span>
  <br/>
<% end %>

这段代码背后会执行:一条查询读取所有 posts,随后对每个 post 再单独查询对应的 user。形成了“一条主查询 + N 条子查询”的模式,被称作 N+1 查询。当数据量较大时,多次数据库往返会显著拉长响应时间。


⚙️ 性能分析工具:Rack Mini Profiler

为了更好地观察这些查询带来的开销,我们使用 Rack Mini Profiler。在开发环境中安装并配置后,在浏览器 URL 后加 ?pp=profile-memory 可以看到页面顶部的性能面板,显示 SQL 查询次数、执行时间和内存占用等信息。


🧪 实验环境与数据生成

  • 使用 Rails 8 和 SQLite 创建简化的 usersposts 表。
  • 每个用户生成 5 000 篇文章,共两名用户,以便观察不同查询方式的差异。

⚖️ 三种方案的性能对比

性能对比一览

模式查询数页面加载时间SQL 总耗时内存分配Ruby 对象数
N+1 查询10 0015.8 s146.3 ms5.9 KB86
includes2188 ms56.7 ms455.4 KB3675
子查询10 0015.7 s128.8 ms7.2 KB91

🚨 方案一:N+1 查询

在控制器中简单地 @posts = Post.order(created_at: :desc),然后在视图中访问 post.user.name。这种方式代码简洁,但会导致每次访问关联字段都单独执行一次查询。

  • 截图: 下图展示了 N+1 查询在 Rack Mini Profiler 中的总览。可以看到页面渲染时间超过 5.8 秒,SQL 概述中显示查询次数达到 10001 条。

    N+1 请求耗时和 SQL 概述

  • 内存分配: 另一张图给出了 N+1 查询的内存分配情况

    内存分配情况

小结简单但最慢——因为往返次数爆炸

⚙️ 方案二:includes 预加载

通过 includes 让 Rails 预先加载所有关联用户,减少数据库往返:

@posts = Post.includes(:user).order(created_at: :desc)

Rails 会先取出所有 posts,再批量查询这些 post.user 对象:

  • 截图: Rack Mini Profiler 显示 includes 方式只发起了 2 次查询,页面响应时间从 8 秒级降至约 188 毫秒

    includes 请求耗时和 SQL 概述

  • 内存占用: includes 虽然快了很多,但在内存里实例化了用户对象,仍有一定开销。测试中分配的内存为 455.4 kb,生成了 3675 个对象

    includes 内存分配情况

小结用内存换查询次数。当仅需要某个字段时,把整批关联对象都载入内存,往往不划算

🧮 方案三:SQL 子查询(Subquery)

第三种方式是将关联逻辑交给数据库,通过子查询直接把 users.name 字段放在 posts 查询结果中:

subquery = User
  .select(:name)
  .where("users.id = posts.user_id")
  .limit(1)
  .to_sql

@posts = Post
  .select("posts.*, (#{subquery}) AS author_name")
  .order(created_at: :desc)

生成的 SQL 如下:

SELECT posts.*,
  (SELECT users.name FROM users WHERE users.id = posts.user_id LIMIT 1) AS author_name
FROM posts
ORDER BY created_at DESC;

理论上,这能减少 Rails 与数据库的往返和对象生成。但在测试环境中,每一条 post 的行都执行了一次子查询:

  • 截图: Rack Mini Profiler 提示页面加载时间约 5.7 秒,SQL 概述中显示 10001 条查询

    Subquery 请求耗时和 SQL 概述

  • 内存占用: 使用子查询方式时,内存使用量为约 7.2 KB,对象数约 91

    Subquery 内存分配情况

小结让数据库干擅长的事。对于「每行只需要一个派生字段」的场景,子查询通常**更快且更省内存。

🔍 Rails N+1 查询的本质

  1. 懒加载机制: ActiveRecord 天生按需查询,在视图里访问关联对象时才执行 SQL,这导致循环里触发多次查询。
  2. 慢在往返,不在 SQL: N+1 查询慢在无谓的数据库往返,而不是 SQL 执行本身。即使单次查询只有几毫秒,累加起来仍然很慢。
  3. includes 的权衡: 通过提前加载关联对象来减少往返,但需要额外内存。
  4. Subquery 的局限: 如果每行数据都触发一次子查询,虽然减少了 Ruby 对象生成,但查询次数仍然是 N+1 级别,整体反而更慢。

⚖️ 开发中的选择建议

场景推荐方案理由
数据量小(<20 条),需要多个关联字段.includes减少查询次数,代码直观
数据量大,仅需单字段聚合子查询或聚合函数内存占用小,减少对象创建
高频访问且字段变化不大缓存或反规范化字段减少实时查询,提升速度

💡 总结

N+1 查询本质问题在于数据库往返次数过多
includes 通过预加载在内存中构建关联,适合小数据集
子查询能减少 Ruby 对象生成,但如果每行都执行子查询,响应时间不一定快

根据场景选择合适的策略,让数据库做它擅长的工作,Rails 应该更专注于渲染

最后,想送给自己,也送给大家一句话:

纸上得来终觉浅,绝知此事要躬行。

仅仅是看一遍、懂了大概或是一知半解,并不能真正掌握。唯有亲自动手实践,才能体会其中的重点与要领。希望大家在遇到问题时,不妨多尝试自己解决,在行动中积累经验,才能真正看清自己的实力与成长。


参考链接:

Last Modified: October 28, 2025