AI 摘要
为什么 includes 让内存暴涨,而 SQL 子查询却如此高效 —— Rails N+1 查询的真相
本文基于 Akshay Khot 的文章 和本地实验结果,深入剖析 Rails 中 N+1 查询的本质、为什么 includes 会消耗更多内存,以及 SQL 子查询(Subquery)在时间与空间上的取舍。
🧩 什么是 N+1 查询
在 Rails 开发中,N+1 查询是常见的性能陷阱。以 User 和 Post 两个模型为例:
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 创建简化的
users、posts表。 - 每个用户生成 5 000 篇文章,共两名用户,以便观察不同查询方式的差异。
⚖️ 三种方案的性能对比
性能对比一览
| 模式 | 查询数 | 页面加载时间 | SQL 总耗时 | 内存分配 | Ruby 对象数 |
|---|---|---|---|---|---|
| N+1 查询 | 10 001 | 5.8 s | 146.3 ms | 5.9 KB | 86 |
| includes | 2 | 188 ms | 56.7 ms | 455.4 KB | 3675 |
| 子查询 | 10 001 | 5.7 s | 128.8 ms | 7.2 KB | 91 |
🚨 方案一:N+1 查询
在控制器中简单地 @posts = Post.order(created_at: :desc),然后在视图中访问 post.user.name。这种方式代码简洁,但会导致每次访问关联字段都单独执行一次查询。
截图: 下图展示了 N+1 查询在 Rack Mini Profiler 中的总览。可以看到页面渲染时间超过 5.8 秒,SQL 概述中显示查询次数达到 10001 条。

内存分配: 另一张图给出了 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 虽然快了很多,但在内存里实例化了用户对象,仍有一定开销。测试中分配的内存为 455.4 kb,生成了 3675 个对象

小结:用内存换查询次数。当仅需要某个字段时,把整批关联对象都载入内存,往往不划算。
🧮 方案三: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 条查询

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

小结:让数据库干擅长的事。对于「每行只需要一个派生字段」的场景,子查询通常**更快且更省内存。
🔍 Rails N+1 查询的本质
- 懒加载机制: ActiveRecord 天生按需查询,在视图里访问关联对象时才执行 SQL,这导致循环里触发多次查询。
- 慢在往返,不在 SQL: N+1 查询慢在无谓的数据库往返,而不是 SQL 执行本身。即使单次查询只有几毫秒,累加起来仍然很慢。
- includes 的权衡: 通过提前加载关联对象来减少往返,但需要额外内存。
- Subquery 的局限: 如果每行数据都触发一次子查询,虽然减少了 Ruby 对象生成,但查询次数仍然是 N+1 级别,整体反而更慢。
⚖️ 开发中的选择建议
| 场景 | 推荐方案 | 理由 |
|---|---|---|
| 数据量小(<20 条),需要多个关联字段 | .includes | 减少查询次数,代码直观 |
| 数据量大,仅需单字段聚合 | 子查询或聚合函数 | 内存占用小,减少对象创建 |
| 高频访问且字段变化不大 | 缓存或反规范化字段 | 减少实时查询,提升速度 |
💡 总结
N+1 查询本质问题在于数据库往返次数过多includes 通过预加载在内存中构建关联,适合小数据集
子查询能减少 Ruby 对象生成,但如果每行都执行子查询,响应时间不一定快
根据场景选择合适的策略,让数据库做它擅长的工作,Rails 应该更专注于渲染
最后,想送给自己,也送给大家一句话:
纸上得来终觉浅,绝知此事要躬行。
仅仅是看一遍、懂了大概或是一知半解,并不能真正掌握。唯有亲自动手实践,才能体会其中的重点与要领。希望大家在遇到问题时,不妨多尝试自己解决,在行动中积累经验,才能真正看清自己的实力与成长。
参考链接:
如无特殊说明 Rails 中 N+1 问题的研究 为博主 Lin 原创,转载请注明原文链接: https://blog.lin03.cn/archives/46/