本文以 MySQL 8.0、InnoDB 为主要范围;历史案例保留原记录的观察值,不将其作为当前版本或其他数据集的性能承诺。
一、为什么需要索引:从磁盘 I/O 说起
"给这个查询加个索引就好了。"——这句话说起来简单,但如果不理解索引为什么有效,就无法判断什么时候该加、怎么加、以及加了为什么还是慢。
答案藏在磁盘里。
页访问与缓存决定查找成本
传统机械磁盘的随机访问需要寻道与旋转,SSD 没有相同的机械过程,二者不能共用固定延迟表。数据库也不是每行触发一次磁盘 I/O:InnoDB 按页组织数据,并由 Buffer Pool 缓存;顺序扫描与随机取页的成本不同。
B+Tree 的价值是让一个节点容纳多个导航键,以较小的高度定位目标叶页。内部页常被复用,缓存命中时无需读磁盘。索引并不只是“少走几层”,还要避免读取大量无关记录和反复回表。
二、B+Tree 如何组织一次查询
B+Tree 的三个关键设计决策
[17 | 35] ← 非叶子节点:只存键值,不存数据
/ | \
[8|12] [26|30] [60|75] ← 非叶子节点
/ | \ / | \ / | \
[3,5][9,10][13,15][28,29][36][60][75,79][90,99] ← 叶子节点:存储实际数据
↔ ↔ ↔ ↔ ↔ ↔ ↔ ← 叶子节点横向链表
决策一:内部节点存放用于导航的键和子页引用。 不把完整行放在内部节点,可以提高扇出。默认 16 KiB 页中的有效容量还要扣除页头、记录头和目录等开销,不能直接用 16384/14 推出固定扇出。
决策二:数据全部下沉到叶子节点。 树的叶子处于相同深度,但缓存命中、范围大小、回表与版本检查仍会改变实际耗时。
决策三:叶子节点之间用双向链表连接。 范围查询(如 WHERE id BETWEEN 100 AND 200)只需定位到起点,然后顺着链表遍历,不用回到树根。
用明确假设估算容量
若一棵三层树的内部节点平均扇出为 f,叶页平均放 r 条记录,其量级可估为 f²×r。这里 f、r 都受键长、行格式、页占用和数据分布影响,不是固定常数。高度也不等于物理 I/O 次数,还要考虑缓存和页外字段。
短主键同时减少聚簇树导航键和二级索引中附带主键的空间。UUID 可以采用二进制等存储形式,并非必然占 36 字节;具体选型还要满足生成、排序和业务引用要求。
关于 B+Tree、B-Tree、LSM-Tree 等存储引擎数据结构的理论细节,参见《存储引擎核心数据结构:B-Tree 家族与 LSM-Tree 的设计权衡》。本文聚焦 MySQL 层面的索引使用和优化。
三、InnoDB 索引的存储结构
理解了 B+Tree 的通用原理后,还需要理解 InnoDB 对 B+Tree 的具体实现方式——它直接决定了"回表"的代价和覆盖索引的价值。
聚簇索引 vs 非聚簇索引
InnoDB 和 MyISAM 在索引的组织方式上有根本区别:
| 索引组织 | 叶子项与数据的关系 |
|---|---|
| MyISAM | 索引项引用独立数据文件中的记录 |
| InnoDB 聚簇索引 | 叶子保存行记录 |
| InnoDB 二级索引 | 保存二级键及定位聚簇记录所需的主键 |
InnoDB 的聚簇索引:主键和数据存在一起。聚簇索引叶子保存行记录,较长的字段也可能使用页外存储。这意味着按主键查找只需一棵 B+Tree。
InnoDB 的二级索引:叶子节点存储的不是数据的物理地址,而是主键值。通过二级索引查找时,先在二级索引树中找到主键值,再到主键索引树中找到完整数据——这个过程叫回表。
回表的代价
需要从二级索引取回索引外列时,通常还要查聚簇索引;匹配多条记录时,这不是固定两次页面访问。
SELECT * FROM users WHERE name = '张三';
-- 假设 name 上有索引
步骤 1:在 name 索引树中查找 '张三' → 得到主键 id = 42
步骤 2:在主键索引树中查找 id = 42 → 得到完整行数据(回表)
如果查询返回大量行,每一行都要回表一次,性能会急剧下降。
覆盖索引:避免回表
如果索引包含查询所需的列,就有机会避免为取列而回表;MVCC 可见性检查等因素仍可能访问聚簇记录。这就是覆盖索引(Covering Index)。
-- 索引:INDEX idx_name_age (name, age)
-- 需要回表:SELECT * FROM users WHERE name = '张三'
-- 索引里没有 email、address 等列,必须回表取完整数据
-- 覆盖索引:SELECT name, age FROM users WHERE name = '张三'
-- 索引里就有 name 和 age,直接返回,不用回表
-- EXPLAIN 的 Extra 列会显示 "Using index"
为什么 InnoDB 必须有主键
InnoDB 的数据组织方式决定了它必须依赖一个聚簇索引。如果你没有显式定义主键:
- InnoDB 会选择第一个非空唯一索引作为聚簇索引
- 如果也没有,InnoDB 会自动生成一个 6 字节的隐藏 RowID
自动生成的 RowID 用户不可见、不可查询,浪费了聚簇索引的优势。通常优先考虑短、稳定的显式主键。自增整数有利于顺序插入,但页满时仍需扩展或分裂;全局生成、数据分片等需求也可能要求其他方案。
四、索引的使用规则
建了索引不代表查询一定会用。MySQL 优化器在决定是否使用索引时有一套严格的规则。搞清楚这些规则,才能建出真正有效的索引。
4.1 最左前缀匹配(最重要的规则)
复合索引按键的字典序组织。构造连续查找区间时,等值前缀和后续范围条件最容易发挥作用;范围之后的列仍可能参与索引条件下推、覆盖和过滤,不等于完全无效。MySQL 8.0 在符合条件时还可以采用 skip scan,不能把缺少首列写成索引绝对不可用。范围优化
-- 索引:INDEX idx (a, b, c, d)
WHERE a = 1 AND b = 2 AND c > 3 AND d = 4
-- a/b/c 通常用于界定连续区间,d 仍可参与过滤或索引条件下推
WHERE a = 1 AND b = 2 AND d = 4
-- a/b 构成前缀区间;缺少 c 时,d 不能直接接成长等值前缀
WHERE b = 2 AND c = 3
-- 缺少 a,通常不能直接按左前缀定位;仍需检查覆盖扫描或 skip scan 等计划
对以等值过滤后扫描范围为主的查询,可以评估将范围列放在等值列之后;还要兼顾其他查询、排序和索引长度。
-- 查询:WHERE a = 1 AND b = 2 AND c > 3 AND d = 4
-- 差索引:INDEX (a, b, c, d) → 只用 a, b, c
-- 好索引:INDEX (a, b, d, c) → 用到 a, b, d, c 四列全命中
4.2 选择性(Selectivity)
选择性衡量一个列能过滤掉多少数据:
选择性 = COUNT(DISTINCT col) / COUNT(*)
这个比例描述去重值数与行数的关系,不能用 0.1 或 0.01 作为统一建索引阈值。要看具体查询值命中多少记录,以及返回和排序需要付出的成本。
反直觉案例:低选择性也可能有效。 如果一个 status 字段只有 3 个值(-1, 0, 1),但业务上 99.9% 的记录是 status=1,你要查的恰好是 status=0 的那一小批——索引的效果取决于你要查的值的分布,而不是列整体的选择性。
关键洞察:选择性公式给出的是统计平均,但实际查询命中的是具体值的分布。对于数据分布极度不均匀的列,需要结合业务场景判断。
4.3 五个设计检查点
① 不要在索引列上做计算或函数调用
-- 普通原列索引通常不能直接服务以下函数条件
WHERE DATE(FROM_UNIXTIME(create_time)) = '2024-05-29'
WHERE YEAR(created_date) = 2024
-- 若目标是同一会话时区的一整天,可评估改写为半开区间
WHERE create_time >= UNIX_TIMESTAMP('2024-05-29')
AND create_time < UNIX_TIMESTAMP('2024-05-30')
WHERE created_date >= '2024-01-01' AND created_date < '2025-01-01'
普通索引保存的是原列顺序。函数索引或生成列索引可支持相应表达式,需核对版本、表达式匹配和时区等语义,不能把改写仅当成语法替换。
② = 和 IN 的顺序不影响索引使用
-- 以下两种写法等价,优化器会自动重排
WHERE a = 1 AND b = 2 AND c = 3
WHERE c = 3 AND a = 1 AND b = 2
③ 评估重叠索引,不直接删除旧索引
-- 已有索引:INDEX idx_a (a)
-- 现在需要查 WHERE a = ? AND b = ?
-- idx_ab 可服务不少原有 a 前缀查询
-- 是否替换 idx_a 要核对唯一约束、索引宽度、其他查询和上线变更成本
④ 尽量用覆盖索引减少回表
对高频查询评估覆盖索引,同时计算新增列带来的存储和写入成本,不为 SELECT * 盲目复制整行。
⑤ 先看访问模式,再看单列选择性
当所有前缀条件都是等值时,简单交换它们不一定减少最终扫描量。哪些查询能复用前缀、是否需要范围和排序,通常比“选择性最高的永远在前”更有区分度。
五、EXPLAIN:读懂优化器的决策
索引建好了,查询到底用没用、怎么用?EXPLAIN 提供计划证据;在允许实际执行的环境中,EXPLAIN ANALYZE 可进一步核对实际行数、循环次数与时间。还要观察缓存、锁等待和系统负载。
EXPLAIN SELECT * FROM users WHERE name = '张三' AND age > 20;
核心字段解读
| 字段 | 含义 | 关注点 |
|---|---|---|
| type | 访问类型 | 观察访问方式,不把类型排序当成独立的性能结论 |
| key | 实际使用的索引 | NULL 表示没走索引 |
| rows | 预估扫描行数 | 估算值,需结合过滤、循环次数和实际执行核对 |
| Extra | 附加信息 | 关注 Using index / Using filesort / Using temporary |
type 等级详解
| type | 含义 | 触发条件 | 性能 |
|---|---|---|---|
| const | 通过主键或唯一索引定位一行 | WHERE id = 1 |
极快 |
| eq_ref | JOIN 时被驱动表主键等值匹配 | JOIN ON a.id = b.id |
极快 |
| ref | 非唯一索引等值匹配 | WHERE name = '张三' |
快 |
| range | 索引范围扫描 | WHERE id > 100 / WHERE id IN (1,2,3) |
较快 |
| index | 全索引扫描 | 覆盖索引但无 WHERE 条件 | 一般 |
| ALL | 全表扫描 | 无合适索引或优化器选择扫描 | 小表或大量取数时可能合理 |
Extra 中的关键信号
| Extra | 含义 | 是否需要优化 |
|---|---|---|
| Using index | 使用覆盖索引路径 | 仍可能扫描大量索引项 |
| Using where | 在存储引擎返回数据后由 Server 层过滤 | 看情况 |
| Using filesort | 需要额外排序,不表示必定落盘 | 结合数据量和实际成本判断 |
| Using temporary | 需要内部临时表 | 检查规模与内存/磁盘使用,未必值得消除 |
实战口诀:
- 看到
ALL→ 考虑加索引 - 看到
Using filesort→ 检查 ORDER BY 是否能走索引 - 看到
Using temporary→ 检查 GROUP BY 是否能走索引 rows远大于实际结果行数 → 索引选择性不够或索引列不对
六、ORDER BY 与 GROUP BY 的索引优化
排序和分组是慢查询的常见元凶。MySQL 能利用索引的有序性避免额外排序(filesort),但条件很严格。
6.1 ORDER BY 能走索引的条件
-- 场景一:纯 ORDER BY
-- 索引 (sort_col)
SELECT * FROM t ORDER BY sort_col; -- 索引有机会提供顺序,最终由成本估算决定
-- 场景二:WHERE + ORDER BY
-- 索引 (col_a, sort_col)
SELECT * FROM t WHERE col_a = 1 ORDER BY sort_col; -- 索引有机会提供顺序,最终由成本估算决定
-- 场景三:多列排序
-- 索引 (uid, x, y)
SELECT * FROM t WHERE uid = 1 ORDER BY x, y LIMIT 10; -- 索引有机会提供顺序,最终由成本估算决定
过滤条件可以在扫描后应用,ORDER BY 也可能单独利用排序索引;是否值得如此取决于选择性、LIMIT 和回表成本。不能要求所有 WHERE 列必须与排序列出现在同一索引。排序优化
6.2 ORDER BY 不能走索引的五种情况
-- ① 排序列来自不同索引
-- 有 INDEX(key1) 和 INDEX(key2)
ORDER BY key1, key2 -- ✗ 两个索引无法合并排序
-- ② 跳过了复合索引的中间列
-- INDEX(key_part1, key_part2)
WHERE key_part1 = 1
ORDER BY key_part2 -- ✓ 连续的
WHERE other_col = 1
ORDER BY key_part2 -- ✗ key_part1 缺失
-- ③ ASC 和 DESC 混用
-- INDEX(a, b)
ORDER BY a ASC, b DESC -- MySQL 8.0 可评估对应的混合方向索引
-- ④ WHERE 和 ORDER BY 用了不同索引的列
-- INDEX(key1), INDEX(key2)
WHERE key1 = 1 ORDER BY key2 -- ✗ 走 key1 索引做过滤,但无法用它排序 key2
-- ⑤ 排序列上有函数
ORDER BY YEAR(login_date) -- ✗ 函数破坏了索引有序性
6.3 GROUP BY + Top-N 查询模式
"每个分组取前 N 条"是常见的业务需求。几种实现方式的对比:
方案一:子查询 + MAX(取每组最大值,保留并列)
-- 每组取最大值
SELECT a.* FROM tb a
WHERE val = (SELECT MAX(val) FROM tb WHERE name = a.name)
ORDER BY a.name;
方案二:INNER JOIN + GROUP BY(同样只取最大值,不是任意 Top-N)
SELECT a.* FROM tb a
INNER JOIN (SELECT name, MAX(val) val FROM tb GROUP BY name) b
ON a.name = b.name AND a.val = b.val
ORDER BY a.name;
方案三:窗口函数(MySQL 8.0+,最简洁)
-- ROW_NUMBER:严格排名,不并列
SELECT * FROM (
SELECT *, ROW_NUMBER() OVER (PARTITION BY subject ORDER BY score DESC, id) rn
FROM tb_score
) t WHERE rn <= 3;
-- DENSE_RANK:允许并列,无间隔
SELECT * FROM (
SELECT *, DENSE_RANK() OVER (PARTITION BY subject ORDER BY score DESC) rk
FROM tb_score
) t WHERE rk <= 3;
| 函数 | 处理并列 | 示例:分数 92, 92, 88 |
|---|---|---|
| ROW_NUMBER | 不并列 | 1, 2, 3 |
| RANK | 并列,有间隔 | 1, 1, 3 |
| DENSE_RANK | 并列,无间隔 | 1, 1, 2 |
七、慢查询优化实战
以下三个历史案例保留原稿给出的查询与观察值。原始测试环境、表定义和计划未完整附出,因此这些数字只说明原记录中的效果,不代表本次复现实验或通用提升倍数。
优化前先记录实验条件
记录版本、表和索引定义、样本分布、计划、缓存状态及多次执行结果。MySQL 8.0 已移除查询缓存,不再用 SQL_NO_CACHE 作为通用测试步骤;Buffer Pool 仍会影响冷热读差异。分析执行计划时也要明确 EXPLAIN ANALYZE 会真的运行查询。
7.1 案例一:JOIN 重构——从 1.87s 到 10ms
原始查询:查找最近一段时间内有更新的员工。
SELECT DISTINCT cert.emp_id
FROM cm_log cl
INNER JOIN (
SELECT emp.id emp_id, emp_cert.id cert_id
FROM employee emp
LEFT JOIN emp_certificate emp_cert ON emp.id = emp_cert.emp_id
WHERE emp.is_deleted = 0
) cert
ON (cl.ref_table = 'Employee' AND cl.ref_oid = cert.emp_id)
OR (cl.ref_table = 'EmpCertificate' AND cl.ref_oid = cert.cert_id)
WHERE cl.last_upd_date >= '2013-11-07 15:03:00'
AND cl.last_upd_date <= '2013-11-08 16:00:00';
问题诊断:
- 结果:53 条记录,耗时 1.87 秒
- EXPLAIN 显示:cm_log 用
idx_last_upd_date过滤后只有 379 行 - 但 JOIN 的派生表(cert)返回 63,727 行
- 379 × 63,727 ≈ 2,400 万次比较,绝大多数是无用功
待核对的执行解释:OR 关联可能使有效索引访问困难,但仅凭两个行数相乘不能确定实际比较次数或断言产生笛卡尔积,需要核对当时计划。
优化方案:拆成两条查询 + UNION,让小表 cm_log 先过滤。
SELECT emp.id FROM cm_log cl
INNER JOIN employee emp
ON cl.ref_table = 'Employee' AND cl.ref_oid = emp.id
WHERE cl.last_upd_date >= '2013-11-07 15:03:00'
AND cl.last_upd_date <= '2013-11-08 16:00:00'
AND emp.is_deleted = 0
UNION
SELECT emp.id FROM cm_log cl
INNER JOIN emp_certificate ec
ON cl.ref_table = 'EmpCertificate' AND cl.ref_oid = ec.id
INNER JOIN employee emp ON emp.id = ec.emp_id
WHERE cl.last_upd_date >= '2013-11-07 15:03:00'
AND cl.last_upd_date <= '2013-11-08 16:00:00'
AND emp.is_deleted = 0;
结果:10ms,提升 187 倍。
可迁移的思路:拆分不同关联路径,分别评估索引和计划。这里 UNION 还保留了原查询的去重语义;不能不经检查改成 UNION ALL。
7.2 案例二:低选择性索引——从 6.22s 到 200ms
原始查询:查找待同步的 POI 数据。
SELECT * FROM stage_poi sp
WHERE sp.accurate_result = 1
AND sp.sync_status IN (0, 2, 4);
问题诊断:
- 结果:951 条记录,耗时 6.22 秒
- EXPLAIN:type = ALL,全表扫描 361 万行
- 两个字段的选择性都极低:
accurate_result:只有 -1, 0, 1 三个值sync_status:只有 0, 1, 2, 3, 4 五个值
按常规判断,这两列的选择性太差,不适合建索引。
转折点:理解业务上下文。
这是一个数据同步任务,每 5 分钟执行一次:
- 处理状态为 0/2/4 的记录(待同步)
- 处理完毕后将状态改为 1(已同步)
- 在任意时刻,待同步的数据不超过 1000 条,其余 360 万条都是 status=1
也就是说,虽然 sync_status 只有 5 个值,但 你要查的值的数据量只占 0.03%。
优化方案:
ALTER TABLE stage_poi ADD INDEX idx_acc_status(accurate_result, sync_status);
结果:200ms,提升 31 倍。
教训:数据分布比选择性统计更重要。在数据严重倾斜的场景下,低选择性的列也能从索引中获益。
7.3 案例三:改写收益依赖匹配率
原始查询:分页查询联系人。
SELECT c.id, c.name, c.position, c.sex, c.phone
FROM contact c
INNER JOIN contact_branch cb ON c.id = cb.contact_id
INNER JOIN branch_user bu ON cb.branch_id = bu.branch_id
INNER JOIN org_emp_info oei ON oei.data_id = bu.user_id
WHERE bu.status IN ('0', '1')
AND oei.node_left = 2875 AND oei.node_right = 10802
AND oei.org_category = -1
ORDER BY c.created_time
LIMIT 0, 10;
问题诊断:
- 结果:10 条记录,耗时 13.06 秒
- 单表索引都没问题,JOIN 行数也合理
- 但 JOIN 结果有 77.8 万行,然后对这 77.8 万行排序取前 10 条
尝试优化:改写为 EXISTS 子查询。
SELECT c.id, c.name, c.position, c.sex, c.phone
FROM contact c
WHERE EXISTS (
SELECT 1 FROM contact_branch cb
INNER JOIN branch_user bu ON cb.branch_id = bu.branch_id
INNER JOIN org_emp_info oei ON oei.data_id = bu.user_id
WHERE c.id = cb.contact_id
AND bu.status IN ('0', '1')
AND oei.node_left = 2875 AND oei.node_right = 10802
AND oei.org_category = -1
)
ORDER BY c.created_time LIMIT 0, 10;
原记录的结果:一组参数显示 0ms,这表示未超过当时计时粒度,不是零成本;另一组匹配 0 行的参数耗时 218 秒。
可能的机制:若计划按 created_time 扫描联系人,再逐行验证 EXISTS,低匹配率会迫使它扫描更多候选。LIMIT 10 不意味着底层固定每次取 10 行;必须由实际计划确认这一解释。
更关键的是语义:原 JOIN 可能为同一联系人返回多行,EXISTS 则只判断有无匹配。只有业务确实要唯一联系人、并处理重复与排序关系时,二者才可这样比较。
最终结论:不是所有慢查询都能在 SQL 层面解决。 当 JOIN 结果集巨大且排序字段不在过滤条件中时,需要在应用层寻找出路——比如预计算排序、异步分页、或改变产品交互方式。
八、分页查询优化
深度分页是一个高频性能问题。LIMIT 100000, 20 看起来只取 20 条,实际上 MySQL 需要扫描前 100,020 行,丢弃前 100,000 行。
四种优化方案
方案一:基于主键翻页(最推荐)
-- 前端传入上一页最后一条记录的 id
SELECT * FROM users WHERE id > 456891 ORDER BY id LIMIT 20;
-- 避免重复跳过之前各页;MVCC 与实际数据仍会影响访问量
限制:只能"下一页",不能跳页。适合瀑布流、无限滚动。
方案二:子查询定位起点
SELECT * FROM users
WHERE id >= (SELECT id FROM users ORDER BY id LIMIT 100000, 1)
ORDER BY id LIMIT 20;
-- 子查询走覆盖索引(只查 id),速度快
-- 外层查询从定位点开始,只扫描 20 行
方案三:反向查询
-- 如果总共 160 万行,要取 LIMIT 1200000, 20(偏移 75%)
-- 反向偏移 = 总数 - 原偏移 - 页大小 = 399980
-- ORDER BY id DESC LIMIT 399980, 20,取回后再按 id 升序展示
-- 扫描量从 120 万降到 40 万
前提是总数、过滤集合和分页读取基于一致的数据视图,且排序唯一;否则并发变化会使页码映射漂移。
方案四:延迟关联
-- 先查主键列表(走覆盖索引,无回表)
SELECT a.* FROM users a
INNER JOIN (SELECT id FROM users ORDER BY id LIMIT 100000, 20) b
ON a.id = b.id
ORDER BY a.id;
子查询只在索引上操作,外层 JOIN 只回表 20 行。
| 方案 | 扫描行数 | 可跳页 | 适用场景 |
|---|---|---|---|
| 基于主键翻页 | 约等于 pageSize | 不可以 | 瀑布流、列表翻页 |
| 子查询定位 | 索引扫描 + pageSize | 可以 | 通用分页 |
| 反向查询 | 取决于距尾部的距离 | 可以 | 已知一致总数与唯一排序 |
| 延迟关联 | 索引扫描 + pageSize | 可以 | 需回表的分页 |
九、索引设计决策指南
该不该建索引
| 场景 | 建议 | 原因 |
|---|---|---|
| WHERE 条件中的等值查询列 | 建 | 直接命中 |
| 范围过滤 | 评估等值前缀后的范围列 | 后续列仍可能参与过滤与覆盖 |
| JOIN 关联字段 | 按连接方向与计划评估 | 索引嵌套循环、哈希连接等路径成本不同 |
| ORDER BY 字段 | 考虑和 WHERE 列组成复合索引 | 避免 filesort |
| 高频查询但选择性低的列 | 看数据分布 | 统计选择性不等于实际过滤效果 |
| 很少出现在 WHERE 中的列 | 不建 | 索引的写入代价大于查询收益 |
索引过多的代价
索引不是免费的。每多一个索引:
- 每次 INSERT 需要额外维护一棵 B+Tree(写入变慢)
- 每次 UPDATE 涉及索引列时需要更新索引
- 每个索引都占磁盘空间
- 相近候选与统计偏差可能影响选择;先核对统计和成本,再谨慎评估索引提示
按工作负载评估每个索引的读收益、写放大和空间成本,清理有证据的冗余;不以固定数量作为所有表的上限。
核心原则
索引优化应减少完成目标查询所需的总成本。估算行数是线索,实际访问量、回表、排序、锁等待和写入代价才共同决定收益;聚合查询返回一行也可能合理地读取大量数据。