我刚开始接触MySQL的时候,对索引的理解特别肤浅——就知道“给表加个索引,查询就快了”,至于为什么快、什么时候该加、加了以后会不会有副作用,完全是一笔糊涂账。直到有一次在生产环境里给一张上千万行的订单表加了索引,结果写入性能骤降,被运维同事提醒之后,我才老老实实地把索引这层窗户纸捅破。这篇是零基础系列的第六篇,专门聊MySQL索引,不讲虚的,从数据结构到实操建索引,再到真实的坑,一次讲透。
1. 先搞清楚索引为什么会“快”:B+树和它的工作方式
很多人学索引,上来就是背“索引是B+树”,然后背完了还是一脸懵。我建议换个思路:先理解索引到底解决了一个什么问题,再去看B+树这个数据结构,你会觉得它简直是顺理成章的设计。
1.1 没有索引的时候,MySQL是怎么查数据的
假设你有一张用户表,里面存了100万条记录,现在要查 WHERE user_name = '张三' 这样的记录。如果没有索引,MySQL只能做全表扫描——从第一行数据开始,一行一行地读,把每一行的 user_name 字段取出来,跟你给的条件比对。这就像在一本没有目录的书里找一个词,只能从第一页翻到最后一页,运气好翻到第一页就找到了,运气不好翻到最后一页才找到。
全表扫描的时间复杂度是O(n),数据量小的时候无所谓,比如几千行,几十毫秒就扫完了。可一旦数据量到了百万级、千万级,这项操作就会变得不可接受,查询直接跑到几秒甚至几十秒。
1.2 B+树怎么解决了全表扫描的问题
B+树本质上是一个多路平衡查找树。跟传统的二叉树不同,B+树的一个节点可以存很多个元素,而且“矮胖”——高度很低。一个三层的B+树,轻轻松松就能存上千万条索引记录。
查询的时候,从根节点开始,逐层往下,每一层都通过二分查找确定下一步应该走哪个分支。三层B+树意味着最多做三次磁盘I/O就能定位到目标数据的位置。磁盘I/O是数据库性能的命门,减少I/O次数就是减少等待时间。
B+树还有两个关键特点:
- 数据只存在叶子节点,非叶子节点只存索引键值,这样单个节点能放下更多键值,树更矮,查询更稳定。
- 叶子节点之间通过双向链表相连,方便范围查询。比如查
WHERE age BETWEEN 20 AND 30,只要定位到20的位置,然后顺着链表往右一路读就行,特别适合范围查询和排序。
我用一个生活化的类比:B+树就像是图书馆的索引卡片柜,你要找某本书,先按大类找到对应的抽屉,再按作者姓氏找到对应的卡片,最后根据卡片上的书架编号直接过去拿书,不需要在几十排书架里一圈圈找。
1.3 为什么不是哈希索引
MySQL的InnoDB存储引擎其实支持自适应哈希索引,但默认的主索引结构是B+树。原因也很简单:哈希索引只能做等值匹配,也就是 WHERE user_name = '张三' 这种查询,速度极快,但它做不了范围查询,也做不了排序,更不支持部分前缀匹配。B+树虽然等值查询比不上哈希的O(1),但面对真实业务里大量的范围查询、排序、分组操作,B+树的综合优势非常明显。
所以你在设计索引时,如果业务场景是纯等值查询,可以考虑哈希索引或者带哈希特征的中间表;但凡涉及范围、排序、模糊匹配,B+树的索引才是正解。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 实操部分:MySQL里索引的完整操作手册
概念讲完了,现在动手。这一节我按“怎么建、怎么看、怎么删、怎么改”的顺序,把索引的常用SQL全部过一遍,每条都会配实际场景说明。这部分其实是零基础读者最容易卡住的地方——不是语法不会,而是不知道每条语句到底用在什么场合。
2.1 创建索引:CREATE INDEX和CREATE TABLE内定义
在已存在的表上创建索引,语法如下:
sql复制CREATE INDEX idx_user_name ON user(user_name);
这条命令会在 user 表的 user_name 字段上创建一个名为 idx_user_name 的普通索引。普通索引是最基础的索引类型,它的作用只是加速查询,不限制字段的唯一性。
如果是新表,可以在创建表的时候直接定义索引字段:
sql复制CREATE TABLE user (
id INT NOT NULL AUTO_INCREMENT,
user_name VARCHAR(50) NOT NULL,
age INT,
PRIMARY KEY (id),
INDEX idx_user_name (user_name)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
这里我顺手定义了主键索引,InnoDB表强烈建议有主键。为啥?因为InnoDB的B+树聚集索引是按照主键来组织数据的,如果没有主键,InnoDB会找一个没有空值的唯一列当主键,找不到还会隐式生成一个6字节的rowid。与其让MySQL帮你生成,不如自己定一个,既可控又清晰。
2.2 唯一索引:既加速又防重复
唯一索引的特点就是字段值不能重复,但这并不要求字段必须是主键。例如用户邮箱,业务上不允许重复,又经常被作为查询条件:
sql复制CREATE UNIQUE INDEX uk_user_email ON user(email);
一个很常见的疑问是:主键索引和唯一索引有什么区别?
- 主键索引一定是唯一索引,一个表只能有一个主键索引。
- 唯一索引可以有很多个,允许NULL值,但NULL值只能有一个(在MySQL的默认规则中,唯一索引允许一个NULL)。
- 主键索引的叶子节点存储的是整行数据(聚集索引),唯一索引和普通索引的叶子节点存储的是主键值(二级索引)。
记住这个区别非常关键,它直接决定了你做的操作是“回表”还是“覆盖扫描”。
2.3 查看索引:搞清楚索引到底建没建上
经常有同事跟我说“我明明建了索引,查询还是慢”,结果一查,索引压根没生效。查看一张表的所有索引:
sql复制SHOW INDEX FROM user;
这条命令会输出这张表上的所有索引信息,包括索引名、字段名、字段顺序、唯一性、基数(Cardinality)、排序规则等。我最关心两个地方:
Key_name索引名称,看有没有重复的索引。Seq_in_index字段在索引中的顺序,组合索引就靠这个字段判断各列的位置。Cardinality基数,值越大说明这个字段的区分度越高,索引价值越大。
如果你是在命令行下,还可以用 SHOW CREATE TABLE user\G 直接看建表语句里包含了哪些索引定义,这种方式更直观。
2.4 删除索引:清理冗余索引
删索引的语法很简单:
sql复制DROP INDEX idx_user_name ON user;
不过这里我想多说一句:删索引之前,一定要确认这个索引是不是真的没用了。最稳妥的做法是先在测试环境把索引删掉,跑一遍业务的核心查询语句,对比执行计划和查询耗时,确认没有影响再在生产环境操作。我不止一次看到有人删了索引之后,某条平日不走量的后台查询突然变慢,最后又灰溜溜地把索引加回来。
2.5 修改索引:没有直接改的语法
MySQL实际上没有“ALTER INDEX”这种直接改索引定义的语句。如果你想改一个索引的字段或者顺序,操作步骤是先DROP再CREATE:
sql复制ALTER TABLE user DROP INDEX idx_user_name;
ALTER TABLE user ADD INDEX idx_user_name_age (user_name, age);
这个场景在组合索引的调整中特别常见,比如你原来建了 (user_name, age),后来发现业务改成了 (age, user_name) 先过滤,那就得删掉旧的,重新建新的。
3. 实战中的索引设计:这五个原则决定索引有没有用
索引不是建得越多越好,也不是随便拿个字段建一下就完事。这一节讲的这几个原则,是我在实际项目中反反复复用到的判断标准,每一个背后都有真实的踩坑经历。
3.1 区分度:字段值重复率越低,索引越值得建
“区分度”是指字段中不同值的比例。计算公式是:
code复制区分度 ≈ COUNT(DISTINCT column) / COUNT(*)
如果区分度接近1,说明这个字段的值几乎不重复,比如订单号、身份证号,这类字段建索引效果非常好。如果区分度很低,比如性别字段只有“男”“女”两个值,区分度约为 2/100万,建了索引基本没用——因为通过索引查出来的数据量还是很大,MySQL优化器算一算成本,觉得还不如直接全表扫描呢。
我遇到过最典型的低区分度字段是“状态字段”,比如订单状态,只有待支付、已支付、已发货、已完成等几个取值。很多人习惯给状态字段建索引,但实际上对大量已完成的订单来说,这个索引的选择性太差,查询时MySQL宁愿走全表扫描也不走索引。
提示:区分度低的字段要不要建索引,看业务是否高频。如果某个状态在表里占比极小(比如“退款中”状态只有几千条,而全表有一千万),那么给这个低占比状态建索引反而可能有用,因为能快速圈定一个小集合。
3.2 覆盖索引:查询的字段全部在索引里
覆盖索引是一个极易被忽略但收益很大的优化点。所谓覆盖索引,就是查询的所有字段都能在索引里直接拿到,不需要回表。
举个例子:
sql复制CREATE INDEX idx_age ON user(age);
SELECT user_name FROM user WHERE age = 25;
这个查询里,age 是索引字段,但 user_name 不在索引里。MySQL通过索引定位到主键之后,还要根据主键回表读取完整数据行,才能拿出 user_name。回表意味着多一次随机I/O。
但如果把索引改成:
sql复制CREATE INDEX idx_age_user_name ON user(age, user_name);
那么 age 和 user_name 都在索引里,查询只需要扫索引就返回结果,不需要回表。在数据量大的场景下,这种优化能省掉大量随机I/O,查询性能提升非常明显。
所以设计索引时,你可以先把高频查询的 WHERE 字段列出来,再把 SELECT 需要用到的字段也列出来,看能不能用组合索引把“过滤条件+查询字段”都包含进去,让索引“覆盖”查询。
3.3 前缀索引:大字段怎么建索引
如果某个字段是VARCHAR(200)甚至TEXT,整个字段建索引会占用大量空间,而且索引树会变得很宽,性能反而下降。这时可以用前缀索引,只取字段的前N个字符来建索引:
sql复制CREATE INDEX idx_user_name_prefix ON user(user_name(10));
表示只取 user_name 前10个字符建索引。它的代价是:如果两个用户名的前10个字符相同,索引就可能冲突,查询时会有更多回表。所以N的选择需要评估:一般先统计一下不同前缀长度下的区分度,找到一个既能保留足够区分度又不会太长的临界值。
计算方式举例:
sql复制SELECT COUNT(DISTINCT LEFT(user_name, 5)) / COUNT(*) AS prefix_diff_5,
COUNT(DISTINCT LEFT(user_name, 10)) / COUNT(*) AS prefix_diff_10,
COUNT(DISTINCT user_name) / COUNT(*) AS full_diff
FROM user;
观察不同前缀长度的区分度,选择接近完整字段区分度的最短前缀长度,这就是合适的N。
3.4 组合索引:字段顺序决定了生死
组合索引是重点中的重点。很多人以为“给多个字段建了索引,查询的时候不管哪个字段都能加速”,这是错误的。组合索引的本质是“先按第一个字段排序,再按第二个字段排序,再按第三个字段排序”,就像电话簿先按姓氏排,再按名字排。
这个结构决定了最左前缀原则:查询条件里必须以组合索引的最左字段开头,索引才能生效。
sql复制CREATE INDEX idx_user_age ON user(user_name, age);
WHERE user_name = '张三'—— 走索引,没问题。WHERE user_name = '张三' AND age = 25—— 走索引,效果最好。WHERE age = 25—— 不能走索引。因为索引先按user_name排,直接按age查,B+树根本没法定位。
所以组合索引字段顺序的确定,核心依据是你最常使用的查询模式。把等值查询的字段放前面,把范围查询的字段放后面,也是常见的经验法则,因为范围查询会中断索引后续字段的匹配。
3.5 冗余索引:看着无害,实则是负担
所谓冗余索引,就是多个索引的字段存在重复或者包含关系。比如你有一个 idx_user_name,又建了 idx_user_name_age,因为后者已经包含 user_name,前者就是冗余的。冗余索引不仅浪费磁盘空间,还会拖慢INSERT、UPDATE、DELETE的写入速度,因为每次写入都要维护多个索引树。
在建索引的时候,我习惯先把这张表已有的索引全部列出来,再决定新索引是否真的有必要。MySQL 5.7以上的版本可以通过 sys.schema_redundant_indexes 这个视图直接查询冗余索引:
sql复制SELECT * FROM sys.schema_redundant_indexes;
4. 踩坑记录:最常遇到的索引失效场景和排查方法
这一节是实战中最值钱的部分。我遇到过太多次“明明建了索引,但查询依然慢”的案例,绝大多数原因是索引失效。下面我把最常踩的几个坑列出来,每个都会给出具体的错误写法和正确写法。
4.1 对索引字段使用函数
这是一个高频坑。你的SQL这么写,索引就会失效:
sql复制SELECT * FROM user WHERE DATE(create_time) = '2025-01-01';
因为 create_time 被 DATE() 函数处理过了,索引列不再是原始值,MySQL无法直接使用B+树查找。正确做法是对查询条件的原始值做范围匹配:
sql复制SELECT * FROM user
WHERE create_time >= '2025-01-01 00:00:00'
AND create_time < '2025-01-02 00:00:00';
一条原则:不要让索引列参与任何运算或者函数处理,尽量保持索引列是裸列。
4.2 隐式类型转换
这个坑往往藏在看似正确的SQL里,很难一眼发现。比如 user_phone 字段在表里的类型是VARCHAR,但你在查询时传入了数字:
sql复制SELECT * FROM user WHERE user_phone = 13800138000;
MySQL会自动把字符串字段转成数字来比较,相当于在索引列上用了隐式转换函数,索引就会失效。解决方法是让类型完全匹配:
sql复制SELECT * FROM user WHERE user_phone = '13800138000';
我在实际开发中发现,这个问题在Java后端特别普遍,因为很多时候参数类型是从请求里透传进来的,没有显式控制类型。
4.3 LIKE模糊查询:前导通配符会导致索引失效
WHERE user_name LIKE '%张%' 这种写法,因为通配符在最前面,B+树无法确定起始查找位置,所以不会走索引。但 WHERE user_name LIKE '张%' 是可以用索引的,因为字符串排序时,所有以“张”开头的记录是连续排在一起的,B+树能定位到起点。
如果业务真的需要前导模糊查询,我建议用全文索引或者外部搜索引擎方案,在MySQL层面硬走LIKE再加上前导通配符,只能全表扫描。
4.4 OR条件:可能导致索引失效
sql复制SELECT * FROM user WHERE user_name = '张三' OR age = 25;
如果 user_name 和 age 上都有单独索引,MySQL有可能使用索引合并(index_merge),但更常见的情况是优化器选择放弃索引,直接全表扫描。稳妥的做法是拆成两条SQL用UNION合并,或者改用组合索引让OR两边的条件都走索引。
4.5 判断条件为NULL的情况
WHERE email IS NULL 或者 WHERE email IS NOT NULL 在MySQL的B+树索引结构下,大多数时候是无法有效走索引的。因为索引里存储的NULL值通常在树的某一端,优化器无法精确快速定位。如果业务中确实需要经常按某个字段是否为空来过滤,建议用一个默认值(比如空字符串或0)来替代NULL,然后对这个字段建索引,查询效率会更好。
4.6 慢查询排查思路
聊完失效场景,我分享一下实际的排查流程,这套方法我在多个项目里验证过,效率很高:
- 先用
EXPLAIN查看执行计划,关注type列。如果看到ALL,说明是全表扫描;如果看到ref或range,说明走索引了。 - 看
key列,确认实际用到的索引名称是不是你预期的那一个。 - 看
rows列,估计扫描的行数。如果扫描行数接近全表行数,即使使用的是索引,优化器也可能认为不值得。 - 看
Extra列,如果出现Using filesort,说明排序没有用到索引;出现Using temporary,说明查询创建了临时表。
EXPLAIN 是吃透索引的最强工具,没有之一。我建议每个初学者都拿几条日常SQL去跑一下,看看执行计划里的各个字段到底代表什么,慢慢就有感觉了。
5. 进阶方向:组合索引的细节与索引的代价
当你能熟练建索引、看执行计划、排查失效场景之后,已经可以解决大部分业务问题了。这一节再补几个进阶方向,帮你在索引的理解上再上一个台阶。
5.1 最左前缀原则的“边界”理解
有些人对最左前缀理解得太死,以为组合索引 (a, b, c) 中查询条件必须包含 a 且只能从 a 开始。实际上:
a必须出现在WHERE条件中,否则索引无法使用。b不一定必须出现。如果只有a,索引可以加速。- 但
b一旦出现,且条件里没有a,索引整体失效。 - 如果
a和c出现、b没出现,那索引只能用到a,c无法参与索引定位。
举个例子,查询 WHERE a = 1 AND c = 3,组合索引 (a, b, c) 只能帮助定位到 a = 1 的区间,然后在这个区间里过滤 c = 3,本质上是回表或扫描这个区间里的所有记录。所以设计组合索引时,查询中频繁使用的字段尽量都放在索引的前缀序列里。
5.2 索引下推:MySQL 5.6的隐藏加速
MySQL 5.6开始引入索引下推(Index Condition Pushdown,ICP),这个特性对组合索引的查询优化帮助很大。
原理是这样:没有ICP的时候,MySQL使用索引定位到一条记录,就会马上回表读取完整数据行,然后在服务层对记录做WHERE条件的过滤。有ICP之后,MySQL会在存储引擎层就使用索引中的其他字段来做初步过滤,减少回表次数。
举个例子,组合索引是 (user_name, age),查询是:
sql复制SELECT * FROM user WHERE user_name LIKE '张%' AND age = 25;
如果没有ICP,MySQL先通过 LIKE '张%' 定位到所有“张”姓用户,再回表读取每一行,然后逐行过滤 age = 25。有了ICP,由于 age 也在这个组合索引里,MySQL就能在索引遍历时先判断 age = 25,只对符合条件的数据回表。回表次数减少,性能自然提升。
这个优化是MySQL内部自动完成的,不需要改SQL。但如果你用 EXPLAIN 看执行计划,Extra 列会显示 Using index condition,这就说明ICP生效了,可以放心。
5.3 索引不是免费的:写入性能的代价
最后必须强调的是,索引不是白来的,它有几个隐藏成本:
- 存储空间。每多一个索引,就多一棵B+树,磁盘空间和内存缓冲池都会被占用。
- 写入维护成本。每次INSERT、UPDATE、DELETE,需要同步维护所有索引树。索引越多,写入越慢。
- 优化器选择成本。索引太多时,MySQL优化器在选择索引时也需要更多计算,极端情况下会选错索引。
所以在实际系统里,我一般遵循这个原则:单表索引数量控制在5个以内,核心业务表也不超过7个。优先考虑用组合索引满足多条件查询,而不是给每个字段单独建索引。写多读少的表索引更要克制,读多写少的表可以适当多建索引来加速查询。
关于索引和排序:如果查询中经常出现 ORDER BY age,而年龄字段上有索引,MySQL可以利用索引的有序性直接返回结果,避免 Using filesort。这也意味着你把范围查询字段放在组合索引的最后,不会影响前面字段的索引匹配,还有机会让ORDER BY直接走索引。不过这个属于更深入的优化技巧,零基础阶段可以先了解,等遇到排序慢的实际问题再回头研究。
6. 实践建议:给你的表和SQL做一次索引体检
如果你现在手头有正在开发的数据库,我建议你按下面这个流程做一次索引健康检查,整个过程半小时内能搞定。
先找出耗时最长的慢查询,这个可以开启MySQL慢查询日志,也可以直接在业务监控里捞TopSQL。对每一条慢SQL,用 EXPLAIN 看执行计划,重点看 type、key、rows 和 Extra 四列。
如果发现某条高频SQL是全表扫描,不要急着加索引,先确认WHERE字段是哪些,看这些字段值是否适合建索引。比如区分度很低的状态字段,或者直接查询了整张表的绝大部分数据,这些情况加索引意义都不大。如果字段适合建索引,那就按照前面讲的原则,设计一个组合索引,尽量把过滤字段和查询字段都覆盖进来。
加完索引之后,再用 EXPLAIN 验证一次,对比前后的 rows 和 Extra 变化。也可以跑一下真实查询,比较实际耗时。千万注意,索引变更一定要在低峰期操作,大表加索引会锁表,InnoDB虽然支持在线DDL,但还是建议先在小表上实验,确认无误再对生产环境操作。
最后分享一个我个人的习惯:每次建索引或者改索引,我都会在数据库设计文档里记一笔,写清楚这个索引是为了解决哪条SQL、当时的查询耗时是多少、加完之后降到了多少。这个习惯看起来不起眼,但等到半年后同事来问你“这个索引能不能删”的时候,它能帮你快速判断索引的真实价值,不用重新分析一遍。
