4、SQL查询优化:慢查询分析、索引使用、避免N+1问题
SQL 查询优化这件事,说起来不复杂,但真到了线上环境,你才会发现一个慢查询能把整个接口拖垮。我自己的经验是:80% 的性能问题,根源都在 SQL 上。这一节我们就一步步拆解——怎么找到慢查询、怎么用好索引、怎么干掉 N+1 这种“经典坑”。
4.1 慢查询分析——先找到敌人,再开枪
你想想看,如果不知道哪个查询慢,后面所有优化都是瞎忙活。所以第一步永远是:定位慢查询。
4.1.1 开启慢查询日志
MySQL 里可以用以下配置来捕获执行时间超过阈值的 SQL:
-- 开启慢查询日志
SET GLOBAL slow_query_log = ON;
-- 设定阈值(单位:秒),我习惯设 1 秒
SET GLOBAL long_query_time = 1;
-- 日志文件路径
SET GLOBAL slow_query_log_file = '/var/log/mysql/slow.log';
拿到日志后,用 mysqldumpslow 工具聚合分析,重点关注那些出现频率高、总耗时长的 SQL。我个人还会配合 Performance Schema 去查锁等待和 IO 开销。
4.1.2 用 EXPLAIN 解读执行计划
找到疑似慢的 SQL 后,贴到 EXPLAIN 里看执行计划。其实只要盯住这几个字段就够了:
| 字段 | 关注点 | 典型问题 |
|---|---|---|
| type | 访问方式。ALL 最差,index 次之,ref/eq_ref/const 好 | 出现 ALL 说明没用到索引,全表扫 |
| rows | 预估扫描行数。越大越慢 | 和实际返回行数差太多,说明统计信息不准 |
| Extra | Using filesort、Using temporary 都是“危险信号” | 排序或分组没用到索引,触发了磁盘临时表 |
| key | 实际使用的索引。NULL 代表无索引 | 明明有索引却没用?可能索引失效了 |
EXPLAIN。养成肌肉记忆,后面省大功夫。
4.2 索引使用——用好它,而不是滥用它
索引就像书的目录——没有它你翻到死,但页页都加目录你也受不了。下面几个原则是我在实际项目中反复验证过的。
4.2.1 索引类型与适用场景
- B+树索引:MySQL 默认,最适合范围查询、排序、等值匹配。我记得有一次线上死锁,就是因为唯一索引和普通索引的间隙锁冲突,后来改成了非唯一索引才解决。
- 哈希索引:Memory 引擎专用,等值查询极快,但不支持范围。审计接口里很少用。
- 全文索引:适合大文本模糊搜索,比如查日志内容。不要对 varchar 建普通索引然后
LIKE '%xxx%'——那肯定慢。 - 空间索引:GIS 场景专用,普通业务用不上。
4.2.2 最左前缀法则——联合索引的灵魂
建联合索引时,最常用的条件放最左边。假设表 audit_log 有联合索引 (user_id, action, created_at):
-- ✅ 能命中
SELECT * FROM audit_log WHERE user_id = 123 AND action = 'login';
-- ✅ 也能命中(最左前缀匹配到 user_id)
SELECT * FROM audit_log WHERE user_id = 123;
-- ❌ 不走索引(跳过了最左列)
SELECT * FROM audit_log WHERE action = 'login';
-- ❌ 范围查询后面的列失效(created_at 也不能走索引过滤)
SELECT * FROM audit_log WHERE user_id = 123 AND action LIKE 'log%' AND created_at > '2024-01-01';
嗯,这里要注意:范围查询(>, <, BETWEEN, LIKE 前缀模糊)会阻断后续列走索引。所以设计联合索引时,要把范围条件放在最后面。
4.2.3 覆盖索引——少回表就是快
当查询需要的所有列都在索引中时,MySQL 直接通过索引返回结果,不用回表查聚簇索引。这就是覆盖索引。
-- 假设有 (user_id, action, created_at) 联合索引
-- 如果只查这三列,就是覆盖索引查询
EXPLAIN SELECT user_id, action FROM audit_log WHERE user_id = 123;
-- Extra 字段会显示 "Using index",表示用到了覆盖索引
我在优化报表接口时,经常故意把查询字段塞进索引里,避免回表 IO。代价是索引体积会变大,需要权衡写入性能与查询性能。
rows 还是很大,查询反而更慢。后来改成只对前 10 个字符做前缀索引,才真正有效。记住:高选择性的列才适合建索引。
4.3 避免 N+1 问题——ORM 的隐形杀手
N+1 问题太常见了,尤其是用 ORM 框架(MyBatis、Hibernate、JPA)的时候。说白了就是:做一次查询拿到 N 条主记录,然后循环里又发 N 次查询拿关联记录。总共 1+N 次查询,网络延迟和数据库压力全上去了。
4.3.1 典型场景
-- 假设有两个表:用户表 user,操作日志表 audit_log
-- 最初写法(伪代码):
List<User> users = userDao.findAll(); // 1 次查询
for (User user : users) {
List<AuditLog> logs = auditLogDao.findByUserId(user.getId()); // N 次查询
// 处理...
}
-- 如果 users 有 1000 条,数据库就要执行 1001 次查询
4.3.2 解决方案
- 批量查询 + IN 条件:先查出所有用户 ID,再一次性查关联表。
- 子查询 / JOIN:直接用一条 SQL 搞定。但要注意 JOIN 可能导致结果集膨胀,且要对大表谨慎。
- ORM 懒加载改急加载:Hibernate 的
@ManyToOne(fetch = FetchType.EAGER)或 MyBatis 的collection标签里用嵌套查询+延迟加载时,考虑改成关联查询。 - 数据冗余 / 缓存:对频繁读取的关联数据,比如用户名字,可以冗余到审计日志表中,避免连表查询。
4.4 本章知识体系全貌
下面的 SVG 图把核心流程串起来了:从定位慢查询开始,到优化索引、最后根治 N+1。你可以把它当一张“检查清单”贴在工作台上。
总结起来很简单:先分析,再索引,最后治理 N+1。我个人在给团队做审计接口优化时,就是用这张图当检查清单,一个节点一个节点过,效果非常稳。你对着它做一次全链路扫描,大部分 SQL 性能问题都能暴露出来。