"该加索引的地方都加了,还是慢"——这句话暴露的是把索引当成开关:以为加上就快,没加就慢。索引真正的作用只有一个:减少需要读的行数与页数。它不改变查询逻辑,只改变访问路径;路径不对,加多少个索引都不解决问题。
这篇从查询形态反推索引组合:什么条件下联合索引比多个单列强、覆盖与回表怎么权衡、什么时候索引本身就是错的答案。
1. 收益来源:扫描行数,而不是"有没有索引"
看执行计划先看四件事,而不是 key 有没有值。
| 观察点 | 含义 | 判断 |
|---|---|---|
type |
访问方式(const/ref/range/index/ALL) | ALL 即全表扫描 |
rows |
预估需要读的行数 | 与结果集大小差一个量级就要怀疑 |
filtered |
预估过滤后剩余比例 | 很低说明选错了列 |
Extra |
是否回表、是否用临时表、是否 filesort | 出现 Using temporary 要单独处理 |
EXPLAIN SELECT id, amount FROM orders
WHERE shop_id = 42 AND status = 1 AND created_at >= '2026-01-01'
ORDER BY created_at DESC LIMIT 20;
索引把"读全表 N 行"变成"读索引一段 + 回表 k 行",收益来自 N/k;当 N 只有几千行或 k 接近 N(区分度差),收益趋零而代价照付。
2. 联合索引与最左前缀
联合索引 (a, b, c) 的物理顺序是先按 a、再按 b、再按 c。这个顺序决定了它能服务哪些查询。
| 查询条件 | 能否用 (a,b,c) |
用到几列 |
|---|---|---|
a=? |
能 | a |
a=? AND b=? |
能 | a,b |
a=? AND b=? AND c=? |
能 | a,b,c |
b=? |
不能(跳过最左列) | 0 |
a=? AND c=? |
部分 | 只用到 a |
a>? AND b=? |
部分 | a 范围后 b 失序 |
-- 三个单列索引:优化器可能走 index merge,也可能只挑一个
ALTER TABLE orders ADD INDEX idx_shop (shop_id),
ADD INDEX idx_status (status),
ADD INDEX idx_created (created_at);
-- 一个联合索引:三个条件同时定位到一段连续区间
ALTER TABLE orders ADD INDEX idx_shop_status_created (shop_id, status, created_at);
"三个单列不如一个联合"的前提是三个条件总一起出现且按区分度排序;若查询常常只用其中一列,联合索引对那类查询无用。
3. 区分度与选择性:谁该放在前面
选择性 = 不同值个数 / 总行数。选择性高的列放前面,能把扫描范围压得更小;低选择性列放前面,等于先划出一个大区间再逐条过滤。
| 列 | 选择性量级 | 放前面的效果 |
|---|---|---|
| 订单号、用户 ID | 接近 1 | 直接定位到极少行 |
| 城市、类目 | 中(几十到几千) | 有效收窄 |
| 状态、性别、是否删除 | 极低(个位数) | 收窄有限,通常不该单独建索引 |
-- 用统计信息估选择性,别凭感觉
SELECT COUNT(DISTINCT shop_id) / COUNT(*) AS sel_shop,
COUNT(DISTINCT status) / COUNT(*) AS sel_status
FROM orders;
-- 更细的分布看直方图(MySQL 8.0 起支持,按版本核对)
ANALYZE TABLE orders UPDATE HISTOGRAM ON status, shop_id;
例外:低选择性列放前面并非总错——若它总与高选择性列同行,且带来索引内排序或覆盖,代价可被抵消。判据是 rows,不是教条。
4. 覆盖索引与回表
二级索引叶子存索引列 + 主键;需要的列不在索引里,就要按主键再查聚簇索引,这就是回表。
Extra 显示 |
含义 | 代价 |
|---|---|---|
Using index |
覆盖索引,不回表 | 只读索引页 |
Using index condition |
索引条件下推,仍要回表 | 减少回表次数但不消除 |
Using where |
服务层再过滤 | 可能读了很多无用行 |
Using filesort |
需要额外排序 | 内存或磁盘排序 |
Using temporary |
需要临时表 | 常见于 GROUP BY / DISTINCT |
-- 覆盖:查询列全部在索引中
ALTER TABLE orders ADD INDEX idx_cover (shop_id, status, created_at, amount);
SELECT shop_id, status, created_at, amount FROM orders
WHERE shop_id = 42 AND status = 1; -- Extra: Using index
-- 不覆盖:多取一个不在索引里的列,立刻回表
SELECT shop_id, status, amount, remark FROM orders WHERE shop_id = 42;
覆盖的代价是索引变宽:每页条目变少、写放大上升,塞大字段换覆盖通常不划算。
5. 排序、范围与索引的截断
ORDER BY 能否走索引,取决于三件事:排序键是否是索引的前缀、方向是否一致、排序前是否有范围条件破坏了顺序。
| 条件 | 能否用索引排序 | 原因 |
|---|---|---|
ORDER BY a 且索引 (a) |
能 | 索引本身有序 |
WHERE a=? ORDER BY b 且索引 (a,b) |
能 | 等值列之后的列仍有序 |
WHERE a>? ORDER BY b,索引 (a,b) |
不能 | 范围后顺序不再保证 |
ORDER BY a ASC, b DESC |
视版本 | 需要降序索引支持,按版本核对 |
ORDER BY 表达式 |
不能 | 排序键不是索引列 |
-- 范围条件截断后续列:status 只能过滤,不能定位
SELECT * FROM orders WHERE shop_id = 42 AND created_at > '2026-01-01' AND status = 1;
-- (shop_id, status, created_at) 只用上前两列;把 created_at 提前则 status 用不上
所以列序没有绝对答案:同一张表的不同查询形态要求不同列序。索引设计是对着查询清单做取舍。
6. 索引失效的六种常见情形
| 情形 | 例子 | 说明 |
|---|---|---|
| 列上套函数 | DATE(col)='2026-01-01' |
改为范围条件 |
| 隐式类型转换 | 字符串列传数字、排序规则不一致 | 类型与字符集对齐 |
| 前缀模糊 | LIKE '%abc' |
只能全扫;LIKE 'abc%' 可用 |
| OR 混用 | 一侧有索引、一侧没有 | 可能退化为全表 |
| 不等于 | !=、NOT IN |
通常无法定位区间 |
| NULL 判断 | IS NULL / IS NOT NULL |
依版本与统计信息而定,需实测 |
-- 改成范围,让索引可用
WHERE created_at >= '2026-01-01' AND created_at < '2026-01-02'
-- 隐式转换:列是 varchar 就不要传数字
WHERE order_no = '202601010001' -- 不是 = 202601010001
提醒:IS NULL 在多数版本可以走索引,真正的问题是 NULL 比例的估算偏差;判据是 rows 与 filtered。
7. 冗余索引、数量边界与写读比
两个索引存在包含关系就是冗余:(a) 被 (a,b) 完全覆盖。冗余不占查询便宜,只占写入与空间。
| 形态 | 是否冗余 | 处理 |
|---|---|---|
(a) 与 (a,b) |
是,前者冗余 | 删 (a) |
(a,b) 与 (b,a) |
否,服务不同前缀 | 按查询清单保留 |
(a,b) 与 (a,c) |
否 | 可考虑合并为 (a,b,c) |
-- MySQL 自带冗余索引检测(按版本核对可用性)
SELECT * FROM sys.schema_redundant_indexes;
SELECT * FROM sys.schema_unused_indexes; -- 需开启索引使用统计
| 写读比 | 索引数量倾向 | 理由 |
|---|---|---|
| 读极多、写极少 | 可适当多,优先覆盖 | 读路径收益高 |
| 读写均衡 | 控制在必要集合 | 每个索引都是一次写放大 |
| 写极多、读极少 | 只留主键与唯一约束 | 索引维护成本压过查询收益 |
数量没有普适阈值,"单表五到八个"只是经验量级;真正的约束是每次写入维护几个索引页与优化器选错的概率。
8. 统计信息、分区与上线安全
执行计划依赖统计信息;信息失真时同一句 SQL 会今天走索引、明天全表扫,这就是计划漂移。
ANALYZE TABLE orders; -- 更新统计信息
SHOW INDEX FROM orders; -- 看 Cardinality 是否明显偏离
-- 分区裁剪要求分区键出现在条件里,否则扫全部分区
SELECT * FROM orders PARTITION (p202601) WHERE created_at >= '2026-01-01';
分区表还有一条硬约束:唯一索引必须包含分区键。这意味着"按 id 唯一"与"按时间分区"不能同时满足,设计时要先选一个。
线上加索引的风险在锁与时长:
ALTER TABLE orders ADD INDEX idx_new (shop_id, status),
ALGORITHM=INPLACE, LOCK=NONE; -- 显式声明,失败即报错而不是退化成锁表
上线检查清单
1. 只读副本或影子表验证耗时(按行数量级估算)
2. 低峰执行,设超时与回滚预案
3. 大表用 pt-online-schema-change / gh-ost,先确认磁盘余量
4. 变更后核对 SHOW INDEX 与执行计划
9. 验证:估算与实际差多少
EXPLAIN 给的是估算,EXPLAIN ANALYZE 给的是实际执行。两者差距大,说明统计信息或列序假设错了。
| 信号 | 含义 | 动作 |
|---|---|---|
| 估算远小于实际 | 统计过期或数据倾斜 | ANALYZE TABLE,看直方图 |
| 实际远小于扫描 | 索引没定位好,读了再过滤 | 调列序或补覆盖列 |
Using filesort 仍在 |
排序键与索引顺序不符 | 改索引或改查询 |
| 回表高于预期 | 覆盖列不足 | 加列或拆查询 |
10. 排错:五种"加了索引还是慢"的情况
| 现象 | 更可能的原因 | 处理 |
|---|---|---|
| 加了索引计划没变 | 优化器认为全表更便宜 | 看 rows 与选择性,必要时强制索引验证 |
| 单条快、并发慢 | 索引页争用或缓冲池不足 | 看 IO 与锁等待 |
| 范围查询越往后越慢 | 深分页,扫描随 offset 增长 | 改游标分页 |
| 写入变慢 | 索引过多导致写放大 | 清理冗余与未使用索引 |
| 计划忽好忽坏 | 统计信息漂移或参数化查询 | 固定统计策略,检查绑定参数 |
11. 边界:索引可能不是答案
| 场景 | 结论 | 替代方向 |
|---|---|---|
| 表只有几千行 | 全表扫可能更快 | 不要为小表堆索引 |
| 写极多、读极少 | 索引是纯成本 | 只留主键与唯一约束 |
| 查询形态不定 | 索引追不上 | 改查询、改模型或上检索 |
| 条件全是低选择性列 | 收益趋零 | 预聚合或宽表 |
| 多列任意组合过滤 | 组合爆炸 | 列式存储/搜索引擎 |
一句话:索引是访问路径的选择,不是慢查询的通用解。当查询形态本身有问题(深分页、SELECT *、无界范围),先改查询;当模型本身有问题,先改模型。索引只在"路径选错"这一层有效。
自测
- 看执行计划时,为什么先看
rows而不是先看key? - 联合索引
(a,b,c)对WHERE b=?为什么完全用不上? - 低选择性列放在联合索引前面,什么情况下反而是对的?
Using index与Using index condition的本质差别是什么?- 范围条件为什么会截断后续列的定位能力?这对列序意味着什么?
- 什么情况下应该停止加索引,转而去改查询或改数据模型?