2993 字
约 9 分钟
21
S11-A09 · 索引体系设计:从查询形态到索引组合
2024-12-31
2026-11-09

"该加索引的地方都加了,还是慢"——这句话暴露的是把索引当成开关:以为加上就快,没加就慢。索引真正的作用只有一个:减少需要读的行数与页数。它不改变查询逻辑,只改变访问路径;路径不对,加多少个索引都不解决问题。

这篇从查询形态反推索引组合:什么条件下联合索引比多个单列强、覆盖与回表怎么权衡、什么时候索引本身就是错的答案。


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 *、无界范围),先改查询;当模型本身有问题,先改模型。索引只在"路径选错"这一层有效。

自测

  1. 看执行计划时,为什么先看 rows 而不是先看 key?
  2. 联合索引 (a,b,c) 对 WHERE b=? 为什么完全用不上?
  3. 低选择性列放在联合索引前面,什么情况下反而是对的?
  4. Using index 与 Using index condition 的本质差别是什么?
  5. 范围条件为什么会截断后续列的定位能力?这对列序意味着什么?
  6. 什么情况下应该停止加索引,转而去改查询或改数据模型?
S11-A09 · 索引体系设计:从查询形态到索引组合
https://www.clxhxhhr.top/posts/6155/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。