调优不是“加索引”。执行计划会骗人,查询缓存会干扰,索引选错、Change Buffer、字符集转换都能把 SQL 带沟里。线上慢查询的排查链路,一次拆开。
面试死在这一句
“我看你简历上写熟悉数据库调优,那你说说线上 SQL 执行慢了,你会怎么处理?”
“这个简单,加个索引就好了。”
“好的,那今天的面试就先到这儿吧。”
这个场景真实到有点扎心。简历上写“熟悉数据库调优”,被追问时能说出口的只有“加索引”三个字。加索引是调优里最常用的一招,但它是结论,不是方法。
面试官想听的是:你怎么判断该加什么索引、加完怎么验证、加不上去的时候怎么办。
下面按实际排查的顺序,把这条链路拆开。
先本地 explain,再线上看真实耗时
调优的主战场在 SQL 本身,但执行环节数据库的参数配置和机器能力也可能需要动。我的习惯分两步:
- 本地环境先跑一遍 SQL,用
EXPLAIN看执行计划,判断它是否符合预期、有没有用上该用的索引 - 确认没问题之后,再到线上观察实际执行时间
有个前提要说清楚:上面这套流程只针对查询语句。 INSERT、UPDATE、DELETE 这些修改语句别随便在线上直接跑,一定要在测试环境验证充分再上线——在生产库上试一条 UPDATE 的代价,可能比慢查询本身大得多。
流程大概长这样,第一次没命中预期就得回头改 SQL 或索引,改完再走一遍:
flowchart TD
A[本地跑 SQL] -->|EXPLAIN 看执行计划| B{命中预期索引}
B -->|否| C[改 SQL 或调整索引]
C --> A
B -->|是| D[线上观察真实耗时]
D --> E{是否达标}
E -->|否| F[排除查询缓存干扰再测]
F --> G[对比 EXPLAIN 与实际]
G --> H[ANALYZE TABLE 或 FORCE INDEX]
查询缓存:8.0 之前最容易把本地测试带偏
我自己被这个坑过。MySQL 8.0 之前数据库有查询缓存功能,本地测试时因为缓存的存在,SQL 快得让人放心;结果上线后缓存命中率一掉,响应时间时高时低,极其不稳定。
MySQL 的查询缓存机制很粗暴:只要对某张表做了更新操作,这张表所有的查询缓存都会被清空。 一个高频写入的表,缓存基本就是建了又清、清了又建。
如果用的是 8.0 以下的版本,排查性能问题时第一件事是排除缓存干扰:
SELECT SQL_NO_CACHE id, name FROM product WHERE name = 'iPhone';
加上 SQL_NO_CACHE 拿到的才是真实的查询耗时。8.0 已经把这套机制整个移除了,新版本不用担心这事。
EXPLAIN 不是圣旨:基数会偏,索引会选错
EXPLAIN 是写 SQL 的必备动作,但输出字段的含义真不是每个人都清楚。有两个地方特别容易踩。
一、统计基数不一定准。 MySQL 的统计信息基于采样算出来:先选 N 个数据页,统计不同值的个数算出平均值,再乘以索引页面总数,得到索引基数。数据在不断变化,统计信息随之更新,变动超过一定阈值时 MySQL 会自动重新统计——你看到的数字只是某个时间点的近似值。
二、索引可能会选错。 一条查询走索引 A 要扫 100 行,走索引 B 只要扫 20 行,优化器最后可能选了 A。因为优化器算的是总代价,不只是扫描行数。走 B 虽然扫得少,但每次都要回表,加上回表的代价之后 B 反而更贵。
发现 EXPLAIN 的结果和实际情况差太多,按这个顺序处理:
-- 1. 重新统计索引信息
ANALYZE TABLE product;
-- 2. 统计信息还是不准,强制走正确的索引
SELECT * FROM product FORCE INDEX (idx_name) WHERE name = 'iPhone';
-- 3. 重新优化 SQL 结构,实在不行考虑新建或删除某些索引
FORCE INDEX 是止血手段,不是长期方案。索引选错往往说明索引设计本身有问题,该动的是索引结构,不是把执行计划钉死。
“加个索引”这四个字,往下能聊的东西不少
覆盖索引:能不回表就别回表
执行查询时如果需要回表——从二级索引回到主键索引取完整数据行——性能会受影响。如果建的索引里已经包含了查询需要的所有字段,就不用再回表了,这就是覆盖索引。
电商业务里经常要从商品表通过某些信息查商品 ID,如果索引已经包含了商品 ID,直接从索引拿到结果,不用再回主键索引查一遍。
-- 索引 idx_name(name) 已覆盖 id(二级索引天然带主键)
SELECT id FROM product WHERE name = 'iPhone';
联合索引与最左匹配
还是商品表,假设经常要根据商品名称查询库存,可以建一个联合索引:
ALTER TABLE product ADD INDEX idx_name_stock (name, stock);
这样通过名称查库存时,索引里已经包含了库存字段,不用回表。但联合索引不能随意见——要考虑它占用的空间,还要利用最左匹配原则,并且可以根据业务查询的实际情况调整字段顺序来优化。
有一点要澄清:视频里提到 NAME LIKE '%xx%' 这种模糊查询也能利用联合索引。严格说,前置通配符用不上 B+ 树的有序定位,真正起作用的是下一节的索引下推。
索引下推:5.6 之后才有的东西
MySQL 5.6 引入的优化特性。举个例子:
SELECT * FROM product WHERE name LIKE '%iPhone%' AND stock = 20;
5.6 之前:先通过索引找到所有满足 name LIKE '%iPhone%' 的记录,然后逐条回表检查 stock = 20 是否满足。
5.6 之后:如果 stock 也在同一个联合索引中,就可以在索引遍历的过程中先判断 stock,提前过滤掉不满足条件的记录,减少回表次数。
同样一条 SQL,版本不同,回表次数可能差一个数量级。
唯一索引 vs 普通索引:Change Buffer 的分界线
更新数据时,数据页在不在内存中,决定了两种索引走完全不同的路径:
| 场景 | 普通索引 | 唯一索引 |
|---|---|---|
| 数据页在内存中 | 直接更新 | 直接更新 |
| 数据页不在内存中 | 写 Change Buffer 缓存,暂不读盘 | 必须立即读入数据页校验唯一性 |
| 适用业务 | 写多读少,如账单、日志、流水 | 有强唯一约束需求的场景 |
图里这个分叉就是唯一索引用不上 Change Buffer 的原因:
flowchart TD
A[收到更新请求] --> B{数据页在内存中吗}
B -->|是| C[直接更新内存页]
B -->|否| D{是唯一索引吗}
D -->|是| E[读入磁盘页校验唯一性再更新]
D -->|否| F[写入 Change Buffer 不读磁盘]
F -->|后续访问该页| G[merge 合并进数据页]
Change Buffer 占用的是 Buffer Pool 的内存空间,通过 innodb_change_buffer_max_size 可以设置它占 Buffer Pool 的比例。
它对写多读少的业务非常有用——账单、日志、流水系统,写操作先记在 Change Buffer 里,减少了随机读磁盘,写入性能提升明显。但如果更新完马上查询,会立即触发 merge,反而带来额外开销,这种场景下 Change Buffer 的优势就不成立了。
前缀索引,以及让索引失效的写法
有些字段比较长,比如用邮箱当用户名,对整段建索引很占空间。这时可以用前缀索引,只取字段前面一部分来建:
ALTER TABLE user ADD INDEX idx_email (email(10));
既能节省空间又能提升效率。但要记住:前缀索引只存了部分信息,查询时可能需要回表确认。 对于长字段,还可以考虑用倒序、哈希等方式处理后再建索引。
另外,对索引字段做函数操作会破坏索引的有序性,导致走不上索引:
-- 走不上索引
SELECT * FROM orders WHERE id + 1 = 10;
-- 改成
SELECT * FROM orders WHERE id = 9;
隐式类型转换,比函数操作更阴
对索引字段做隐式类型转换或字符编码转换,同样走不上索引。
-- order_no 是 varchar,这里传了数字
SELECT * FROM orders WHERE order_no = 123456;
MySQL 会在索引字段上隐式加上 CAST 函数,索引直接失效。加个引号就解决了:
SELECT * FROM orders WHERE order_no = '123456';
同样的坑还有 JOIN。两张表关联查询时字符集不同,MySQL 会在字段上加上 CONVERT 函数,索引一样用不上。 建表时把关联字段的字符集设成一致。
写在最后
SQL 调优不是“再建一个索引”。真到线上慢查询,顺序通常是先排除缓存、再看执行计划、再验证统计信息,实在不行才怀疑索引结构。
你在生产里踩过最离谱的索引失效场景是什么?评论区聊聊。如果这篇帮你少加一个没用的索引,点个赞就行。