只会答加索引MySQL 调优还能聊这 7 个点

15 阅读8分钟

调优不是“加索引”。执行计划会骗人,查询缓存会干扰,索引选错、Change Buffer、字符集转换都能把 SQL 带沟里。线上慢查询的排查链路,一次拆开。

面试死在这一句

“我看你简历上写熟悉数据库调优,那你说说线上 SQL 执行慢了,你会怎么处理?”

“这个简单,加个索引就好了。”

“好的,那今天的面试就先到这儿吧。”

这个场景真实到有点扎心。简历上写“熟悉数据库调优”,被追问时能说出口的只有“加索引”三个字。加索引是调优里最常用的一招,但它是结论,不是方法。

面试官想听的是:你怎么判断该加什么索引、加完怎么验证、加不上去的时候怎么办。

下面按实际排查的顺序,把这条链路拆开。

先本地 explain,再线上看真实耗时

调优的主战场在 SQL 本身,但执行环节数据库的参数配置和机器能力也可能需要动。我的习惯分两步:

  1. 本地环境先跑一遍 SQL,用 EXPLAIN 看执行计划,判断它是否符合预期、有没有用上该用的索引
  2. 确认没问题之后,再到线上观察实际执行时间

有个前提要说清楚:上面这套流程只针对查询语句。 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 调优不是“再建一个索引”。真到线上慢查询,顺序通常是先排除缓存、再看执行计划、再验证统计信息,实在不行才怀疑索引结构。

你在生产里踩过最离谱的索引失效场景是什么?评论区聊聊。如果这篇帮你少加一个没用的索引,点个赞就行。