1. 索引到底解决什么问题:先建立分类的坐标系
做MySQL开发这些年,我有个深刻的感受:所有性能问题,十有八九最后都能追到索引——要么是没建索引,要么是建了索引但压根没走。而“mysql中索引分类”这道题,几乎每次技术面试都会出现,看着是送分题,真正能答全、答透的人却不多。
大部分人的知识储备停留在“主键索引、唯一索引、普通索引”这几个名字上,知道建索引能加速查询,但对B+树和Hash的区别说不清楚,对聚簇索引和二级索引的底层结构更是一头雾水。这其实不怪大家,因为日常开发中索引对我们是“黑盒”:建好了就快,没建就慢,我们很少有机会去审视索引本身到底在物理上长什么样、逻辑上被赋予了哪些约束。
所以这篇文章我想换一个角度,把“索引分类”拆成三个维度重新梳理一遍:
- 按数据结构分:B+树索引、Hash索引。回答的是“索引在物理上怎么存储、怎么查找”。
- 按存储结构分:聚簇索引、二级索引(非聚簇索引)。回答的是“数据行和索引的存放关系”。
- 按功能逻辑分:主键索引、唯一索引、普通索引、全文索引、空间索引。回答的是“索引被赋予了哪些约束,业务上能做什么”。
三条线不是互相独立的,而是同一个问题的三个侧面:一条索引在物理上长什么样,决定了它查询快不快、支持什么查询模式;它在逻辑上叫什么名字,决定了它有哪些额外约束。真正理解索引分类,不是去背名词,而是把这三个维度串起来,看到一个完整的逻辑链条。
这篇文章不预设你已经有很深的索引功底,但从索引机制推导到底层原理再到实操维护都会覆盖到。准备面试的人可以当复习提纲,日常被慢查询折磨的后端同学可以当避坑手册。下面正式开讲。
2. 数据结构维度:B+树索引和Hash索引的底层差异
2.1 B+树为什么能成为MySQL默认选择
B+树索引是MySQL中应用最广的索引结构,InnoDB存储引擎的默认索引就是B+树。要理解它为什么“能打”,先要搞懂B+树和普通二叉树的区别。
传统二叉查找树在极端情况下会退化成链表,查找效率从O(logN)降到O(N),在大数据量场景下不可接受。而B+树是多叉平衡树,每个节点可以存储多个键值,树的高度很矮。更关键的是,B+树的所有数据都存放在叶子节点,并且叶子节点之间通过链表相连,这一设计同时优化了两件事:单点查询和范围查询。
业内有一个经典的容量估算:InnoDB的页大小默认是16KB。假设表的主键是BIGINT(8字节),指针占6字节,那么一个非叶子节点大约可以存储 16KB / (8+6) ≈ 1170 个键值。三层高的B+树可以容纳约 1170 × 1170 × 16 = 2197万条数据。也就是说,一张两千多万行的表,只要走主键索引查找任意一行,最多只读三个磁盘页。这就是B+树在千万级数据量下依然能保持毫秒级响应的根本原因。
这一点和“MySQL为什么选B+树而不是红黑树”是同一个问题——红黑树虽然也是平衡树,但它的节点度数是2,同等数据量下树高比B+树高很多,意味着磁盘IO次数更多。而B+树的“矮胖”特性,天生就是为磁盘存储设计的。
2.2 Hash索引的高性能与致命短板
Hash索引的查找逻辑很直观:对索引列的值做Hash运算,得到的Hash值直接定位到存储位置,单条记录等值查询的复杂度是O(1),理论上比B+树的O(logN)还要快。
但Hash索引有个闻名的缺陷矩阵:
- 不支持范围查询。B+树的叶子节点是有序链表,可以连续扫描;Hash索引的存储位置是Hash散列的结果,和键值本身没有大小关系,所以
WHERE col > 100这种查询无论怎么优化都走不了Hash索引。 - 不支持排序。原因同上,无序。
- 不支持部分匹配。联合索引的Hash值基于所有索引列一起计算,无法只根据最左列定位数据。这和B+树联合索引的“最左前缀原则”直接冲突。
- 存在Hash冲突。多个不同键值算出了同一个Hash值,需要链地址法解决冲突,这会引入额外的读取开销。
这也是为什么InnoDB默认不用Hash索引的主要原因。但细心的读者可能听说过“自适应哈希索引”(Adaptive Hash Index)——这是InnoDB的运行时优化机制,当它检测到某个索引值被频繁等值访问时,会在B+树之上自动构建一个Hash索引来加速。注意,它是引擎内部自动维护的,用户无法干预,也无需手动创建。所以面试时如果被问到“InnoDB支持Hash索引吗”,标准回答是:InnoDB本身支持Hash索引的语法,但实际建成的是B+树;真正的Hash索引可以在Memory引擎中使用,或者通过InnoDB的自适应哈希索引间接享受其加速效果。
2.3 业务场景选型建议
从实战角度出发,绝大多数业务表都应该以B+树索引为主。Hash索引的应用场景非常窄,基本只有“等值查询密集、无范围需求、数据量可控”的缓存型数据才值得考虑。以我自己的经验,电商系统的订单详情查询虽然按order_id精确查询为主,但订单列表页往往还需要按时间排序、按状态过滤,这些操作B+树都能顺带搞定,而Hash索引一个都做不了。
见到过一些同学在设计“热数据表”时,为了追求那一点点等值查询的性能,把索引结构改成Hash,结果后面产品加了一个范围查询功能,整个表结构都要推倒重来。所以我的建议只有三个字:用B+树。除非你能明确说出来“这张表100%只有等值查询”,否则不要碰Hash索引。
3. 功能逻辑维度:主键索引、唯一索引、普通索引的职责分工
3.1 主键索引:InnoDB表的数据组织核心
主键索引在InnoDB里不只是“一个索引”这么简单,它直接决定了数据在磁盘上的物理组织形式。每一个InnoDB表都要求有主键,如果没有显式指定,InnoDB会选择一个非空的唯一索引作为主键,如果连唯一索引都没有,InnoDB会自动生成一个不可见的6字节rowid作为主键。
主键索引的约束是PRIMARY KEY,它要求列值唯一且非空。从功能分类的角度说,它是唯一索引的一种特殊形态,但它的地位远高于普通唯一索引:
- 它是聚簇索引的载体(第4章详述);
- 一张表只能有一个主键索引;
- 主键是行数据的“物理地址”,等值查询走主键通常意味着“直达”。
这里想强调一句:很多同学建表时没有显式主键的习惯,以为“有个唯一索引就够了”。这个认知在MyISAM时代还能将就,在InnoDB时代是很危险的——没有显式主键,InnoDB替你生成rowid,这个rowid对应用完全不可见,你连按主键查询的能力都没有,所有二级索引的回表也会出问题。建表第一件事,老老实实加一个自增主键。
3.2 唯一索引:业务约束与查询加速的双重身份
唯一索引(UNIQUE)要求索引列的值不能重复,但允许存在多个NULL值(MySQL对NULL的处理是“不参与唯一性判断”)。一张表可以有多个唯一索引。
在实际开发中,唯一索引最常见的用途是防止重复数据。举个例子:订单表的order_no业务订单号,理论上必须全局唯一。如果只靠应用层做幂等判断,并发环境下极容易出现两个请求同时通过了“是否存在”检查、然后同时插入两条相同订单号的数据。数据库层的唯一索引是最后一道真正可靠的防线。
但这里就引出一个经典问题:唯一索引和普通索引,在写入性能上有明显差异。
普通索引的写入可以通过InnoDB的Change Buffer机制进行优化——如果要更新的目标页不在缓冲池中,先把变更记录到Change Buffer,后续再异步合并回磁盘,避免了立即读盘的代价。而唯一索引为了保证唯一性约束,必须先读取目标页判断是否冲突,所以每次插入都要读目标页,Change Buffer在这种场景下完全无法发挥作用。
这个差异在高并发大批量写入的场景下会被放大到肉眼可见。我接手过一个导单系统,每天千万级的流水写入,最初业务表给流水号字段加了唯一索引,压测时写性能始终上不去;后来沟通后确认流水号的唯一性可以由上层分布式ID生成器保证,把唯一索引改成普通索引,再配合Change Buffer,写入吞吐提升了将近一倍。
当然,这不意味着唯一索引要少用——业务上的唯一性约束如果必须由数据库兜底,该加还是得加,性能问题可以通过设计来缓解。关键在于你要知道这个性能损耗发生在哪里,而不是蒙在鼓里。
3.3 普通索引与全文索引:日常使用与倒排索引
普通索引(INDEX/KEY)没有任何限制,纯粹为了加速查询。从分类角度它没什么好讲的,但从使用角度有两点经验值得分享:一是普通索引可以建立在多列上,形成联合索引(也叫复合索引),查询时遵循最左前缀原则;二是普通索引非常适合与ORDER BY、GROUP BY配合,B+树的有序性可以避免额外的文件排序。
全文索引(FULLTEXT)则是另一种数据结构实现——倒排索引。它的核心不是“键值→数据行”的映射,而是“单词→包含该单词的文档列表”。MySQL 5.6之前全文索引只有MyISAM支持,5.6开始InnoDB也支持了。需要注意的是,MySQL全文索引默认不支持中文分词,要支持中文搜索需要配置ngram插件。如果你在做一个需要中文全文搜索的站内搜索功能,优先考虑Elasticsearch而不是MySQL全文索引——这个结论我后面在实战章节里会再展开。
3.4 空间索引:地理数据的专属方案
空间索引(SPATIAL)用于GEOMETRY等空间数据类型的字段,基于R-Tree实现,主要服务于地理位置相关的业务。日常业务很少用到,但如果你做LBS应用,记住MySQL有这个东西,关键词字段加上空间索引,比用经纬度两个普通索引配合范围计算要高效得多。
4. 存储结构维度:聚簇索引与二级索引,InnoDB的绝对核心
4.1 聚簇索引:主键即数据,数据即主键
这是InnoDB区别于其他存储引擎的最大特点。聚簇索引的叶子节点直接存放的是完整的数据行,而不是指向数据行的指针。换句话说,表数据本身就是按照主键顺序组织的B+树。
这个设计的直接结果有两个:
- 主键索引的查询效率极高。
WHERE id = 100这个查询,在B+树中定位到叶子节点时,数据行已经在手里了,不需要二次定位。 - 数据行的物理存储顺序和主键的逻辑顺序基本一致。这保证了按主键范围查询时,磁盘读取是近似顺序IO,性能非常可观。
理解这一点后,很多面试题都有了答案。比如“为什么InnoDB表必须有主键”——聚簇索引是数据行的物理组织形式,没有主键InnoDB就无法组织这张表的数据。
4.2 二级索引:回表与覆盖索引
二级索引(也叫辅助索引/非聚簇索引),叶子节点存储的是索引列的值 + 主键值,而不是完整数据行。所以通过二级索引查询数据时,通常要做两步:
- 在二级索引B+树上找到主键值;
- 带着主键值回到聚簇索引B+树上,再查找完整数据行。
第二步就是大家常说的回表。回表不是必然发生的:如果查询需要的数据在二级索引的叶子节点上全部包含,那就不需要回表,这种索引就叫覆盖索引。
举个具体例子,表user(id, name, age, phone),建立联合索引(name, age):
-- 需要回表:查询列phone不在索引中 SELECT * FROM user WHERE name = '张三'; -- 不需要回表:id、name、age都在索引中,覆盖索引生效 SELECT id, name, age FROM user WHERE name = '张三';生产环境中,千万不要小看覆盖索引的优化威力。有一次优化一个报表查询,原来查询要回表读取全行数据,返回的SELECT列里有四个大字段,但实际只需要id和状态两个列。改造后建立覆盖索引,SQL从1.8秒降到0.03秒——只是因为你告诉MySQL“这几个数据我不用去主键树取了”。
4.3 主键设计如何影响聚簇索引的性能
这是聚簇索引分类知识在日常建表中最重要的落地场景。由于数据行按照主键顺序物理排列,主键的插入顺序直接决定了数据页的写入模式:
- 自增主键:新插入的行总是追加在B+树末尾,写入是顺序IO,几乎不发生页分裂,数据页利用率高。
- UUID等随机主键:新主键的值落在已有数据区间的任意位置,InnoDB需要不断调整B+树结构、移动数据,频繁触发页分裂,产生大量碎片,写入性能和空间利用率都会下降。
所以一句话:InnoDB表强烈推荐使用自增整数主键。这不是什么“最佳实践话术”,而是聚簇索引物理机制推导出来的必然结论。
我见过一个反例:一个用户表用业务生成的36位UUID字符串做主键,数据量刚过百万,写入就开始出现明显的性能抖动,表空间膨胀到正常预期的两三倍。后来迁移到自增主键,业务侧把UUID改成普通唯一索引,瞬间恢复平稳。这个案例中,业务唯一性由唯一索引兜住,主键只负责内部物理组织,两种索引各司其职,非常典型。
5. 索引失效:为什么索引分类学得再好,你的查询还是慢
5.1 六种最常见的索引失效场景
知道索引分类只是起点,真正考验功夫的是“建的索引到底走不走”。以下六种情况是我在线上环境反复见过的坑,每一类都对应一个典型查询语句:
1. 联合索引不满足最左前缀
联合索引说白了是“按列从左到右有序”的B+树。对(a, b, c)建立联合索引,查询中单独用b或c做条件,索引是不会走的。最左前缀原则要求查询条件必须包含最左边的列,而且跳跃也不行——比如条件包含a和c,那么只有a能用到索引排序,c只能作为回表后的过滤。
2. 范围查询右侧的列索引失效
对(a, b)联合索引,WHERE a = 1 AND b > 10这条SQL中,a能用到索引等值匹配,但b的范围条件之后如果还有第三个索引列,那第三个列就排不上队了。在MySQL 8.0.13及之后版本,部分范围场景有了改进(索引跳跃扫描),但业务上不要依赖这个新特性去赌。
3. LIKE前置模糊
WHERE name LIKE '%三',因为B+树按前缀有序,模糊匹配从中间开始无法利用有序性定位。但'三%'这种后置模糊就可以走索引。这一条可以直接记住:能用后置模糊就用后置模糊。
4. 对索引列使用函数或运算
WHERE DATE(create_time) = '2024-01-01'会让create_time的索引失效,因为索引中存储的是原始值,MySQL无法对索引值做函数变换后的匹配。正确做法是WHERE create_time >= '2024-01-01' AND create_time < '2024-01-02'。凡是索引列参与了运算、隐式转换,都归为这一类。
5. 隐式类型转换
最常见的是字符串列和数字比较。比如phone列是VARCHAR类型,但SQL写成WHERE phone = 13800138000,MySQL会把字符串列转换成数字再比较,索引直接失效。这一条隐蔽性很强,因为执行不报错,数据也能查出来,就是慢得离谱。
6. OR条件中带有非索引列
WHERE age = 18 OR name = '张三',即使age和name各自都有索引,如果age有索引而name没有,这个OR查询会退化成全表扫描,因为优化器无法把两个条件拆分成两个索引访问再合并。生产上多条件查询尽量用IN或UNION替代OR。
5.2 索引失效的连带效应:行锁为什么退化成表锁
索引失效带来的杀伤力不只是查询慢,在事务并发场景下还会引发更严重的锁问题。
InnoDB的行锁实现原理是基于索引的——锁定的不是整张表的“行”,而是索引记录。当UPDATE语句的WHERE条件能通过索引精确定位到某几行时,InnoDB锁住这几行即可;但当WHERE条件无法走索引、需要对全表扫描判断时,InnoDB实际上不得不对扫描到的所有行加锁,最终表现和表锁几乎一样,并发事务互相阻塞。
这是索引分类知识在实际业务中最重要的一个衍生价值:索引既是性能的保障,也是并发能力的保障。我遇到过不止一次线上死锁问题,根因排查到最后,都是因为某条UPDATE的索引条件写歪了,导致锁范围扩大。
5.3 优化器为什么不选你的索引
还有一个特别气人的场景:你明明建了索引,EXPLAIN一看,type还是ALL(全表扫描)。这通常不是索引失效,而是查询优化器基于成本评估后主动放弃了索引。
优化器会参考索引的基数(Cardinality)——索引值的分布数量。基数太低(比如性别字段只有男女两个值),走索引后还要回表取大量行,优化器算出全表扫描的成本反而更低;数据量很小的表(比如几百行),全表扫描的成本也远低于索引访问+回表。
遇到这种情况,先别急着甩锅给MySQL“不智能”。用ANALYZE TABLE 表名更新统计信息,或者检查是不是命中了上面6种失效场景。如果确认该走索引但优化器就是不走,在8.0里可以考虑索引提示FORCE INDEX,但这是最后的强手段,不建议到处滥用——正确做法是调整索引设计,让优化器自己愿意选它。
6. 索引的创建与维护:从建表到上线的全流程经验
6.1 各类索引的建表与ALTER语句
功能分类对应到SQL语句,其实非常直接:
-- 主键索引 CREATE TABLE user ( id BIGINT AUTO_INCREMENT PRIMARY KEY, user_no VARCHAR(32) NOT NULL, name VARCHAR(32), UNIQUE KEY uk_user_no (user_no), KEY idx_name (name), FULLTEXT KEY ft_content (content), SPATIAL KEY sp_loc (location) ) ENGINE=InnoDB;如果表已经建好了,通过ALTER TABLE补齐索引时给出完整的常用写法:
-- 普通索引 ALTER TABLE user ADD INDEX idx_name (name); -- 唯一索引 ALTER TABLE user ADD UNIQUE INDEX uk_user_no (user_no); -- 联合索引 ALTER TABLE user ADD INDEX idx_name_age (name, age); -- 主键(注意一张表只有一个主键) ALTER TABLE user ADD PRIMARY KEY (id); -- 删除索引 ALTER TABLE user DROP INDEX idx_name;关于联合索引的列顺序,我的经验是两条规则:等值条件的列放前面,范围条件的列放后面;区分度高的列优先放前面。这两条规则已经能覆盖90%的业务场景。
6.2 如何快速判断索引是否生效:EXPLAIN实操
排查索引问题,EXPLAIN是最基本的工具,没有之一。日常分析SQL,重点看几个输出列:
| 列名 | 关键值 | 含义 |
|---|---|---|
| type | system/const/eq_ref/ref/range/index/ALL | 访问类型,从优到劣,ALL是最差的全表扫描 |
| key | 实际使用的索引名 | 如果为NULL说明没有可用索引 |
| key_len | 字节数 | 联合索引实际用了前几列,数字越大说明用的列越多 |
| rows | 预估扫描行数 | 越小越好 |
| Extra | Using index / Using index condition / Using where | 是否用到覆盖索引、索引下推、行级过滤 |
一个高频考点是Extra中Using index condition,它对应MySQL的**索引下推(ICP)**优化:在联合索引内就过滤掉不满足条件的记录,减少回表次数。比如(name, age)联合索引,WHERE name LIKE '张%' AND age > 25这条SQL,在没有索引下推时,要把所有张%的记录都回表取出来再过滤age > 25;开索引下推后,age > 25在二级索引内就直接过滤了。5.6以后默认开启,这也是联合索引设计更加灵活的原因之一。
6.3 MySQL 8.0 的索引能力升级
如果还在用5.7,升级到8.0后你会明显感受到索引层面的几个变化:
- 降序索引:8.0真正支持索引列倒序存储,
ORDER BY col DESC可以完全走索引不产生文件排序。5.7时代虽然能建INDEX (col DESC),但优化器并不会使用它。 - 隐形索引:新增
INVISIBLE关键字,把一个索引先设为不可见,测试不依赖它时的性能表现,确认没问题后再真正DROP。这是线上“删除索引前验证影响”的绝佳手段。 - 函数索引:8.0.13起直接支持表达式索引,上面提到的
DATE(create_time)失效场景,可以直接建INDEX ((DATE(create_time)))解决。
这三个能力在做索引优化时非常实用,建议尽快用起来。
6.4 大表加索引的正确姿势
给千万级大表建索引,直接执行ALTER TABLE ADD INDEX可能会把表锁住较长时间,造成业务不可用。MySQL 5.6以后支持Online DDL,可以在DDL过程中保持表的读写能力,但实际操作中有几个参数值得注意:
ALTER TABLE user ADD INDEX idx_name (name), ALGORITHM=INPLACE, LOCK=NONE;ALGORITHM=INPLACE表示不复制整张表,直接在原表结构上修改;LOCK=NONE表示不阻塞DML。如果数据库版本较老,或者表结构包含特殊字段不支持INPLACE,可以考虑用pt-online-schema-change这类工具实现平滑变更。
还有一点很多人会忽略:加索引之前,先确认是否存在重复或冗余索引。比如已经建立了(a, b)联合索引,单独再建(a)索引基本就是冗余——前者完全覆盖后者的查询能力。多余的索引写数据时要同步维护,白白增加写入成本和占用空间。我习惯每季度梳理一次线上表的索引清单,把那些“计划中要建但后来没用上”的索引清理掉。一个基本判断标准:如果一张10GB的表加了一个业务上根本没走到的索引,那它带来的伤害就是每次INSERT/UPDATE都要额外多维护一棵B+树。
最后分享一个自己常用的收尾检查动作:每建完一个索引,立刻用EXPLAIN跑一遍目标SQL,确认key列是你期望的索引名、type不为ALL、key_len长度符合预期。整个过程不超过十秒,但能省掉数不清的线上排查时间。索引分类这些知识,最终价值都要落在这一步上——建对了、走对了、维护住了,才是真正把索引用明白了。