深入理解 Rails includes:为什么一个 order(users.xxx) 会导致超级 JOIN 性能问题
在很多 Rails 项目里,includes 几乎已经成为条件反射。
看到 N+1?
makefile
.includes(:user)
先加上再说。
但很多线上数据库性能问题,恰恰就是从这里开始的。
尤其是下面这种代码:
sql
Task.includes(:user)
.order("users.name")
看起来只是:
- 预加载 user
- 顺便按用户名排序
但在线上大数据量环境里,它可能会:
- 生成数百行的 SQL
- 触发多个 LEFT OUTER JOIN
- 导致 PostgreSQL 排序爆炸
- 出现 rows 膨胀
- 内存暴涨
- 临时磁盘排序
- 查询时间从几十毫秒变成数秒
更危险的是:
大部分 Rails 开发者根本意识不到 includes 已经偷偷变成 eager_load。
这篇文章不会泛泛讲 N+1。
我们重点讲:
Rails includes 的"隐式 eager_load"机制
以及:
为什么一个 order(users.xxx) 会把数据库拖进深渊。
一、事故背景:一个"看起来很正常"的 includes
线上出现了一个典型问题:
- PostgreSQL CPU 飙升
- tasks 页面加载缓慢
- p95 查询时间突然增加
- 排查发现某条 SQL 执行时间高达 4~8 秒
Rails 代码:
bash
Task.includes(
users: [
:accounts,
avatar_attachment: :blob
]
)
.order("users.last_name, users.first_name")
.limit(50)
开发者原本的想法很合理:
- includes 解决 N+1
- order 排序用户
- limit 只查 50 条
- 看起来应该很快
结果 PostgreSQL 实际执行的 SQL:
sql
SELECT tasks.*,
users.*,
accounts.*,
active_storage_attachments.*,
active_storage_blobs.*
FROM tasks
LEFT OUTER JOIN task_users
ON task_users.task_id = tasks.id
LEFT OUTER JOIN users
ON users.id = task_users.user_id
LEFT OUTER JOIN account_users
ON account_users.user_id = users.id
LEFT OUTER JOIN accounts
ON accounts.id = account_users.account_id
LEFT OUTER JOIN active_storage_attachments
ON active_storage_attachments.record_id = users.id
LEFT OUTER JOIN active_storage_blobs
ON active_storage_blobs.id = active_storage_attachments.blob_id
ORDER BY users.last_name, users.first_name
LIMIT 50
问题开始出现:
- tasks 表 200 万数据
- users 表 80 万
- account_users 400 万
- attachments 1200 万
数据库开始:
- Hash Join
- Sort spill
- 大量 rows 膨胀
- 临时文件暴涨
而开发者甚至不知道:
includes 已经不再是 preload 了。
二、includes 的真正工作机制
很多人以为:
ini
includes == preload
实际上:
lua
includes
↓
根据 SQL 自动判断
↓
preload 或 eager_load
Rails 的 includes 从来不是一个固定策略。
它是:
"关联加载策略选择器"
preload:真正的预加载
arduino
Task.preload(:user)
会生成:
sql
SELECT * FROM tasks;
SELECT * FROM users
WHERE id IN (...)
特点:
- 多条 SQL
- 不 JOIN
- PostgreSQL 容易优化
- 不会出现 rows 膨胀
这是绝大部分 Web 场景最稳定的方式。
eager_load:JOIN 模式
arduino
Task.eager_load(:user)
生成:
sql
SELECT tasks.*, users.*
FROM tasks
LEFT OUTER JOIN users
ON users.id = tasks.user_id
特点:
- 单条 SQL
- JOIN 所有关联
- ActiveRecord 自动实例化对象图
适合:
- 必须基于关联表过滤
- 必须基于关联表排序
- 必须 group/having
但:
eager_load 非常容易产生 JOIN 膨胀。
includes:最危险的地方
arduino
Task.includes(:user)
Rails 会动态判断:
sql
是否需要关联表参与 SQL?
如果:
那么:
includes
↓
自动切换 eager_load
这就是问题根源。
三、为什么一个 order(users.xxx) 会触发 JOIN
看一个最简单例子。
场景一:普通 includes
arduino
Task.includes(:user)
生成:
sql
SELECT * FROM tasks;
SELECT * FROM users
WHERE users.id IN (...)
Rails 使用 preload。
没有 JOIN。
场景二:增加排序
sql
Task.includes(:user)
.order("users.name")
现在 SQL 出现:
vbnet
ORDER BY users.name
问题来了:
当前 FROM:
css
FROM tasks
里面根本没有 users 表。
PostgreSQL 会直接报错:
sql
missing FROM-clause entry for table "users"
因此 Rails 必须:
自动 JOIN users
于是:
includes
↓
eager_load
生成:
sql
SELECT tasks.*,
users.*
FROM tasks
LEFT OUTER JOIN users
ON users.id = tasks.user_id
ORDER BY users.name
这就是 includes 的隐式行为。
开发者没有写 JOIN。
但:
ActiveRecord 帮你 JOIN 了。
四、LEFT OUTER JOIN 为什么性能会爆炸
真正危险的不是 JOIN 本身。
而是:
JOIN 后的 rows 膨胀。
一个典型 has_many 场景
假设:
ruby
class Task
has_many :users
end
class User
has_many :accounts
end
数据:
rust
1 task
-> 10 users
-> 每 user 5 accounts
JOIN 后:
1 \times 10 \times 5 = 50
一个 task 会变成:
50 rows
百万级数据会发生什么
假设:
bash
100 万 tasks
平均 8 users
平均 4 accounts
最终 JOIN rows:
1{,}000{,}000 \times 8 \times 4 = 32{,}000{,}000
数据库需要处理:
3200 万 rows
而业务真正需要的:
100 万 task objects
explain analyze 实际执行计划
真实执行计划通常类似:
sql
EXPLAIN ANALYZE
SELECT ...
FROM tasks
LEFT OUTER JOIN users ...
LEFT OUTER JOIN accounts ...
ORDER BY users.name
LIMIT 50;
执行计划:
sql
Limit
-> Sort
Sort Key: users.name
Sort Method: external merge Disk: 512MB
-> Hash Left Join
Hash Cond: ...
-> Hash Left Join
-> Seq Scan on tasks
-> Hash on users
-> Hash on accounts
这里有几个关键问题。
1. Sort 成本爆炸
vbnet
ORDER BY users.name
排序的是:
JOIN 后结果集
不是:
tasks
如果 JOIN 后是数千万 rows:
- 内存不够
- PostgreSQL 会写磁盘临时文件
- external merge sort 出现
- 查询时间急剧上涨
2. Hash Join 内存暴涨
sql
Hash Left Join
意味着 PostgreSQL 要:
- 构建 hash table
- 保存 JOIN 数据
- 消耗 work_mem
关联越多:
- hash table 越大
- 内存越高
3. 宽表问题(Wide Row)
eager_load 最大问题之一:
bash
SELECT tasks.*, users.*, accounts.*, ...
大量字段:
- text
- jsonb
- metadata
- attachments
会让:
每一行都极其巨大
即使:
python
tasks.id
重复出现。
数据库依然需要:
- 传输
- 排序
- 缓存
这些完整 rows。
4. ActiveRecord 实例化成本
JOIN 返回的数据:
arduino
Task A
Task A
Task A
Task A
实际上是重复数据。
Rails 内部还需要:
instantiate
再重新组装:
rust
Task
-> users
-> accounts
这一步:
- CPU 高
- Ruby 内存高
- GC 压力大
五、为什么 includes 的隐式行为特别危险
危险点在于:
开发者根本意识不到 SQL 已经变了。
代码:
makefile
.includes(:user)
看起来像:
预加载
实际上:
sql
.order("users.name")
会让整个查询策略彻底改变。
为什么开发阶段发现不了
因为开发环境:
makefile
tasks: 100 条
users: 50 条
JOIN 完全没问题。
甚至:
速度还挺快
但线上:
yaml
tasks: 200 万
users: 80 万
attachments: 1200 万
rows 会指数级膨胀。
最危险的一点
很多开发者会继续追加:
ruby
includes(
users: [
:accounts,
:permissions,
avatar_attachment: :blob
]
)
结果:
ActiveRecord 会把所有关联全部 JOIN 进去。
最终生成一个:
ORM 超级对象图查询
而数据库最不擅长的:
恰恰就是这种东西。
六、Rails 内部源码分析
真正核心逻辑:
arduino
ActiveRecord::Relation
内部有:
includes_values
记录 includes 的关联。
eager_loading?
Rails 内部会判断:
eager_loading?
核心逻辑大致:
ruby
def eager_loading?
includes_values.present? &&
references_eager_loaded_tables?
end
关键点:
references_eager_loaded_tables?
references_eager_loaded_tables?
它会检查:
order_values
where_clause
group_values
select_values
references_values
是否引用了关联表。
例如:
scss
order("users.name")
Rails 会发现:
bash
users 表被引用了
于是:
includes
↓
eager_load
↓
build_joins
最终构建:
sql
LEFT OUTER JOIN
build_joins
内部会递归构建:
rust
users
-> accounts
-> attachments
-> blobs
形成完整 JOIN TREE。
这就是为什么:
css
includes(users: { avatar_attachment: :blob })
会产生极长 SQL。
七、如何正确优化
方案一:强制 preload(最推荐)
如果只是解决 N+1:
arduino
Task.preload(:user)
不要:
includes
尤其:
不要让 Rails 自动推断。
方案二:两段查询(最佳实践)
不要:
sql
Task.includes(:user)
.order("users.name")
改成:
第一步:只排序 IDs
css
task_ids =
Task.joins(:user)
.order("users.name")
.limit(50)
.pluck(:id)
SQL 很轻:
vbnet
SELECT tasks.id
FROM tasks
JOIN users ...
ORDER BY users.name
LIMIT 50
第二步:preload
bash
Task.where(id: task_ids)
.preload(:user)
变成:
sql
SELECT * FROM tasks WHERE id IN (...);
SELECT * FROM users WHERE id IN (...);
完全避免超级 JOIN。
方案三:select 限制字段
避免:
sql
SELECT *
改:
ruby
select(:id, :name)
尤其:
- jsonb
- text
- metadata
- ActiveStorage
一定要避免宽表 JOIN。
方案四:不要 preload 巨型关联树
危险:
ruby
includes(
users: [
:accounts,
:permissions,
avatar_attachment: :blob
]
)
建议:
- 分批加载
- lazy load
- GraphQL dataloader
- 分层查询
八、生产环境最佳实践
大表场景
避免:
sql
includes + order/group
优先:
lua
joins + preload
分页场景
危险:
LIMIT 50
并不能减少 JOIN 成本。
因为:
PostgreSQL 必须先完成 JOIN 和排序。
GraphQL 场景
GraphQL 特别容易:
无限 includes
最终生成超级 JOIN。
建议:
- dataloader
- batch loading
- field-level preload
Activity Feed 场景
Activity Feed 最容易:
多态关联 + includes
最终 SQL 极度复杂。
建议:
- timeline fanout
- denormalization
- 分层加载
审计日志场景
审计系统:
- rows 巨大
- jsonb 多
- 宽字段多
不要:
includes + eager_load
否则排序与 JOIN 成本会极高。
九、最后的核心结论
很多人以为:
includes
是:
"性能优化工具"
实际上:
includes 只是关联加载策略。
真正决定性能的:
不是 includes 本身。
而是:
ActiveRecord 最终生成了什么 SQL。
preload
本质:
多个简单查询
eager_load
本质:
sql
一个巨大的对象图 JOIN
真正危险的不是 JOIN
而是:
ActiveRecord 帮你偷偷 JOIN。
尤其:
scss
includes(:user).order("users.name")
这种代码。
它看起来只是一个排序。
实际上: