CodeRoadMap
首页路线题库资源库文章社区
CodeRoadMap

程序员的学习成长路线图。内容免费开放,登录后可同步路线图与课时进度。

由 程序员柏风 独立维护 · 关于作者

学习

  • 路线图
  • 课程
  • 面试题
  • 文章
  • 我

更多

  • 资源
  • 社区
  • 关于作者
  • 内容说明

© 2026 CodeRoadMap

津ICP备2026012044号-1·津公网安备12011002026155号·coderoadmap@126.com
Ruby on Rails 实战入门/课时/查询与优化
RawBlame
课时目录12 节

Ruby on Rails 实战入门

  1. 01Rails 概览与项目结构
  2. 02路由设计
  3. 03控制器
  4. 04Active Record 模型
  5. 05查询与优化
  6. 06数据库适配
  7. 07视图与组件
  8. 08表单
  9. 09认证与授权
  10. 10资产与后台任务
  11. 11测试与调试
  12. 12部署与运维

查询与优化

ActiveRecord 查询链。

30 分钟

查询与优化

这一课你将能做到

  1. 用 where/order/limit/joins 写出安全的链式查询,杜绝 SQL 注入
  2. 定位并歼灭 N+1 查询,用 includes 一把预加载关联
  3. 用 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 会告诉你真相。

讲清楚要点

  1. 永远不要拼接用户输入进 SQL 字符串:where("title = '#{params[:t]}'") 是教科书级 SQL 注入。一律用占位符 ? 或哈希条件。
  2. 懒加载是双刃剑:查询链在遍历前不执行,方便组合;但也意味着 each 里访问关联会连环发 SQL,这就是 N+1 的温床。
  3. includes 是 N+1 的通用解:看到循环里访问关联,条件反射加上 includes。Bullet 这个 gem 能在开发环境自动报警。
  4. 索引不是免费的:加速查询,拖慢写入(每次 INSERT/UPDATE 要维护索引),还占磁盘。给「常出现在 WHERE/ORDER BY/JOIN 的列」加,别全加。
  5. 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.countN+1 查询Article.includes(:comments)
Article.all.each 处理百万行内存爆炸Article.find_each 分批
给所有列加索引写入变慢、磁盘浪费只给 WHERE/ORDER/JOIN 高频列加
Model.all.map(&:title)全量对象实例化只为取一列Model.pluck(:title)

卡点急救

  1. 页面慢但不知道慢在哪:看开发日志底部 Completed 200 OK in 5000ms (Views: 50ms | ActiveRecord: 4800ms)——时间花在 DB 层。装 Bullet gem 自动报 N+1 和多余预加载。
  2. 加了 includes 还是 N+1:检查是否对关联又做了二次过滤(article.comments.where(...)),过滤条件不在预加载范围内会重新查。把过滤一并写进 scope 或用嵌套预加载。
  3. 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 的布尔列单列索引价值低)、写多读极少的表、小表(几百行全表扫比走索引还快)。

自测三问

  1. Article.where(published: true) 这一行执行时发 SQL 了吗?什么时候才发?
  2. joins(:comments) 和 includes(:comments) 的目的差异是什么?
  3. 为什么 LIKE '%关键词%' 用不上普通索引?

小结一句话

查询优化的三板斧:占位符防注入、includes 歼 N+1、explain 定索引——先看日志再动手。

延伸

  • Active Record Query Interface(官方查询指南)
  • 下一课:数据库适配——从 SQLite 到 MySQL/PostgreSQL,连接池与多数据库。
加载本节练习…
← 上一课Active Record 模型下一课 →数据库适配