索引性能分析实践指南:从理论到落地的深度解析
阅读对象:全栈开发、数据库运维、后端工程师 技术基准:MySQL 8.0 + InnoDB 存储引擎 贯穿案例:电商平台的用户表(users)、订单表(orders)
本文不讲纯理论,而是把索引优化串成一条拿来就能用的完整流水线:定位慢 SQL → 拆解耗时 → 分析执行计划 → 设计索引 → 量化验证 → 持续监控。每个环节都配有可直接复制执行的 SQL 和真实业务场景。
一、为什么要做索引性能分析?
1. 索引的本质:书的目录
把一张没有索引的数据库表想象成一本没有目录的 1000 页厚书。
现在你要找"第 458 页那个叫张三的订单",没有目录怎么办?只能从第 1 页开始,一页一页往后翻,翻到第 458 页才能找到。运气最差的情况,要翻完整个 1000 页——这就是全表扫描,时间复杂度 O(n),表里有 120 万行数据,就要检查 120 万行。
而索引就是这本书的目录:先花 1 秒钟查目录,找到"张三"在第 458 页,然后直接翻过去——这就是索引查找,复杂度 O(logn)。120 万行数据的 B+ 树索引,只需要 3~4 次磁盘 IO 就能定位到目标。
| 对比项 | 无索引(全表扫描) | 有索引(B+ 树) |
|---|---|---|
| 类比 | 逐页翻书 | 查目录定位 |
| 复杂度 | O(n) | O(logn) |
| 120 万行需要读取的行数 | 120 万 | 3~4 次磁盘 IO |
| 典型耗时 | 秒级 | 毫秒级 |
2. 矛盾:索引不是免费的午餐
既然索引这么快,那给所有字段都加上索引不就行了?不行。
索引的本质是一棵额外维护的 B+ 树。每加一个索引,就意味着:
- 写入变慢:每次
INSERT、UPDATE、DELETE,除了改数据本身,还要同步维护每一棵索引树的结构。索引越多,写入时维护的树就越多。 - 磁盘变胖:索引文件本身要占磁盘空间,大表的索引甚至可能比数据文件还大。
- 内存变挤:InnoDB 会把热点索引页缓存到 Buffer Pool,索引太多会挤占缓存数据页的空间。
这就像一本书:目录只有 2 页时很方便;如果每个段落都给你编一个目录,书的一半都是目录,翻起来反而累。
3. 结论:索引优化必须是"科学流程",不是"拍脑袋"
盲目加索引 = 查询没快多少,写入先垮了。
正确的姿势是四步走:
- 先找——找到真正慢、且被高频调用的 SQL(而不是凭感觉猜);
- 再析——分析它为什么慢(慢在哪个阶段?是不是没走索引?);
- 精准加——针对问题设计最合适的索引;
- 量化验——用数据证明索引真的有效,而不是"感觉快了"。
下面进入完整流程。
二、排查第一步:定位低效 SQL
优化的第一步不是猜,而是找。要找到那些"用得最多、扫得最广、返回最少"的 SQL——它们才是性价比最高的优化目标。
1. 查看语句执行频次:给 SQL 排优先级
MySQL 内部维护了全局统计计数器,可以告诉我们数据库从启动到现在,各类语句各执行了多少次。
-- 查看全局各类 SQL 语句的累计执行次数
SHOW GLOBAL STATUS LIKE 'Com_%';输出示例(截取关键行):
+-------------------------+-----------+
| Variable_name | Value |
+-------------------------+-----------+
| Com_select | 86420000 | ← 查询执行了 8600 多万次
| Com_insert | 3200000 |
| Com_update | 2100000 |
| Com_delete | 150000 |
+-------------------------+-----------+关键指标解读:
| 指标 | 含义 | 能说明什么 |
|---|---|---|
Com_select | SELECT 累计执行次数 | 读压力 |
Com_insert / Com_update / Com_delete | 增删改累计执行次数 | 写压力 |
Slow_queries | 慢查询累计次数 | 慢查询的规模 |
-- 查看 InnoDB 引擎累计扫描/读取了多少行数据(判断读放大程度的核心指标)
SHOW STATUS LIKE 'InnoDB_rows_read';+------------------+--------------+
| Variable_name | Value |
+------------------+--------------+
| Innodb_rows_read | 125000000000 | ← 累计扫了 1250 亿行
+------------------+--------------+怎么用这两个数据做决策?
- 用
Innodb_rows_read ÷ Com_select粗算一下:平均每次查询要扫多少行。如果平均每次 SELECT 要扫几千上万行,说明大量查询没走索引。 - 用
Com_select与Com_insert + Com_update + Com_delete的比例,判断系统是读多写少还是写多读少:读多写少的系统可以放心加索引;写多读少的系统加索引要克制。
业务场景还原:
电商平台日活 10 万。运营同学反馈"后台偶尔有点卡"。查频次统计发现:
- 商品列表查询:每天执行 80 万次;
- 订单详情查询:每天执行 5 万次;
- 售后工单查询:每天执行 800 次。
这时哪怕售后查询慢到 3 秒,也不如先把"只慢了 200ms 但每天跑 80 万次"的商品列表查询优化掉——优化优先级 = 频次 × 单次耗时。
2. 慢查询日志:精准捕捉超时 SQL
频次统计只能告诉我们"大盘",具体到是哪条 SQL 慢,就要靠慢查询日志了。
它是 MySQL 自带的"低效 SQL 记录本":任何一条 SQL 执行时间超过你设定的阈值,就自动被记录下来,包括执行时间、扫描行数、锁等待时间、SQL 原文。这是找慢查询的第一入口。
第一步:开启慢查询日志
修改配置文件 my.cnf(Linux)或 my.ini(Windows):
[mysqld]
slow_query_log = 1 # 1 = 开启慢查询日志
long_query_time = 1 # 阈值:执行超过 1 秒就记录
slow_query_log_file = /var/log/mysql/slow.log # 日志文件路径
log_queries_not_using_indexes = 1 # 额外福利:没走索引的语句也记下来生产环境建议阈值设为 1 秒;压测/调优期可以临时调到 0.1 秒抓得更细。
也可以不改配置文件,用 SQL 临时开启(注意:重启后失效):
SET GLOBAL slow_query_log = 1;
SET GLOBAL long_query_time = 1;
-- 确认是否开启成功
SHOW VARIABLES LIKE 'slow_query%';
SHOW VARIABLES LIKE 'long_query_time';第二步:用 mysqldumpslow 分析日志
慢日志文件直接看很痛苦(同样的 SQL 参数不同会重复出现),MySQL 自带了聚合分析工具 mysqldumpslow:
# 按平均执行时间排序,取最慢的前 10 条(找"单次最慢")
mysqldumpslow -s at -t 10 /var/log/mysql/slow.log
# 按扫描行数排序,取前 10 条(找"扫得最广")
mysqldumpslow -s r -t 10 /var/log/mysql/slow.log
# 按出现次数排序,取前 10 条(找"高频慢",性价比最高)
mysqldumpslow -s c -t 10 /var/log/mysql/slow.log输出示例:
Count: 200000 Time=2.30s (460000s) Lock=0.00s Rows=1500
SELECT * FROM orders WHERE user_id = N AND create_time > 'S'这一行告诉我们:这条 SQL 出现了 20 万次,平均每次 2.3 秒,累计吃掉 46 万秒的数据库时间。
案例落地:
慢查询日志捕获到这样一条用户订单查询:
SELECT * FROM orders WHERE user_id = 1001 AND create_time > '2024-01-01';
-- 执行时间 2.3s,每天调用 20 万次频次高、单次慢,优化价值极高——带着它进入下一步深度分析。
三、慢 SQL 深度分析:从耗时拆解到执行计划
找到了嫌疑人(慢 SQL),接下来要做两件事:先搞清楚它慢在哪个阶段(SHOW PROFILES),再搞清楚 MySQL 打算怎么执行它(EXPLAIN)。
1. SHOW PROFILES:给 SQL 做"全身 CT"
SHOW PROFILES 能把一条 SQL 从解析、优化到执行的每个阶段耗时全部列出来,就像体检报告:不是笼统地说"你身体不好",而是精确到"哪个指标异常"。
第一步:开启 profiling(仅当前会话有效)
SET profiling = 1;
-- 确认开启
SHOW VARIABLES LIKE 'profiling';第二步:执行目标 SQL
SELECT * FROM orders WHERE user_id = 1001 AND create_time > '2024-01-01';第三步:查看最近所有 SQL 的耗时清单
SHOW PROFILES;+----------+------------+------------------------------------------------------+
| Query_ID | Duration | Query |
+----------+------------+------------------------------------------------------+
| 1 | 2.31452000 | SELECT * FROM orders WHERE user_id = 1001 AND ... |
+----------+------------+------------------------------------------------------+第四步:查看指定 SQL 的阶段耗时明细
SHOW PROFILE FOR QUERY 1;
-- 进阶:还可以看 CPU、块 IO、上下文切换等系统资源占用
SHOW PROFILE CPU, BLOCK IO FOR QUERY 1;阶段耗时明细示例:
+----------------------+----------+
| Status | Duration |
+----------------------+----------+
| starting | 0.000042 |
| checking permissions | 0.000006 |
| Opening tables | 0.000031 |
| init | 0.000018 |
| optimizing | 0.000085 | ← 优化器制定执行计划,仅 0.08ms
| statistics | 0.000120 |
| executing | 0.000012 |
| Sending data | 1.968542 | ← 占比 85%,瓶颈在这!
| end | 0.000009 |
| closing tables | 0.000011 |
+----------------------+----------+案例分析:
Sending data 阶段耗时 1.97 秒,占总耗时 85%。这个阶段是 MySQL 扫描数据行、过滤条件、把结果发给客户端的过程。它耗时高,几乎可以断定:扫描了太多不该扫的行。
顺便科普几个常见阶段的含义:
Opening tables慢 → 表太多或表缓存不够;optimizing慢 → SQL 结构太复杂(几十个 JOIN);Sending data慢 → 扫描行数过多,索引问题高发区;Creating tmp table慢 → 用了临时表(常见于 GROUP BY 没走索引)。
既然怀疑是扫描阶段的问题,下一步就该看 MySQL 的"执行路线图"——EXPLAIN。
2. EXPLAIN:解读 MySQL 的执行计划
EXPLAIN 的作用是:在不真正执行 SQL 的前提下,告诉你 MySQL 打算怎么执行这条语句——走哪个索引、扫多少行、以什么顺序 JOIN。它是索引优化的核心工具,地位相当于医生的 CT 报告。
使用方式:在任何 SELECT 前面加 EXPLAIN 即可。
EXPLAIN SELECT * FROM orders WHERE user_id = 1001 AND create_time > '2024-01-01';核心字段逐个拆解:
| 字段 | 含义 | 重点关注什么 |
|---|---|---|
id | 查询块的执行序号 | id 大的先执行;id 相同从上往下执行。嵌套子查询时 id 会递增 |
select_type | 查询的类型 | SIMPLE 简单查询(无子查询/UNION);PRIMARY 最外层查询;SUBQUERY 子查询;DERIVED 派生表;UNION 联合查询的第二及后续部分 |
table | 这一行访问的是哪张表 | 多表 JOIN 时会列出多张表 |
type | 访问类型,最重要的一列 | 详见下方性能排序,至少要达到 range |
possible_keys | MySQL 认为"可能"用到的索引 | 为 NULL 说明连候选索引都没有 |
key | 实际真正用到的索引 | 为 NULL 说明没走索引,红色警报 |
key_len | 实际使用了索引的前多少个字节 | 用来判断联合索引用到了前几列 |
rows | 预估要扫描的行数 | 越小越好,这是优化器的估算值 |
filtered | 过滤后剩余行数的百分比 | 100% 表示扫描的行全部有效 |
Extra | 额外补充信息 | Using index 覆盖索引(好);Using filesort 额外排序(警惕);Using temporary 用了临时表(警惕);Using where 存储引擎返回后还在服务端过滤 |
type 字段性能排序(从快到慢,背下来):
system > const > eq_ref > ref > range > index > ALL| type 值 | 含义 | 典型场景 | 评价 |
|---|---|---|---|
system | 表里只有一行 | 系统表 | 理论最快 |
const | 主键/唯一索引等值查询,最多返回一行 | WHERE id = 1001 | 完美 |
eq_ref | 多表 JOIN 时,被驱动表用主键/唯一索引关联 | JOIN ON 主键 | 完美 |
ref | 普通(非唯一)索引等值查询 | WHERE user_id = 1001 | 很好 |
range | 索引范围查询 | WHERE create_time > '2024-01-01'、BETWEEN、IN | 可接受 |
index | 扫描整棵索引树(但没回表) | 只用索引列做全索引扫描 | 一般 |
ALL | 全表扫描 | 没有任何索引可用 | 红色警报 |
经验法则:生产环境的查询,type 至少要达到 range 级别,最好 ref 以上。看到 ALL 就要立刻处理。
案例落地:
对慢查询日志抓到的那条 SQL 执行 EXPLAIN:
EXPLAIN SELECT * FROM orders WHERE user_id = 1001 AND create_time > '2024-01-01';+----+-------------+--------+------+---------------+------+---------+------+---------+-------------+
| id | select_type | table | type | possible_keys | key | key_len | rows | filtered| Extra |
+----+-------------+--------+------+---------------+------+---------+------+---------+-------------+
| 1 | SIMPLE | orders | ALL | NULL | NULL | NULL | 1.2M | 10.0 | Using where |
+----+-------------+--------+------+---------------+------+---------+------+---------+-------------+逐字段诊断:
type = ALL:全表扫描;possible_keys = NULL:连可用的候选索引都没有;key = NULL:确认没走任何索引;rows = 120 万:每次查询要扫 120 万行;filtered = 10%:扫的 120 万行里只有 10% 满足条件,90% 的行是白扫的。
实锤结论:2.3s 的元凶就是缺少合适的索引导致全表扫描。病根找到了,下面开药方。
四、索引规则与失效场景:避坑指南
1. 验证索引效率:加完索引怎么知道好不好用?
很多同学的误区是:索引一加,任务完成。错。加索引不是终点,量化验证才是。
针对上面的问题,创建联合索引:
CREATE INDEX idx_user_time ON orders(user_id, create_time);然后用三个核心指标验证效果:
指标①:执行时间(最直观)
SELECT * FROM orders WHERE user_id = 1001 AND create_time > '2024-01-01';优化前:2.3s → 优化后:0.02s (提升 100 倍以上)指标②:EXPLAIN 执行计划(看本质)
| 字段 | 优化前 | 优化后 |
|---|---|---|
| type | ALL(全表扫描) | ref / range(走索引) |
| key | NULL | idx_user_time |
| rows | 120 万 | 1200 |
扫描行数从 120 万降到 1200 行,减少 99.9%。
指标③:Handler 读取次数(看磁盘 IO)
Handler_read% 系列计数器记录了 MySQL 通过存储引擎读数据的次数,是衡量"磁盘读了几次"的权威指标。使用方法是:执行查询前后各查一次,取差值。
-- 第一次查询,记录基线
SHOW STATUS LIKE 'Handler_read%';
-- 执行目标查询
SELECT * FROM orders WHERE user_id = 1001 AND create_time > '2024-01-01';
-- 第二次查询,对比差值
SHOW STATUS LIKE 'Handler_read%';关键指标含义:
| 指标 | 含义 | 优化后的期望表现 |
|---|---|---|
Handler_read_key | 通过索引键读取的次数 | 大幅上升(好事,说明在用索引) |
Handler_read_rnd_next | 全表扫描逐行读取的次数 | 大幅下降(好事,全表扫描消失了) |
Handler_read_next | 按索引顺序读取下一行的次数 | 上升(范围扫描会用到) |
三个指标相互印证,才能说"这次优化是真的有效"。
2. 最左前缀法则:联合索引的核心逻辑
这是联合索引最容易踩坑、面试必考的规则。
通俗类比:联合索引是"按顺序排的多级目录"。创建 (user_id, create_time, order_status) 联合索引,数据的排序方式是:
- 先按
user_id排序; user_id相同的,再按create_time排序;create_time也相同的,再按order_status排序。
就像字典的"拼音 + 笔画"目录:先按拼音排序,同音字再按笔画排序。你只有先按拼音查,笔画目录才有意义——直接按笔画查字,目录完全帮不上忙。
CREATE INDEX idx_multi ON orders(user_id, create_time, order_status);案例①:从最左开始连续使用——命中完整索引
EXPLAIN SELECT * FROM orders
WHERE user_id = 1001 AND create_time > '2024-01-01' AND order_status = 1;type: range key: idx_multi key_len: 最大长度(三列全用上)✅ 三个字段全部走索引。
案例②:跳过最左列——索引整体失效
EXPLAIN SELECT * FROM orders
WHERE create_time > '2024-01-01' AND order_status = 1;type: ALL key: NULL❌ 查询条件跳过了最左边的 user_id,相当于"不查拼音直接查笔画",索引完全用不上,退化为全表扫描。
案例③:用到最左列但跳过中间列——只命中前缀
EXPLAIN SELECT * FROM orders
WHERE user_id = 1001 AND order_status = 1;type: ref key: idx_multi key_len: 4(只有 user_id 部分)⚠️ user_id 用上了索引;但跳过了中间的 create_time,order_status 在索引里不再是"有序"的,只能回表后逐行过滤。
怎么用 key_len 验证索引用到了第几列?
key_len 表示实际使用的索引字节数。以 idx_multi(user_id INT, create_time DATETIME, order_status TINYINT) 为例:
- user_id(INT):4 字节
- create_time(DATETIME,8.0 中 5 字节 + NULL 标记 1 字节):6 字节
- order_status(TINYINT + NULL 标记):2 字节
| key_len | 说明 |
|---|---|
| 4 | 只用到了 user_id 一列 |
| 10 | 用到了 user_id + create_time 两列 |
| 12 | 三列全部用上 |
通过 key_len 反推索引使用长度,是排查"索引只生效一半"问题的利器。
补充规则:联合索引中遇到范围查询(
>、<、BETWEEN、LIKE 'xx%')时,该列之后的列索引失效。例如WHERE user_id = 1001 AND create_time > '2024-01-01' AND order_status = 1,create_time是范围条件,order_status的索引用不上。所以设计联合索引时,等值条件列在前,范围条件列在后。
3. 索引失效的 7 种高频场景
以下每个场景都给出:错误写法 → 失效原因 → 正确改写。这是日常开发中最常踩的坑。
场景①:索引字段参与运算或函数
-- ❌ 错误:对索引列使用了函数
SELECT * FROM orders WHERE YEAR(create_time) = 2024;失效原因:索引 B+ 树里存的是 create_time 的原始值(如 2024-03-15 10:30:00),而 YEAR(create_time) 计算后的结果(2024)在索引里根本不存在。MySQL 只能对每一行都算一遍函数再比较——全表扫描。
-- ✅ 正确:索引列保持"干净",把计算挪到条件右侧
SELECT * FROM orders
WHERE create_time >= '2024-01-01' AND create_time < '2025-01-01';场景②:使用 != / <> / IS NOT NULL
-- ❌ 错误:不等判断
SELECT * FROM orders WHERE user_id != 1001;失效原因:B+ 树索引擅长"定位到某个值/某个范围",但"不等于"意味着要跳过某个值扫描其余所有,索引的有序性帮不上忙,优化器通常放弃索引。
-- ✅ 正确:转化为两个范围查询的并集
SELECT * FROM orders WHERE user_id < 1001
UNION ALL
SELECT * FROM orders WHERE user_id > 1001;
-- 或者从业务上拆分:排除法换正向列举(比如状态不等于"已删除" → 状态 IN (正常, 已发货, 已完成))场景③:模糊查询以 % 开头
-- ❌ 错误:通配符 % 在最前面
SELECT * FROM orders WHERE order_sn LIKE '%10001%';失效原因:B+ 树是按字符串从左到右排序的。LIKE '%10001%' 不知道目标以什么开头,无法在树中定位起点,只能全表扫。
-- ✅ 方案一:改成前缀匹配(知道开头才能用索引)
SELECT * FROM orders WHERE order_sn LIKE '2024%';
-- ✅ 方案二:真正的"任意位置关键词搜索"是搜索引擎的活,
-- 用 Elasticsearch / 全文索引,别让 B+ 树硬扛场景④:字段类型不匹配(隐式类型转换)
-- ❌ 错误:order_sn 是 VARCHAR 类型,查询时传了数字
SELECT * FROM orders WHERE order_sn = 20240001;失效原因:MySQL 会默默做隐式类型转换,实际执行的是 WHERE CAST(order_sn AS SIGNED) = 20240001——等于对索引列套了函数,与场景①同理,索引失效。
-- ✅ 正确:参数类型与字段定义严格一致,字符串加引号
SELECT * FROM orders WHERE order_sn = '20240001';这个坑在 Java 开发里尤其常见:字段是 VARCHAR,代码里传了 Long。肉眼完全看不出来,只能靠 EXPLAIN 发现。
场景⑤:OR 连接的条件中存在非索引字段
-- ❌ 错误:user_id 有索引,但 phone 没有索引
SELECT * FROM orders WHERE user_id = 1001 OR phone = '13800138000';失效原因:OR 的语义是"满足任意一个条件就行"。phone 没有索引,意味着查 phone 那半边必须全表扫——既然都要全表扫了,优化器干脆整个查询都不用索引。
-- ✅ 正确:拆成两个各自走索引的查询,用 UNION 合并
SELECT * FROM orders WHERE user_id = 1001
UNION
SELECT * FROM orders WHERE phone = '13800138000';
-- 前提:给 phone 也建上索引场景⑥:MySQL 评估全表扫描更快(优化器的"自作主张")
-- 这条 SQL 写法没有任何问题,索引也存在
SELECT * FROM orders WHERE user_id > 100;失效原因:这条 SQL 可能命中表里 80% 的数据。优化器算了一笔账:走索引要扫描索引树 + 逐行回表 80 万次,比直接顺序全表扫描还慢。一般经验值是命中数据超过表总量约 20% 时,优化器倾向于全表扫描。
这不是 bug,是优化器的理性决策。应对方式:
- 如果业务确实要取大量数据,接受全表扫描(或走离线数仓);
- 如果确信索引更快,用
FORCE INDEX强制(见下文); - 检查统计信息是否过期,执行
ANALYZE TABLE orders;让优化器拿到准确数据再决策。
场景⑦:多索引时优化器选错索引
-- orders 表上同时存在 idx_user_time(user_id, create_time) 和 idx_status(order_status)
SELECT * FROM orders WHERE user_id = 1001 AND order_status = 1;失效原因:优化器基于统计信息估算每个索引的成本。如果统计信息过期(比如刚批量导入了 500 万行数据),它可能误判 idx_status 更优,实际执行却很慢。
解决方案:
-- 方案一:更新统计信息,让优化器基于准确数据重新决策
ANALYZE TABLE orders;
-- 方案二:用 SQL 提示强制指定索引(见下一节)五、进阶索引优化:索引类型的选型与实践
1. SQL 提示:手动干预优化器的选择
绝大多数情况应该信任优化器,但统计信息失真时它也会犯错。SQL 提示(Index Hint)就是留给人的"一票否决权":
-- ① USE INDEX:给优化器"提建议"(它可以不听)
SELECT * FROM orders USE INDEX(idx_user_time)
WHERE user_id = 1001 AND create_time > '2024-01-01';
-- ② FORCE INDEX:下"死命令",强制使用(必须听)
SELECT * FROM orders FORCE INDEX(idx_user_time)
WHERE user_id = 1001 AND create_time > '2024-01-01';
-- ③ IGNORE INDEX:拉黑某个索引,让优化器别用它
SELECT * FROM orders IGNORE INDEX(PRIMARY)
WHERE user_id = 1001;案例:大促批量导入历史订单后,统计信息过期,MySQL 误选了主键索引执行这条查询,耗时从 0.02s 恶化到 1.8s。应急处理:
SELECT * FROM orders FORCE INDEX(idx_user_time)
WHERE user_id = 1001 AND create_time > '2024-01-01';
-- 执行时间立刻恢复 0.02s注意:FORCE INDEX 是应急手段而非日常手段。SQL 里写死索引名,会让 SQL 和索引结构耦合——以后索引改名、删索引,SQL 就报错。根本解法还是 ANALYZE TABLE 更新统计信息、或者重新审视索引设计。
2. 覆盖索引 & 回表查询:减少磁盘 IO 的核心技巧
先理解 InnoDB 的索引结构:
- 聚簇索引(主键索引):叶子节点存的是整行数据;
- 二级索引(普通索引):叶子节点只存"索引列的值 + 主键值",不存整行数据。
回表:用二级索引查到主键后,还要拿着主键去聚簇索引里再查一次,才能拿到整行数据。相当于:你翻目录找到了页码,还得再翻到那一页拿内容——多了一次磁盘 IO,命中行数多时代价巨大。
二级索引 [user_id=1001] → 拿到主键 id=50001 → 回表查聚簇索引 → 拿到整行数据
(一次索引查找) (又一次索引查找)覆盖索引:如果你要查的字段全部都在索引里,就不需要回表了——索引里直接拿结果,省掉第二次查找。相当于:目录上直接写了答案,连翻页都省了。
-- 已有联合索引 idx_user_time(user_id, create_time)
-- ✅ 覆盖索引:SELECT 的字段(user_id、create_time)全在索引里
SELECT user_id, create_time FROM orders WHERE user_id = 1001;EXPLAIN 结果中 Extra = Using index ← 覆盖索引的标志,没有回表-- ❌ 需要回表:SELECT * 包含的 address、amount、remark 等字段不在索引里
SELECT * FROM orders WHERE user_id = 1001;EXPLAIN 结果中 Extra 没有 Using index ← 每命中一行都要回表取整行数据实践建议:
- 高频查询避免
SELECT *,只 SELECT 需要的字段,给覆盖索引创造条件; - 极端高频的查询,可以针对性地把查询字段"塞进"联合索引,人为制造覆盖索引。比如订单列表页只需要展示
(user_id, create_time, order_status, amount),就把这四个字段建成一个联合索引,列表查询全程不回表。
3. 前缀索引:解决长字段的索引空间问题
问题场景:用户表要支持按邮箱查用户。email 是 VARCHAR(100),平均长度 30 字符。给整列建索引:
- 每个索引项存 100 字节,一千万用户就是约 1GB 的索引;
- 索引大 → 一个 B+ 树节点能装的索引项少 → 树变高 → IO 变多。
前缀索引:只取字段的前 N 个字符建索引,用一点点区分度的损失,换来索引体积的大幅缩小。
第一步:计算区分度,找到合适的前缀长度
区分度 = 前缀不重复的数量 ÷ 总行数,越接近 1(100%)越好。
-- 分别测试不同前缀长度的区分度
SELECT
COUNT(DISTINCT LEFT(email, 8)) / COUNT(*) AS prefix_8,
COUNT(DISTINCT LEFT(email, 10)) / COUNT(*) AS prefix_10,
COUNT(DISTINCT LEFT(email, 15)) / COUNT(*) AS prefix_15,
COUNT(DISTINCT email) / COUNT(*) AS full_col
FROM users;+----------+-----------+-----------+----------+
| prefix_8 | prefix_10 | prefix_15 | full_col |
+----------+-----------+-----------+----------+
| 0.9123 | 0.9658 | 0.9989 | 1.0000 |
+----------+-----------+-----------+----------+经验值:区分度超过 95% 就够用。上表中前 10 个字符的区分度已达 96.6%,性价比最优。
第二步:创建前缀索引
CREATE INDEX idx_email_prefix ON users(email(10));前缀索引的两个局限(选型时要知道):
- 无法做覆盖索引:索引里存的不是完整值,MySQL 必须回表确认完整 email 是否匹配;
- 不能用于 ORDER BY / GROUP BY:前缀排序不等于完整值排序。
4. 单列索引 vs 联合索引:什么时候用哪个?
先记住一个关键事实:MySQL 一次查询通常只使用一个索引(index merge 是少数例外场景)。这意味着:
给 user_id、create_time、order_status 各建一个单列索引,执行多条件查询时,MySQL 只能挑其中一个索引用,剩下的条件要在结果集里逐行过滤——三个索引废了两个。
选型决策:
场景 A:单一条件的高频查询 → 单列索引足够
-- 业务:只按用户 ID 查订单(比如"我的订单"入口)
SELECT * FROM orders WHERE user_id = 1001;
-- 方案:单列索引即可
CREATE INDEX idx_user ON orders(user_id);场景 B:多条件组合查询 → 联合索引
-- 业务:按"用户 + 时间范围 + 订单状态"组合查询(订单筛选页)
SELECT * FROM orders
WHERE user_id = 1001 AND create_time > '2024-01-01' AND order_status = 1;
-- 方案:一个联合索引顶三个单列索引
CREATE INDEX idx_multi ON orders(user_id, create_time, order_status);真实对比数据(120 万行 orders 表实测量级):
| 方案 | EXPLAIN type | 扫描行数 | 耗时 |
|---|---|---|---|
| 三个单列索引 | ref(只用上 user_id 一个) | 约 5 万(剩余两列回表后过滤) | 1.5s |
| 联合索引 | range(三列全用上) | 约 800 | 0.15s |
效率相差 10 倍以上。
-- 验证:EXPLAIN 中 key 命中联合索引,key_len 达到三列总长度,rows 大幅下降
EXPLAIN SELECT * FROM orders
WHERE user_id = 1001 AND create_time > '2024-01-01' AND order_status = 1;联合索引设计三原则:
- 区分度高的字段放左边(user_id 区分度高,order_status 只有几种取值,放右边);
- 等值查询列在前,范围查询列在后(范围列后面的列索引失效);
- 一个联合索引尽量覆盖多个查询场景:有了
(user_id, create_time, order_status),就不需要再建(user_id)、(user_id, create_time)——最左前缀天然兼容。
六、索引性能分析完整 Workflow 总结
把全文所有技术点串成一套可复用的闭环流程:
① 定位:慢查询日志(mysqldumpslow)+ Com_% / Innodb_rows_read 频次统计
→ 找出「高频 × 单次慢」的性价比最高优化目标
↓
② 拆解:SET profiling = 1 → SHOW PROFILES → SHOW PROFILE FOR QUERY N
→ 确认慢在哪个阶段(Sending data 占比高 = 扫描行数过多)
↓
③ 分析:EXPLAIN 执行计划
→ 看 type(是不是 ALL?)、key(是不是 NULL?)、rows、Extra
→ 确认是"没索引"还是"索引失效"
↓
④ 设计:结合最左前缀法则,避开 7 种失效场景
→ 选择索引类型:联合索引 / 前缀索引 / 覆盖索引
→ 遵循三原则:高区分度在左、等值在范围前、一个联合索引覆盖多场景
↓
⑤ 落地:CREATE INDEX 创建索引
↓
⑥ 验证:三指标量化确认
→ 执行时间(如 2.3s → 0.02s)
→ EXPLAIN(type: ALL→ref,rows: 120万→1200)
→ Handler_read%(read_rnd_next 大降,read_key 大升)
↓
⑦ 兜底:优化器选错索引时
→ ANALYZE TABLE 更新统计信息
→ 应急用 FORCE INDEX 等 SQL 提示
↓
回到 ① 持续监控,形成闭环核心心法一句话:
索引优化不是"加个索引"就结束了,而是"定位 → 分析 → 设计 → 验证 → 持续监控"的完整闭环。每一步都要拿数据说话,不要拿感觉说话。