前一阵和候选人聊到MySQL索引,对方能把八股文里的“最左前缀”“回表”“覆盖索引”背得很熟,但当我追问“InnoDB为什么选B+树,而不是哈希索引或者普通B树”的时候,他一下子卡住了。这个场景太常见了。很多人缺的不是索引知识点,而是把底层数据结构和算法串起来的能力。今天这篇内容专门聊聊MySQL索引的底层结构与查找算法,适合正在准备MySQL面试的人、日常写SQL的后端开发,以及被线上慢查询折腾过的运维同学。我会尽量从InnoDB存储引擎的视角来讲,不聊那些“背下来就行”的结论。
1. 先想明白:索引究竟“省”在哪
1.1 没有索引的全表扫描是拿IO硬扛
先说一个最简单的用户表,里面有两千万行,表结构大概是 id、username、phone 这种。如果执行 SELECT * FROM user WHERE username = 'abc',而且 username 上没有索引,MySQL能怎么做?答案是只能把整张表的数据页从前往后扫一遍,逐个判断username是否等于目标值。
InnoDB的数据并不是无序堆放的,它底层是按主键组织成一棵B+树,叶子页里面存完整行记录。但“按主键有序”只能帮主键查询,对username没有任何帮助。为了找一条username,MySQL需要遍历所有叶子页,也就是全表扫描。假如一行数据1KB,一个默认16KB的数据页大约能存16行,两千万行就有125万多个叶子数据页。这个数量级的页遍历下来,哪怕数据都在Buffer Pool里,也足够把CPU和内存带宽打满;如果遇到冷数据要读磁盘,那对IO的伤害就更直接了。
全表扫描和索引查找的区别,就像在一个没有目录的大厚书里找一句话,和直接翻到书后面的术语索引去查页码的区别。书后面的术语索引本身就是额外印出来的内容,它不包含正文全部信息,但能告诉你某个关键词在哪个页码,这就是索引最朴素的形态。
1.2 索引本质是一套“空间换时间”的有序冗余结构
索引不是玄学,它本质上就是额外维护一份数据,让查询能快速定位。但为什么说它“有序”很关键?因为有序结构才能用上二分定位、范围扫描、排序优化这些算法。如果索引只是把列复制一份,但内部没有规则,那和全表扫没有区别。
标题里写“时间结构”,大概率是想说“数据结构”。不过这个说法倒也不算错:索引优化的本质,就是用选择好的数据结构去换查询时间。面试官问“索引底层是什么”,其实想考察的是你对B+树的时间复杂度、树高、磁盘IO模型有没有概念。比如一棵三层的B+树,可以支撑两千万行级别的数据,而一次查找只需要两三次磁盘IO,这才是索引能快的真正原因。
但索引不是免费的。每建一个索引,写入时都要额外维护一棵树;每多一个索引,INSERT和UPDATE的成本都会增加,磁盘占用也变大。索引策略本质上是在“读的加速”和“写的成本”之间找平衡。所以不是每个字段都值得建索引,更不是索引越多越好。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. InnoDB的B+树到底是怎么组织数据的
2.1 数据页、索引页和树高
理解InnoDB索引,先要理解它的存储单元。InnoDB的数据是分成数据页来管理的,默认一个页16KB。B+树里的根节点、内部节点、叶子节点,在物理上都是一个一个的数据页。内部节点页里放的是“索引键 + 指向子页的指针”,叶子页里放的才是真正要查的东西。
为什么这个结构能扛住千万级数据?我们可以简单算一笔账。假设主键是BIGINT,占8字节,一个页指针大约占6字节,两者加起来约14字节。16KB的页大概能放下1170个这样的键指针组合,也就是说一个内部节点能分出约1170个子节点。假设一行完整记录约1KB,一个叶子页能放16行。
那么一棵两层的B+树,根节点下挂1170个叶子页,能存大约 1170 * 16 = 18720 行。三层的B+树,就能存大约 1170 * 1170 * 16 = 2190万 行。树高是三层的时候,查询一次最多只需要两次磁盘IO,因为根节点通常会常驻在InnoDB的Buffer Pool里。这就是千万级表按主键查依然很快的根本原因:不是CPU多快,而是IO次数被压缩到了两三次。
2.2 聚簇索引决定了主键比想象中更重要
InnoDB里有一个很反直觉的设计:表里的数据本身就是按主键组织成一棵B+树的,这棵树就是聚簇索引。普通二级索引的叶子节点只存“索引列 + 主键”,但聚簇索引的叶子节点存的是整行记录。换句话说,一张InnoDB表只能有一个聚簇索引,数据行的物理存储顺序也被主键顺序决定了。
如果你建表时没有指定主键,InnoDB会先找有没有非空的唯一键可以作为聚簇索引;如果也没有,它会在内部生成一个隐藏的row_id作为主键。虽然业务上好像不报错,但隐藏主键对二级索引不友好,而且你没法通过主键高效定位行。所以一张正规的InnoDB表,一定要显式设计主键。
聚簇索引这个机制,还直接影响主键类型的选择。如果主键是随机的UUID,插入时新的主键会落在B+树的任意位置,经常触发页分裂和页重排,写入会产生大量随机IO,长期下来碎片也严重。相反,自增或趋势递增的BIGINT主键,新记录基本都追加到树的右侧,页分裂少,写入更连续。这也是生产环境默认推荐用BIGINT自增,而不是UUID做主键的原因。
2.3 二级索引为什么要回表,覆盖索引是怎么回事
给非主键字段建立的索引,在InnoDB里叫二级索引。二级索引的叶子节点不存整行,只存“索引列的值 + 主键值”。查询时如果SELECT *,二级索引只能帮我们找到主键,然后再拿着主键去聚簇索引里取完整行,这个过程叫回表。
回表相当于一次额外的B+树查找。如果查询导致大量回表,每条记录可能都对应两次随机IO,性能自然上不去。所以在产品设计阶段,应该尽量让查询“只访问一个索引就拿到所有需要的字段”,这就引出了覆盖索引。
覆盖索引不是一种独立的索引类型,而是一种使用状态。只要当前查询的字段都包含在某个二级索引的索引列或主键里,那么执行时就可以只扫这个二级索引,不用回表。比如执行SELECT id, username FROM user WHERE username = 'abc',如果二级索引是idx_username(username),由于叶子节点本身带主键id,这个查询就不需要回表。MySQL中的执行计划会显示Using index。反过来,如果查询的是SELECT phone FROM user WHERE username = 'abc',phone不在这个二级索引里,就必须回表才能拿到。
3. 为什么InnoDB偏偏选了B+树,而不是哈希或B树
3.1 磁盘IO模型决定了树要“矮胖”
很多人在学校学过二叉树,但联想到MySQL索引时会困惑:为什么不用红黑树或者AVL树?原因很简单,二叉树对内存中的数据很友好,对磁盘却不太友好。一棵两千万节点的二叉树,高度大概在25层左右。如果每一层对应的节点页不在内存里,向下走一层就可能产生一次磁盘IO,最坏情况下查一条数据要二十多次磁盘IO,这在数据库里是不可接受的。
B树和B+树都是多路搜索树,每个节点可以有很多子节点,树高被压得很低。但普通B树有个问题:它的内部节点也存数据。对于一个16KB的页来说,如果既要存索引键,又要存真实数据,那么每一页能容纳的子节点指针数量就会大幅减少,导致整棵树变高,IO次数增加。
B+树把所有真实数据都放在叶子节点,内部节点只放键和指针,这样内部节点能分出更多叉,树更矮。同时,普通B树的叶子节点没有横向链接,范围查询需要反复回到父节点去查找下一个目标;B+树的叶子节点之间用双向链表串联,一旦定位到范围起点,就可以沿着链表顺序往后扫。对于MySQL里常见的BETWEEN、>、<和ORDER BY操作,这个特性非常关键。
3.2 从“页”的局部性看B+树的优势
数据库对磁盘IO有个很重要的优化思路,就是一次读一个页,而不是一次读一条记录。一个16KB的页里,可能包含几百条索引键或十几行记录,这让顺序扫描变得非常高效。B+树每个节点的大小正好和数据页对齐,读取一个节点就是一次完整的页IO,没有浪费。
哈希索引等值查询确实快,理论上是O(1),但缺点也非常明显。首先,哈希索引完全无序,范围查询和排序都帮不上忙;其次,哈希冲突多了,效率也会下降;再次,哈希索引不支持最左前缀这类部分匹配。InnoDB内部有一个“自适应哈希索引”,只能由引擎根据热点查询自动在B+树上建立,用于优化特定等值查询,我们不能主动把一个普通字段指定为哈希索引,这与业务上想要的通用索引不是一回事。
3.3 红黑树、跳表为什么不主用于数据库
红黑树在内存中很平衡,插入和删除的复杂度都是O(logN),但它的问题是树高比B+树高不少,节点之间的内存布局也分散,无法利用磁盘页的顺序读取。跳表在Redis这类内存数据库里用得比较多,因为内存随机访问成本低,实现又简单,但在磁盘场景下节点位置更分散,局部性不如B+树,也不适合按页批量加载。
从算法选型角度总结,InnoDB选择B+树,本质上是根据“数据在磁盘上,IO次数决定性能”这个前提做的选择。B+树通过低树高减少随机IO,通过叶子链表支持高效范围扫描,通过页对齐实现整页读写。理解了这几条,以后不管是面到“为什么是B+树”还是实际优化索引,思路都不会乱。
4. 联合索引背后的关键算法:最左前缀、下推与排序
4.1 联合索引内部是怎么排序的
很多人以为联合索引就是给多个列分别建索引,其实不是。联合索引idx(a, b, c)只有一棵B+树,节点里的键是一个元组,排序规则是:先按a排,a相同再按b排,a和b都相同再按c排。树的叶子节点里存放的是(a, b, c, 主键id)。
这个“先按第一列,再按第二列”的顺序,决定了你能不能用上这个索引。我们查询时如果要通过索引快速定位,条件里必须能约束第一列,否则后面的列在整棵索引树里并不是全局有序的。
打一个比方,通讯录先按姓氏排列,同姓氏里面再按名字排列。如果你只知道对方名字叫“小明”,不知道姓什么,这个通讯录对你来说就没法直接二分定位,你只能从第一页开始翻。联合索引的原理一模一样。
4.2 最左前缀原则背后的核心原因
当SQL写了WHERE a = 1 AND b = 2,联合索引idx(a,b,c)的可用性很高,MySQL会先按a=1定位,再按b=2继续缩小范围。如果写WHERE b = 2,因为缺少a,b在整棵索引树里无序,优化器没法在树的根节点上直接按b二分定位,通常会退化成全索引扫描甚至全表扫描,这就是我们常说的“没有满足最左前缀”。
如果把条件换成WHERE a = 1 AND c = 3,a可以用索引定位,但c无法在a等值之后继续精确定位。因为a=1对应的所有记录中,c并不是有序的;索引只能先扫描a=1对应的整段记录,再回表或过滤c。这里要注意,“最左前缀”不是要求查询里必须把几个索引列都写全,而是查询条件不能跳过索引的最左列。
同理,范围查询也有顺序影响。假如联合索引是(a,b),执行WHERE a = 1 AND b > 200时,a做等值定位,b做范围定位,依然能用索引。但执行WHERE a > 1 AND b = 2时,a范围条件之后,b本身在a的各个分组里并不全局有序,没法再做高效等值定位,b条件基本只能作为普通过滤条件。
4.3 索引下推是减少回表的经典优化
MySQL 5.6开始支持索引下推。用一个实际例子来解释,假设有联合索引idx_name_age(name, age),执行:
sql复制SELECT * FROM user WHERE name LIKE '张%' AND age = 30;
name LIKE '张%'让MySQL可以定位到姓张的索引区间,但年龄在这个区间内并不是全局有序的。MySQL 5.6之前,存储引擎把区间内所有符合条件的索引记录都取出来,先回表拿完整行,再在Server层过滤age = 30;MySQL 5.6之后,age = 30这个条件可以“下推”给存储引擎,存储引擎在扫描二级索引记录时,发现age列就在当前索引上,可以顺便判断年龄是不是30,只有满足年龄条件才回表。
这个优化看似不起眼,但遇到姓张的用户非常多、age=30的人却很少的场景,回表次数会大幅减少。执行计划里如果出现Using index condition,通常就表示走了索引下推。
4.4 覆盖索引在排序和统计场景里的额外价值
覆盖索引除了能减少回表,还经常被用来处理统计类查询。比如SELECT COUNT(*) FROM order WHERE status = 1,如果status上有二级索引,MySQL统计时直接扫status的索引树就行,不需要扫聚簇索引大表。二级索引页通常比聚簇索引页小,因为索引页不存整行记录,所以同样的扫描范围,覆盖索引能缓存更多有效记录,速度更快。
但要注意,“Using index”和“Using index condition”不是一回事。“Using index”表示不需要回表,完全覆盖;“Using index condition”表示用了索引下推,但仍可能需要回表。面试时如果能主动区分这两个Extra信息,会让人觉得你不是背的,而是真正看过执行计划。
5. 通过执行计划读懂索引行为
5.1 创建索引的基本操作
先给一个常见订单表结构作为例子:
sql复制CREATE TABLE user_order (
id BIGINT NOT NULL AUTO_INCREMENT,
user_id BIGINT NOT NULL,
order_no VARCHAR(64) NOT NULL,
amount DECIMAL(10,2) NOT NULL,
status TINYINT NOT NULL,
create_time DATETIME NOT NULL,
PRIMARY KEY (id),
UNIQUE KEY uk_order_no (order_no),
KEY idx_user_create_time (user_id, create_time),
KEY idx_user_amount (user_id, amount)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
建索引的常用语法有下面几种:
sql复制-- 创建普通二级索引
CREATE INDEX idx_user_status ON user_order (user_id, status);
-- 也可以在建表后用ALTER添加
ALTER TABLE user_order ADD INDEX idx_create_time (create_time);
-- 查看表上的索引
SHOW INDEX FROM user_order;
-- 删除冗余索引
DROP INDEX idx_user_amount ON user_order;
我个人的建议是,正式环境不要用CREATE INDEX直接跑大表,应该先在测试库用同量级数据评估,再通过在线DDL工具或低峰期执行,避免长时间锁表。MySQL 8.0里默认的索引操作支持算法和锁策略参数,但仍然要谨慎。
5.2 EXPLAIN关键列怎么读
排障第一件事就是看执行计划:
sql复制EXPLAIN SELECT order_no, amount
FROM user_order
WHERE user_id = 100
AND create_time >= '2025-01-01'
AND create_time < '2025-02-01'
ORDER BY create_time DESC
LIMIT 10;
执行结果里最值得关注的是这几列:
possible_keys:优化器认为可能用到的索引。key:实际选中的索引。如果这里为空,说明没走索引。key_len:联合索引实际使用到的字节数,可以用来判断到底用了几列。rows:预估需要扫描的行数。这个数字越小通常越好。type:访问类型。常见的从好到差大致是const > eq_ref > ref > range > index > ALL。Extra:额外信息。看到Using filesort代表排序没走索引,看到Using temporary代表用了临时表,都需要警惕。
有些场景里possible_keys有索引,但key为空,不代表“SQL写得有问题”,而更可能是优化器算过账之后发现全表扫更便宜。比如表只有几千行,或者条件查出来的比例超过总量百分之二三十,索引回表成本可能比全表扫还高。这时候不用硬加索引,硬加也未必会被使用。
5.3 一个典型的慢排序查询优化案例
曾经有个线上订单查询很慢,SQL大概是:
sql复制SELECT *
FROM user_order
WHERE user_id = 12345
ORDER BY create_time DESC
LIMIT 10;
表上原本有一个单列索引idx_user_id(user_id)。这个索引能把查询快速定位到user_id=12345的所有订单,但同一user_id下的记录在这个索引里的顺序是按主键排列的,并不是按create_time排列。MySQL需要先把该用户的几千单都捞出来,再做一次文件排序,最后取10条。
当时我做的优化并不复杂:把单列索引删掉,换成联合索引(user_id, create_time)。因为联合索引在定位到user_id=12345之后,这个分组里的create_time天然有序,排序字段直接走索引,不再需要filesort。再执行同样的SQL,优化器能直接倒序扫描叶子页,取够10条就停,扫描行数从几千行降到了10行。
这个案例也解释了为什么联合索引不能随便调换字段顺序。如果建的是(create_time, user_id),遇到既按user_id过滤又按create_time排序的业务,效果就不是最优。设计的核心思路是让“过滤字段”和“排序字段”在索引的排序规则里保持一致。
5.4 key_len判断联合索引用了多少列
执行计划里的key_len非常实用。比如idx_user_create_time(user_id, create_time)中,如果user_id是BIGINT非空,占8字节;create_time是DATETIME非空,在MySQL 5.6以上版本通常占5字节。如果一条SQL只用了user_id = 100去定位,key_len大约只有8;如果还用到了create_time >= '2025-01-01',key_len大约会是13。
所以排查联合索引是否完全生效时,别只看key是不是自己想要的索引,还要看key_len有没有达到预期。如果建了三个字段的联合索引,但key_len只显示第一列的字节数,说明后面两列大概率没有参与定位,SQL条件里的字段顺序或写法可能有问题。
6. 线上最常见的索引失效场景及排查方法
6.1 索引失效清单
我在实际排查中经常见到的失效场景如下,使用表格整理一下:
| 场景 | 原因 | 正确写法或建议 |
|---|---|---|
| 对索引列使用函数 | WHERE DATE(create_time) = '2025-01-01' |
改成范围查询 >= 和 < |
| 对索引列做运算 | WHERE id + 1 = 10 |
改成 WHERE id = 9 |
| 隐式类型转换 | varchar字段和数字比较 | 保证参数类型和字段类型一致 |
| 违反最左前缀 | 联合索引(a,b),只查b | 调整索引顺序或让条件包含a |
| 前缀模糊 | LIKE '%keyword' |
考虑全文索引或搜索引擎,避免非必要前缀匹配 |
| OR连接非索引条件 | WHERE a = 1 OR b = 2 |
给相关字段建索引,或用UNION拆分 |
| 字符集不一致 | 两表字段字符集不同导致join转换 | 保证join字段字符集相同 |
这里要特别强调一句:不要把“索引失效”当成绝对规则去背。MySQL的优化器会自己算成本,有时候即使能走索引,它也会选择全表扫。真正需要关注的是“为什么”。
6.2 函数计算和隐式类型转换为什么影响索引
很多人对函数导致索引失效的理解不深,只记住了“索引列上别用函数”。其实本质原因是:B+树里存储的是原始值,一旦对索引列套上函数,比如DATE(create_time),MySQL每次都要把原始值先算成日期,再去和查询条件比较。这就破坏了原始列的有序性,优化器无法直接在B+树上二分查找。
隐式类型转换同理。假设phone字段是VARCHAR,执行WHERE phone = 13800138000,MySQL会把字符串类型的phone转换成数字再比较,相当于对索引列做了一次类型转换,索引自然用不上。正确做法是应用层把参数写成字符串:WHERE phone = '13800138000'。如果你准备在电话号码这类字段上建索引,更好的方案其实是一开始就设计成BIGINT,只要没有前导零问题,整型存储更省空间也比较快。
6.3 前缀索引适合什么场景
超长字符串字段,比如用户邮箱、文章URL、日志里的traceId,整列建索引会占用大量空间,叶子页能存的记录变少,树也相对变高。这时可以考虑前缀索引,只把字段的前N个字符放进索引。
假设有一个user_email字段:
sql复制SELECT
COUNT(DISTINCT LEFT(user_email, 10)) / COUNT(*) AS diff10,
COUNT(DISTINCT LEFT(user_email, 15)) / COUNT(*) AS diff15,
COUNT(DISTINCT LEFT(user_email, 20)) / COUNT(*) AS diff20
FROM user;
通过比较不同前缀长度的区分度,选一个区分度接近完整列,但前缀尽量短的长度,然后建索引。我一般建议不要盲目追求太长,通常邮箱20个字符左右已经能覆盖绝大多数区分度。
但要注意,前缀索引不能用于覆盖索引优化,也不能完整地支持ORDER BY user_email这类排序,因为索引里没有保存完整值。如果一个字段频繁用于排序或统计,前缀索引可能不是最优选择。
6.4 遇到慢查询时的排查顺序
我遇到慢查询Review时,基本流程是固定的。先打开慢查询日志或者performance_schema,拿到具体SQL;然后EXPLAIN看执行计划,重点关注type、key、rows和Extra。如果rows特别大,就从表结构和业务条件出发,看能不能调整联合索引。如果条件本身找不到合适的索引,我还会问业务,这个查询是不是必须实时精确,能不能改成汇总表或缓存。
有一次排查一个列表接口,SQL条件里同时有status和create_time,最初表上有两个单列索引,优化器只能选其中一个,然后另一个条件靠回表过滤。后来改成联合索引(status, create_time),回表量立刻降了下来。很多看起来“索引失效”的现场,其实是索引设计没有覆盖真实SQL模式。
7. 我现在设计索引的固定套路
7.1 从SQL反推索引,而不是从列反推
很多同学建表时喜欢给每个常见字段都加一个单列索引,但这样做效果很差。正确做法是先收集业务SQL,把高频查询里的WHERE等值条件、范围条件、ORDER BY和GROUP BY字段列出来。等值条件放联合索引的左边,范围条件放在等值字段的右边,排序字段尽量和等值字段一起组成联合索引尾部。
举个例子,核心SQL是:
sql复制SELECT * FROM order
WHERE category = ?
AND
