“给这个字段加个索引”几乎是性能优化讨论中出现频率最高的一句话。但索引不是性能魔法:有的索引能让查询从秒级降到毫秒级,有的索引建了却完全没被使用,还有的索引会拖慢写入并占用大量磁盘。
要真正用好 MySQL 索引,关键不是记住几个 DDL 语句,而是理解它在做什么:索引用额外的存储空间和写入维护成本,换取更少的数据访问次数。
本文以 MySQL 的 InnoDB 引擎为主线,结合常见业务表和 EXPLAIN,讲清楚索引为何更快、联合索引如何设计,以及如何定位“明明有索引却不生效”的问题。
一、没有索引时,MySQL 如何查数据?
先看一张简化的订单表:
1 | CREATE TABLE `orders` ( |
现在要查询某位用户最近的已支付订单:
1 | SELECT id, total_amount, created_at |
如果 user_id、status、created_at 都没有索引,MySQL 就只能逐行读取表数据,判断每一行是否满足条件。这种方式称为全表扫描(full table scan)。
当表只有几百行时,全表扫描往往没有问题;但订单表增长到千万级后,即使只有极少数行满足条件,也可能需要检查大量无关记录。真正慢的通常不是“比较条件”本身,而是读取和访问大量数据页的成本。
索引的意义,就是提前组织一份更适合查找的数据目录,让数据库先缩小范围,再读取需要的行。
二、索引的本质:一本按规则排序的目录
可以把数据表理解成一本很厚的书:
- 没有目录时,找“Redis”这个词只能从第一页翻到最后一页;
- 有了按拼音排序的目录,可以先定位到“R”附近,再找到页码;
- 最后只翻到对应页面读取正文。
数据库索引也是类似思路。它会保存“索引列的值”和“如何定位数据行的信息”,并按照某种结构组织。查询条件如果符合这份目录的排序规则,就能快速跳过大批无关数据。
注意两个常被混淆的结论:
- 索引并不是复制一整张表,而是额外的数据结构;
- 索引不是越多越好。每一次
INSERT、UPDATE、DELETE都可能需要同步维护相关索引。
三、为什么 InnoDB 大多使用 B+ 树索引?
MySQL 的 InnoDB 中,最常见的普通索引、唯一索引、主键索引,底层通常都是 B+ 树。不需要手写 B+ 树,但理解它能解释很多索引规则。
1. 先排除两个直觉方案
数组或有序链表:有序数组查找快,但插入中间代价高;链表插入容易,定位却慢。它们都不适合频繁读写的大表。
二叉搜索树:理论上查找复杂度是 O(log n),但树可能长得很高,且一个节点通常只放很少的值。数据库数据在磁盘或页缓存中,树高意味着更多随机 I/O。
2. B+ 树的关键优势
B+ 树的每个节点可以保存大量键值和子节点指针,因此分叉很多、树高很低。即使索引中有千万条记录,通常也只需要访问少量层级的数据页。
此外,B+ 树的叶子节点按顺序连接,非常适合范围查询:
1 | SELECT * |
找到 7 月 1 日的起点后,数据库可以沿叶子节点顺序读取到 8 月 1 日为止,而不用为每一条记录重新从根节点查找。
B+ 树的重点不是“比较次数少”,而是“用很少的数据页访问定位到连续的数据范围”。这正适合磁盘和缓存页的工作方式。
四、聚簇索引与二级索引:InnoDB 最重要的结构
理解 InnoDB 索引,必须先区分两类索引。
1. 聚簇索引(Clustered Index)
InnoDB 的表数据本身就存放在主键索引的叶子节点中。因此主键索引被称为聚簇索引:叶子节点不仅有主键值,还存有整行数据。
1 | SELECT * FROM orders WHERE id = 1001; |
如果按主键查询,MySQL 可以直接沿着主键 B+ 树找到那一行完整数据,这类查询通常非常高效。
InnoDB 会优先选择以下内容作为聚簇索引:
- 显式声明的主键;
- 第一个非空唯一索引;
- 若两者都没有,则生成隐藏行 ID。
因此,业务表应显式设计稳定、尽量短的主键。常见的自增 BIGINT 简单且对写入友好;UUID 也能使用,但随机 UUID 可能让新记录分散插入 B+ 树的各个位置,带来更多页分裂和空间碎片。
2. 二级索引(Secondary Index)
除了主键以外的索引,通常称为二级索引。例如:
1 | CREATE INDEX idx_orders_user_id ON orders(user_id); |
二级索引的叶子节点保存的不是整行数据,而是索引列值 + 主键值。因此下面这条查询通常分两步:
1 | SELECT total_amount |
- 在
idx_orders_user_id中找到满足user_id = 10086的主键; - 再根据主键回到聚簇索引取出
total_amount。
第二步叫回表。回表不是错误,只是当结果集很大时会增加随机访问成本。后文的覆盖索引,就是减少回表的一种手段。
五、索引的常见类型与适用场景
1. 主键索引
1 | PRIMARY KEY (id) |
主键唯一且不能为空,每张 InnoDB 表只有一个。它决定了聚簇索引的组织方式。
2. 普通索引
1 | CREATE INDEX idx_orders_created_at ON orders(created_at); |
用于加速筛选、排序和关联,允许重复值。
3. 唯一索引
1 | CREATE UNIQUE INDEX uk_users_email ON users(email); |
除了加速查询,它还把“邮箱不能重复”变成数据库层的约束。对于用户名、第三方平台用户 ID、业务幂等号等,唯一索引是正确性的重要保障。
4. 联合索引
1 | CREATE INDEX idx_orders_user_status_created |
一个索引包含多个列,并且列顺序非常重要。它是业务查询优化中最常用、也最容易设计错的一类索引。
5. 全文索引与空间索引
全文检索可使用 FULLTEXT,空间地理数据可使用 SPATIAL。它们有独立的适用边界;常规的等值、范围、排序查询,讨论的仍主要是 B+ 树索引。
六、联合索引与最左前缀原则
假设有下面这个联合索引:
1 | KEY idx_orders_user_status_created (user_id, status, created_at) |
可以把它理解为先按 user_id 排序;同一 user_id 内再按 status 排序;两者都相同的记录再按 created_at 排序。
| 查询条件 | 能否有效利用该索引 | 原因 |
|---|---|---|
WHERE user_id = ? |
可以 | 命中最左列 |
WHERE user_id = ? AND status = ? |
可以 | 连续命中前两列 |
WHERE user_id = ? AND status = ? AND created_at >= ? |
可以 | 连续命中三列 |
WHERE status = ? |
通常不理想 | 缺少最左列 user_id |
WHERE created_at >= ? |
通常不理想 | 缺少前面的列 |
这就是常说的最左前缀原则:联合索引需要从最左边的列开始,按连续前缀使用。
一个常见误解:WHERE 条件的书写顺序不等于索引顺序
下面两条 SQL 的优化器通常会得到相同效果:
1 | WHERE user_id = 10086 AND status = 2 |
MySQL 优化器会重排可交换的条件。真正重要的是索引定义里的列顺序,而非你在 SQL 中把哪个条件写在前面。
联合索引如何确定列顺序?
没有放之四海皆准的口诀,应结合真实查询设计。一个实用起点是:
- 优先考虑高频查询的筛选条件;
- 等值匹配列通常放在范围条件前;
- 结合
ORDER BY、GROUP BY看能否避免额外排序; - 让一个索引服务一组相似查询,而不是为每条 SQL 机械建一个索引;
- 通过
EXPLAIN和真实数据分布验证,而非只凭“区分度高的列在前”。
例如“查询某用户某状态下的最近订单”常见写法是:
1 | CREATE INDEX idx_orders_user_status_created |
在支持降序索引的 MySQL 版本中,这能更贴合倒序读取;即使不显式声明 DESC,也应实际验证执行计划是否消除了额外排序。
七、覆盖索引:为什么 SELECT * 往往不是好习惯
如果查询所需的列全部存在于某个二级索引中,MySQL 可以只扫描索引而无需回表,这叫覆盖索引。
仍使用索引 (user_id, status, created_at):
1 | SELECT id, created_at |
id 是二级索引叶子节点中天然携带的主键,created_at 也在索引内,因此这条查询有机会只读二级索引。EXPLAIN 的 Extra 常会显示 Using index。
反过来:
1 | SELECT * |
SELECT * 很可能要求读取索引中不存在的列,从而产生回表。它还会增大网络传输、让字段变更隐式影响接口返回。因此,生产代码应尽量显式选择真正需要的字段。
不要为了覆盖每一种查询而把大量列塞进联合索引。索引越宽,占用空间越大、写入越慢、缓存命中率越低。覆盖索引是有针对性的优化,而不是默认策略。
八、索引失效的常见原因
“索引失效”并不总是索引真的坏了。更准确地说,是当前 SQL 不能高效地利用索引顺序,或优化器估算全表扫描成本更低。
1. 对索引列做函数或表达式计算
1 | -- 不理想:需要对每一行计算 YEAR |
同理,WHERE DATE(created_at) = '2026-07-10'、WHERE price * 100 > 999 都可能难以使用普通索引。可改写查询,或在合适版本使用函数索引/生成列后再建立索引。
2. 模糊查询以通配符开头
1 | WHERE name LIKE '%张%' |
B+ 树按前缀有序,%张 前面没有确定前缀,无法定位起点。LIKE '张%' 则通常可以利用索引。真正的全文搜索需求,应考虑全文索引或搜索引擎,而不是期望普通索引解决任意包含匹配。
3. 隐式类型转换
1 | -- phone 是 VARCHAR,但传入未加引号的数字 |
当列类型与参数类型不一致,MySQL 可能对列进行转换,进而影响索引使用,也可能带来意外匹配。应用层应让绑定参数与列类型一致:
1 | WHERE phone = '13800138000' |
4. OR、!=、NOT IN 不一定不能用索引,但要警惕
这类条件可能让选择性变差或让优化器选择其他计划。例如 status != 2 通常会匹配绝大多数行,走索引再回表未必比扫描表更快。遇到慢查询时应看实际执行计划,不要机械地认为“出现 OR 就一定不能用索引”。
5. 范围条件后面的列不一定还能用于精确定位
1 | WHERE user_id = 10086 |
对索引 (user_id, created_at, status) 来说,user_id 可等值定位,created_at 可做范围扫描;范围后的 status 是否还能继续参与索引过滤,取决于版本、索引条件下推等优化,但通常不能再像连续等值列那样缩小 B+ 树的定位范围。索引列顺序应围绕查询模式慎重安排。
九、用 EXPLAIN 看懂一条查询
优化索引前后都应该执行 EXPLAIN。例如:
1 | EXPLAIN |
常见字段可以这样理解:
| 字段 | 关注点 |
|---|---|
type |
访问方式。const、eq_ref、ref、range 通常优于 ALL;但不能只看这一列。 |
possible_keys |
优化器认为理论上可选的索引。 |
key |
实际选用的索引;为 NULL 不一定是错误,但值得分析。 |
key_len |
实际使用的索引长度,可辅助判断联合索引利用到了哪些列。 |
rows |
预估需要检查的行数,越少通常越好。 |
Extra |
Using index 表示可能覆盖索引;Using filesort、Using temporary 常提示排序或分组有优化空间。 |
一个健康的执行计划不等于必须“所有指标完美”。例如查询本来就需要返回全表大部分数据时,ALL 可能是合理选择;强行走低选择性索引反而更慢。优化的目标是降低整体成本,而不是追求某个字段看起来漂亮。
在 MySQL 8.0 中,必要时可进一步使用:
1 | EXPLAIN ANALYZE |
它会执行查询并给出实际耗时与行数,能帮助发现“估算与现实不一致”的情况。在线上使用前要注意:它不是纯分析命令,复杂查询本身会真实执行。
十、从慢查询到索引优化:一个完整例子
假设运营后台有一个订单列表:
1 | SELECT id, user_id, total_amount, created_at |
如果这是一条高频查询,可考虑:
1 | CREATE INDEX idx_orders_status_created |
原因是:
status是等值条件,先缩小到已支付订单;created_at用于时间范围,并承担排序;id作为主键会包含在二级索引叶子节点中;- 若还需
user_id、total_amount,是否把它们加入索引,要根据数据量、写入频率和是否值得避免回表判断。
如果 status = 2 占表中 95% 的记录,单独使用 status 的选择性很差;但与时间范围组合后,联合索引仍可能有价值。反之,如果查询时间范围很宽、又需要返回大量行,优化器也可能认为扫描更便宜。结论必须由数据和 EXPLAIN 验证。
十一、索引设计的常见误区
1. “每个 WHERE 字段都建一个单列索引”
这会导致索引数量膨胀。MySQL 有时能做索引合并(Index Merge),但它通常不如一个合适的联合索引高效,还会增加写入负担。索引应围绕高频 SQL 的过滤、排序、分组与关联组合来设计。
2. “区分度越高的字段一定放最前面”
区分度很重要,但不是唯一标准。若查询固定为 WHERE tenant_id = ? AND created_at BETWEEN ...,把租户隔离列放在前面常常更符合访问模式,即使它的区分度不如其他列。等值、范围、排序、查询频率和数据分布都要综合考虑。
3. “加索引只影响读,不影响写”
新增索引后,每次插入都要写入索引记录,更新索引列时还可能删除旧索引项、插入新索引项。高写入表上的冗余索引,会直接影响吞吐、磁盘空间和备份恢复时间。
4. “有主键就不需要其他索引”
主键只擅长按主键查询。按邮箱查用户、按用户查订单、按时间筛选日志,都需要匹配业务访问路径的索引。
5. “强制索引总能解决问题”
FORCE INDEX 是诊断或特殊场景下的工具,不应成为常规修复方式。它把执行计划绑定到当前数据分布;数据量和统计信息变化后,今天的最优选择可能成为明天的负担。应先检查 SQL、索引设计、统计信息和返回行数。
十二、上线与维护建议
索引优化不仅是写一条 CREATE INDEX,还应考虑变更风险:
- 在测试或预发环境用接近生产的数据验证执行计划和耗时;
- 大表加索引可能占用较长时间和额外资源,评估 MySQL 版本的在线 DDL 能力与锁影响;
- 保留慢查询日志,按执行次数、平均耗时、扫描行数排序处理;
- 定期检查重复索引、冗余索引和长期未使用索引,但删除前必须确认没有隐藏查询依赖;
- 对分页列表优先考虑“基于游标/最后一条记录”的翻页,避免深分页的
LIMIT 100000, 20。
深分页之所以慢,是因为数据库即使最终只返回 20 行,也可能需要定位并跳过前面的十万行。对于按时间倒序的列表,更可控的方式是携带上一页最后一条记录的 (created_at, id) 作为游标:
1 | SELECT id, user_id, created_at |
相应索引应与排序和筛选字段匹配,例如 (user_id, created_at, id)。
结语
索引之所以快,不是因为它让数据库“更努力地计算”,而是因为它让数据库“不必看那么多无关数据”。B+ 树降低定位成本,联合索引匹配真实访问路径,覆盖索引减少回表;而 EXPLAIN 则帮助我们用证据而不是直觉验证优化结果。
最实用的工作方式是:先找出真实慢 SQL,再理解其筛选、排序和返回字段,然后设计尽量小且贴合查询的索引,最后用执行计划和真实数据复核。索引设计从来不是一次性的背诵题,而是持续围绕业务访问模式做出的工程权衡。