深入理解 Rails includes:为什么一个 order(users.xxx) 会导致超级 JOIN 性能问题

深入理解 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")

这种代码。

它看起来只是一个排序。

实际上:

它会彻底改变整个 SQL 执行策略。

相关推荐
baviya1 小时前
用 Spring AI Alibaba JManus 构建零售智能客服工单系统:从 0 到日处理 10 万单
后端·ai编程
叫我少年1 小时前
C# 基础数据类型:布尔类型
后端
鹏程十八少2 小时前
12. Android 协程通关秘籍:31 道资深工程师面试题精讲
前端·后端·面试
白宇横流学长2 小时前
基于Spring Boot的校园考勤管理系统的设计与实现
java·spring boot·后端
ReSearch2 小时前
sfsEdgeStore:边缘计算时代的轻量级数据存储解决方案
数据库·后端·github
SamDeepThinking2 小时前
拼单模块设计实战
java·后端·架构
_waylau3 小时前
“Java+AI全栈工程师”问答02:Spring Boot 自动配置原理
java·开发语言·spring boot·后端·spring
无尽冬.3 小时前
个人八股之三层架构
java·经验分享·后端·架构·异世界
贫民窟的勇敢爷们3 小时前
SpringBoot多环境配置全解+配置优先级管控
java·spring boot·后端