大家好,我是程序员柏风,AI 最全的 Vibecoding 学习地图 http://coderoadmap.cn 作者。
查询与优化
这一课你将能做到
- 用
where/order/limit/joins写出安全的链式查询,杜绝 SQL 注入 - 定位并歼灭 N+1 查询,用
includes一把预加载关联 - 用
explain和日志分析慢查询,判断该不该加索引
为什么要学
Rails 把 SQL 包得太好,好到初学者常常「不知道自己发了多少条 SQL」。开发环境数据少,一切飞快;上线后数据量上来,页面突然要 5 秒——十有八九是 N+1 和缺索引。查询优化是 Rails 工程师从「能写」到「能扛流量」的分水岭。
对比本站:Spring Boot 用 JPA 时同样会踩 N+1(@OneToMany 懒加载),解法是 JOIN FETCH 或 EntityGraph——思路和 Rails 的 includes 完全一致。学会一边,另一边触类旁通。
先建立图像
N+1 问题一张图看懂:
@articles = Article.limit(10) # 1 条 SQL:取 10 篇文章
<% @articles.each do |article| %>
<%= article.comments.count %> # 每篇文章 1 条 SQL!
<% end %>
总共发出的 SQL:
SELECT * FROM articles LIMIT 10; ← 1 条
SELECT COUNT(*) FROM comments WHERE article_id=1; ← +1
SELECT COUNT(*) FROM comments WHERE article_id=2; ← +1
... 共 10 次循环 ← +10
合计 11 条 = 1 + N(N=10),数据越多越致命
修复后:
@articles = Article.includes(:comments).limit(10)
# 只发 2 条 SQL:一条取文章,一条 WHERE article_id IN (1..10) 取所有评论
跟着做
步骤 1:链式查询基础
bin/rails console
# 条件查询(占位符防注入)
Article.where(published: true)
Article.where("created_at > ?", 7.days.ago)
Article.where("title LIKE ?", "%#{keyword}%")
# 排序、限量、偏移(分页三件套)
Article.order(created_at: :desc).limit(10).offset(20)
# 只取需要的列
Article.select(:id, :title)
# 聚合
Article.where(published: true).count
Comment.group(:article_id).count # => {1=>5, 2=>3, ...}
链条可以任意组合,直到被遍历才真正执行 SQL(懒加载):
query = Article.where(published: true) # 此刻没查库
query = query.order(created_at: :desc) # 还没查
query.each { |a| puts a.title } # each 触发,一次 SELECT
步骤 2:用 scope 封装常用查询
# app/models/article.rb
class Article < ApplicationRecord
scope :published, -> { where(published: true) }
scope :recent, -> { order(created_at: :desc) }
scope :search, ->(keyword) { where("title LIKE ?", "%#{keyword}%") }
end
控制器里读起来像自然语言:
@articles = Article.published.recent.search(params[:q]).limit(20)
scope 可链式叠加,是 Rails 查询代码保持整洁的核心手法。
步骤 3:现场制造并歼灭 N+1
控制台里打开 SQL 日志(默认已开),制造问题:
articles = Article.limit(5)
articles.each { |a| puts a.comments.size }
日志里会看到 1 条 articles 查询 + 5 条 comments 查询。修复:
articles = Article.includes(:comments).limit(5)
articles.each { |a| puts a.comments.size }
日志只剩 2 条 SQL。三种预加载策略:
Article.includes(:comments) # 两条 SQL(默认,最常用)
Article.eager_load(:comments) # 一条 LEFT JOIN
Article.preload(:comments) # 强制两条 SQL
关联上还要过滤/排序时用 eager_load 或 joins,普通场景 includes 让 Rails 自己选。
步骤 4:joins 做关联过滤
查「有评论的文章」:
Article.joins(:comments).distinct
# SELECT DISTINCT articles.* FROM articles
# INNER JOIN comments ON comments.article_id = articles.id
joins 只用于过滤(不加载关联数据进内存),includes 用于「接下来要用关联数据」。两者目的不同,别混。
步骤 5:用日志和 explain 找慢查询
开发日志里每条 SQL 都带耗时:
Article Load (152.3ms) SELECT "articles".* FROM "articles" WHERE "articles"."title" LIKE '%rails%'
152ms 对单条查询太慢了,拿它去 explain:
Article.where("title LIKE ?", "%rails%").explain
输出显示 Seq Scan(全表扫描)——10 万行全扫当然慢。加索引:
bin/rails generate migration AddIndexToArticlesTitle
add_index :articles, :title
注意:LIKE '%rails%' 前缀通配符会让普通 B-tree 索引失效,全文搜索场景要用 PostgreSQL 的 pg_trgm 或专用搜索引擎。而 LIKE 'rails%'(后缀通配)能走索引——explain 会告诉你真相。
讲清楚要点
- 永远不要拼接用户输入进 SQL 字符串:
where("title = '#{params[:t]}'")是教科书级 SQL 注入。一律用占位符?或哈希条件。 - 懒加载是双刃剑:查询链在遍历前不执行,方便组合;但也意味着
each里访问关联会连环发 SQL,这就是 N+1 的温床。 includes是 N+1 的通用解:看到循环里访问关联,条件反射加上 includes。Bullet 这个 gem 能在开发环境自动报警。- 索引不是免费的:加速查询,拖慢写入(每次 INSERT/UPDATE 要维护索引),还占磁盘。给「常出现在 WHERE/ORDER BY/JOIN 的列」加,别全加。
- count/size/length 有讲究:
count永远发 SQL;size已加载时数内存、未加载时发 COUNT;length先全量加载再数内存。大集合慎用 length。
你可能会懵的地方
-
find_each和each区别?Article.each一次把全部记录拉进内存,百万级数据直接 OOM。Article.find_each(batch_size: 1000)分批取,导出、迁移数据时必须用它。 -
pluck和select区别?select(:id, :title)仍返回模型对象(只填了这两列);pluck(:title)直接返回值的数组,不实例化模型,快得多。只要原始数据用 pluck。 -
exists? 和 present? 区别?
exists?发一条SELECT 1 ... LIMIT 1,查存在性最快;present?会先全量加载记录再判断,大表上是灾难。 -
开发环境很快,生产为什么慢? 数据量差 4 个数量级。全表扫描在 100 行时无感,100 万行时致命。上线前用种子数据灌够量级再测:
bin/rails db:seed或工厂脚本批量造数据。 -
joins 之后怎么没拿到关联对象? joins 只做 SQL 层过滤,不实例化关联对象。要同时过滤又预加载,写
Article.includes(:comments).where(comments: { ... })。
错法对照
| 错法 | 问题 | 正解 |
|---|---|---|
where("title = '#{params[:t]}'") | SQL 注入 | where("title = ?", params[:t]) |
循环里 article.comments.count | N+1 查询 | Article.includes(:comments) |
Article.all.each 处理百万行 | 内存爆炸 | Article.find_each 分批 |
| 给所有列加索引 | 写入变慢、磁盘浪费 | 只给 WHERE/ORDER/JOIN 高频列加 |
Model.all.map(&:title) | 全量对象实例化只为取一列 | Model.pluck(:title) |
卡点急救
- 页面慢但不知道慢在哪:看开发日志底部
Completed 200 OK in 5000ms (Views: 50ms | ActiveRecord: 4800ms)——时间花在 DB 层。装 Bullet gem 自动报 N+1 和多余预加载。 - 加了 includes 还是 N+1:检查是否对关联又做了二次过滤(
article.comments.where(...)),过滤条件不在预加载范围内会重新查。把过滤一并写进 scope 或用嵌套预加载。 - migration 加索引在大表上锁表:MySQL 大表加索引会阻塞写入。生产上用
algorithm: :concurrently(PostgreSQL)或在线改表工具(gh-ost),低峰期执行。
面试常问
-
什么是 N+1 查询?怎么发现和解决? 答:取 N 条主记录后发 1 条 SQL,循环访问每条记录的关联时又各发 1 条,共 N+1 条。发现:日志里出现大量相似 SQL、Bullet gem 报警、APM 工具慢追踪。解决:
includes预加载(两条 SQL)、eager_load(JOIN 一条 SQL),或只取所需字段的 pluck/joins。 -
includes、joins、eager_load 区别? 答:includes 默认两条 SQL(主表 + 关联表 IN 查询),智能判断;joins 是 INNER JOIN 只为过滤不加载关联对象;eager_load 强制 LEFT OUTER JOIN 一条 SQL 并加载关联。循环用关联数据→includes;按关联字段过滤但不用数据→joins;既要过滤又要数据→includes + where 或 eager_load。
-
什么时候加数据库索引? 答:高频出现在 WHERE、ORDER BY、JOIN ON、外键的列;explain 显示全表扫描且表数据量大时。不加的场景:低基数列(如只有 true/false 的布尔列单列索引价值低)、写多读极少的表、小表(几百行全表扫比走索引还快)。
自测三问
Article.where(published: true)这一行执行时发 SQL 了吗?什么时候才发?joins(:comments)和includes(:comments)的目的差异是什么?- 为什么
LIKE '%关键词%'用不上普通索引?
小结一句话
查询优化的三板斧:占位符防注入、includes 歼 N+1、explain 定索引——先看日志再动手。
延伸
- Active Record Query Interface(官方查询指南)
- 下一课:数据库适配——从 SQLite 到 MySQL/PostgreSQL,连接池与多数据库。