做后端开发的,基本都遇到过“明明建了索引,查询还是慢”这种诡异问题。今天这篇博文不打算教你怎么背索引语法,而是把 MySQL 索引的底层数据结构与算法翻到台面上讲透:二叉树、红黑树、B 树、B+ 树、聚簇索引、二级索引、最左前缀、回表、覆盖索引、索引下推、排序算法——这些名词背后真正的“为什么”到底是什么。为什么 MySQL 最终选了 B+ 树?为什么联合索引只有遵从最左前缀才能生效?为什么范围查询后面的字段又突然不按索引走了?这些问题搞懂了,面试题不再靠背,线上慢查询也能真正动手解决。适合刚学完 SQL 基础的人、准备跳槽的开发者,以及每次写 SQL 都靠“试索引”的性能优化人员。
1. 为什么MySQL索引最终选了B+树
1.1 索引的本质:用“目录”换“翻书”速度
先退一步想,索引到底解决什么问题。没有索引的时候,一张千万行记录的表,你要按 user_id 查一条订单,MySQL 只能从第一个数据页一路扫到最后一个数据页,这个操作叫全表扫描。全表扫描不是不能做,问题是它要读的不仅是千万行,而是千万行背后的数据页。一个数据页 16KB,一千万行即便每行平均 500B 也算下来要读差不多 488MB 数据。而磁盘读取再快,随机 IO 也是按毫秒计的,全表扫一遍,查询基本就废了。
索引的思路很简单,和查字典一样:书很厚,但你不需要从第一页翻到最后一页找“mysql”这个词。目录里只记录“mysql 在第 780 页”,你直接翻过去就行。数据库索引也一样,它是一棵独立的树结构,树里每个节点只存几样东西——索引键值、指向子节点的指针,以及最终指向数据行的句柄。查询时先顺着索引树定位到目标键值,再拿句柄去读实际数据行。这就是“用额外的写入和存储,换查询时的快速定位”。
但代价也很直接:每次 INSERT、UPDATE、DELETE,MySQL 不仅要改数据页,还得同步改索引树,所以索引不是越多越好,是有维护成本的。一个合格的程序员看索引,不能只盯着查询速度,还要算写入放大和存储开销,这个后面第 5 节专门展开。
1.2 备选结构逐个看:为什么哈希、二叉树、红黑树出局
既然索引本质是一棵可用于查找的树,那用哪个数据结构就成了核心问题。MySQL 官方文档里其实白纸黑字写着,InnoDB 索引采用 B+ 树,还支持自适应哈希索引。但为什么不是别的?我们把候选人逐个拉出来盘一遍。
哈希表。哈希的等值查询是 O(1),理论上最快。但它有两个硬伤:第一,哈希索引完全无法支持范围查询,比如WHERE age > 18,取模散列之后年龄根本没有大小顺序,只能全表扫;第二,哈希碰撞多了性能退化,而且 MySQL 里由哈希索引的辅助结构,只对热点等值查询生效,叫自适应哈希索引,由 InnoDB 自己控制,不开放手动创建。所以哈希表只能当辅助,不能当主力。
二叉树。二叉搜索树的查找是 O(log n),看似不错,但它在最坏情况下会退化成链表,也就是 O(n)。而且即使平衡,一个千万行表,树的深度也要二十多层。注意数据库索引的每次“跳一层节点”都是一次磁盘页随机读,二十多次随机 IO 已经够慢了。这还没算上节点分裂后的失衡问题,维护成本极高。
红黑树。红黑树解决了二叉树退化为链表的问题,自平衡,保证 log n 深度。但它有个天然缺点:每个节点只能存一个键值,而且红黑树的节点在磁盘上依然是不连续页面。依然是二十多层深度,依然是二十多次随机 IO。红黑树在大内存的 Redis 里很好用,因为在内存里没有磁盘 IO 的概念,指针跳转极快;但放到 MySQL 这种磁盘型数据库身上,就不可能作为主索引结构。
B 树。B 树是把二叉树横向拉宽、纵向压扁的多路搜索树。一个节点能放几百个键值对,所以三层 B 树就能覆盖几千万行,查询从根到叶子只要 3 次磁盘读取,这就是质的飞跃。B 树的问题在于,它的每个节点都存放记录数据,导致节点容量小,树的扇出低,同样数据量下树比 B+ 树更高;更致命的是,范围查询在 B 树里需要中序遍历多个节点,中间还可能要回溯到父节点,这对磁盘 IO 又不友好。
B+ 树,才是最后胜出的那个。它把所有真实数据全放到叶子节点,非叶子节点只存放键值和指针,所以它的扇出极高,一个非叶子节点能指向一千多个子节点。而且叶子节点之间用双向链表串联,范围查询只需要找到起点,然后顺着链表顺序扫下去就行,完美契合数据库最常见的“等值查一个、范围扫一串”场景。
1.3 三层B+树到底能存多少数据
讲 B+ 树最爽的就是手算容量,我每次给团队讲索引底层结构都会当场算一遍。InnoDB 一个数据页默认是 16KB,假设你的表主键是 BIGINT,占 8 字节,B+ 树非叶子节点里每个索引项大约由键值 8 字节加指针 6 字节组成,一共 14 字节,于是一个 16KB 的页大概能放 16 * 1024 / 14,约等于 1170 个索引项,也就是能指向 1170 个子页。
第二层如果也都是非叶子节点,那每个第二层节点又能指向 1170 个第三层叶子页,第三层总共就有 1170 * 1170 ≈ 137 万个叶子节点。每个叶子节点按平均每行记录 1KB 计算,可以放约 16 行记录,那么三层 B+ 树支撑的行数大约是 137 万 * 16,也就是 2192 万行。如果你把记录压缩到平均 512 字节,单表三层 B+ 树可以扛到 4000 万行以上。
这个计算的结论很实用:千万级单表,只要索引设计合理,B+ 树的查询基本稳定在 3 次磁盘 IO 以内。第 1 次读根节点,第 2 次读中间节点,第 3 次读叶子节点,然后如果是主键聚簇索引,可以直接拿到整行数据;如果是二级索引,还要额外回表一次,这个后面讲。这也是为什么网上一直流传“单表两千万行是个坎”,本质逻辑就在这:超过三层 B+ 树的承载量后,查询从 3 次 IO 变成 4 次 IO,虽然只多一次,但对热点链路的延迟影响是能够感知到的。当然如果你非要单表存几十亿行也不一定崩,MySQL 支持这么做,只是索引层深度上去了,性能就开始劣化。
2. 从页到聚簇索引:InnoDB的存储底牌
2.1 数据页与行格式
要理解索引,不能只停留在树上,还得往下钻一层,看看数据到底在 InnoDB 里长什么样。InnoDB 最底层的存储单位叫页,默认大小 16KB。页是 InnoDB 与磁盘交互的最小单位,也就是说,哪怕你只查一行记录,InnoDB 也要把这个记录所在的整个页从磁盘读到内存。页分为多种类型:存放表数据的叫数据页,存放索引键值的叫索引页,还有存放 Undo Log、Redo Log 的系统页等等。B+ 树的每一个节点,本质上就是一个页。
数据页内部不是把行记录一股脑堆在一起的,它有固定的结构:页头、页尾记录页号和校验值,中间是行数据区,还有一个页目录(Page Directory)用来加速页内查找。页目录里的每个槽指向一组记录的相对位置,查找页内某一行时,先通过二分法在目录里找到对应槽,再顺着记录链表定位到目标行。所以 B+ 树负责的是“从哪个页拿数据”,页内查找则靠二分和链表配合,这也是为什么我们总说索引是分层定位的,树的每一层都在把范围缩小,最终在页内找到行。
行格式方面,MySQL 8.0 默认是 DYNAMIC,行数据如果超过页大小,会把长字段放到溢出页,原页只保留 20 字节的指针。这意味着你如果建了一张全是超长 VARCHAR 的表,实际叶子节点能容纳的行数会明显下降,三层 B+ 树能支撑的总行数也会跟着缩水。所以“三层 B+ 树能存两千万行”这个经验值,是建立在行宽度合理的前提下的,那些动辄一行几 KB 的表,真实容量要小得多。
2.2 聚簇索引和页分裂
InnoDB 中,表数据本身就是按聚簇索引组织的,不需要你手动创建。所谓聚簇索引,就是叶子节点直接存放整行数据,而这张表只能有一个这样的索引,一般就是主键。如果你建表时没指定主键,InnoDB 会找第一个 NOT NULL 的唯一索引当聚簇索引;都没有的话,它会生成一个隐藏的 6 字节 ROWID 作为聚簇索引。这就是为什么我一直强调,InnoDB 表必须显式定义主键,否则你连行物理存放顺序都不可控。
聚簇索引的物理意义很直接:数据行按主键值排序存放。这里的“排序”指的是逻辑顺序,不是每个页都严格物理连续,但新插入的行,如果主键值比现有最大值大,会被追加到 B+ 树最右侧的叶子节点,这是个顺序写操作,速度极快。相反,如果主键是无序的(比如 UUID),新行的主键值随机分布,它要插入的位置可能在 B+ 树中间某个已有节点里。那个节点满了怎么办?InnoDB 只能把节点分裂成两个,把一半数据搬过去,还要更新父节点指针,这个过程就是页分裂。
页分裂的实际影响经常被低估。第一,分裂涉及大量数据移动和页写入,写放大明显;第二,分裂后的页填充率会下降,极端情况下只有 50%,也就是说本来一个页能存 16 行,分裂完两个页加起来还是 16 行,空间浪费一半;第三,页分裂会造成物理碎片,全表扫描变慢,大量碎片还会让主键范围扫描多读很多“半页”。这也是为什么我强烈建议,InnoDB 表的主键能选自增整型就选自增整型,能用雪花算法那种趋势递增的 ID 也行,就是别用纯 UUID 当主键。第 5 节还会再展开。
2.3 二级索引、回表与覆盖索引
聚簇索引以外的索引,统称二级索引,也叫辅助索引。二级索引的叶子节点不存整行数据,只存索引键值和主键值。查询走二级索引时,第一步顺着二级索引 B+ 树定位到叶子节点,拿到主键值,第二步再拿着主键去聚簇索引里重新定位一次,才能拿到整行记录。这第二步就叫“回表”。
回表是二级索引查询额外付出的代价,很多慢查询的根因就是回表次数太多。举个我实际调过的例子:一张订单表有几百万行,SELECT * FROM orders WHERE user_id = 10 AND create_time > '2023-01-01'走了二级索引,可能定位到 5 万个主键,这 5 万个主键对应的数据行又是随机分散在大量数据页里的,等于每读一行都要一次随机 IO,性能自然惨不忍睹。
应对回表有两个核心手段。第一个是覆盖索引,也就是把 SELECT 需要的字段全部塞进索引里,这样查询在二级索引叶子节点就能拿到全部数据,不需要再回表。比如你高频查询SELECT user_id, status FROM orders WHERE create_time > ...,就可以建一个(create_time, user_id, status)的联合索引,让 Extra 显示Using index。第二个是索引下推(Index Condition Pushdown,简称 ICP),MySQL 5.6 以后引入。它可以在二级索引扫描过程中,先把不满足条件的记录直接过滤掉,减少回表次数。比如WHERE name LIKE '张%' AND age > 20,如果联合索引是(name, age),ICP 会在遍历索引时顺便判断 age > 20,不符合的直接丢掉,不用先回表拿整行再过滤。优化器里看到 Extra 的Using index condition就代表 ICP 生效了。
3. 联合索引、最左前缀与排序算法
3.1 联合索引到底是怎么排的
联合索引,就是多个字段组成的索引,比如(a, b, c)。很多新手以为联合索引就是把三个字段分别建三个索引,这是大错特错。联合索引本身还是一棵 B+ 树,只不过节点里的键值是有序排列的多字段组合。它的排序规则可以理解成订单里“先按什么看,再按什么看”:先按第一个字段 a 排序,a 相等的行再按 b 排序,b 也相等的再按 c 排序。这个逻辑和 Excel 里的多级排序一模一样。
因为这种嵌套排序规则,联合索引里的字段顺序就变成了一件极其敏感的事。B+ 树的叶子节点链表只能保证一个全局顺序,而这个顺序有一个“优先级”:a 是老大,b 是老二,c 是小弟。只要 a 不同,后面 b、c 的顺序就被打乱了。所以联合索引里每一个位置都在依赖它前面所有字段的稳定性,这也就是最左前缀原则的底层逻辑:你只能从排序规则的最左侧开始,连续取字段,才能利用到树的顺序性。
举个例子,索引(user_id, create_time),查询条件WHERE user_id = 1 AND create_time > '2023-01-01'非常完美,因为先确定 user_id 等于固定值,索引在 user_id 相同的前提下已经把 create_time 排好序了,可以直接定位到 target 叶子节点开始范围扫描。反过来,如果查询是WHERE create_time > '2023-01-01',没有 user_id 条件,B+ 树里 create_time 的全局顺序是乱掉的,MySQL 无法利用这个联合索引的排序性,只能退化成全索引扫描甚至全表扫描。
3.2 最左前缀与范围查询的“后失效”
最左前缀在实际使用中有几个经典分支。假设联合索引是(a, b, c):
WHERE a = 1:能走索引,因为只要第一个字段定下来,就可以沿着 1 这棵子树往下找。WHERE a = 1 AND b = 2:能走索引,a 定值后 b 也是有序的,可以继续精确匹配。WHERE a = 1 AND b = 2 AND c = 3:最理想,三个字段全部定值。WHERE b = 2:不能走索引,因为没有 a 的约束,整棵树的 b 字段相对无序。WHERE a > 1 AND b = 2:只能部分走索引,a 用来定位范围,但 b=2 就没法利用了。因为 a > 1 这个范围条件下,b 在跨多个 a 值时是无序的,MySQL 只能把 a > 1 的所有行扫出来再逐个过滤 b。
最后一条就是面试和线上优化里最常见的问题:范围查询后面的字段索引失效。失效不是绝对说“完全不走索引”,而是“后面的字段不再参与索引定位”。很多开发者误以为联合索引(a, b, c)只要写了 a 就一定万事大吉,结果a > 1 AND b = 2时 b 根本没用上,rows 估算还是很大。所以设计联合索引时,一个高段位的习惯是:把查询里最常用的等值条件放在联合索引最前面,把范围条件放在最后面,这样前面等值字段能快速把范围缩小,后面的范围字段再来扫尾。反过来,如果把范围字段放最前,后面再好的等值条件也用不上。
3.3 order by 背后的排序算法与索引适配
索引和排序算法的关系也很有意思。你写ORDER BY create_time时,MySQL 有两种选择:要么利用索引天然的有序性,直接按 B+ 树叶子链表顺序读取,完全不做额外排序;要么先取回一批数据,再放到内存或临时文件里自己排序。后者在 MySQL 的执行计划里叫filesort。
filesort 分两种场景:如果排序的数据量小于sort_buffer_size(默认 256KB,可调),在内存里直接排序,内存排序用的是快速排序或堆排序;如果数据量超过排序缓冲区,MySQL 就必须把数据分块写入临时文件,每个块内部先排好序,再用归并排序把多个有序块合并成一个整体有序的结果集。为什么用归并排序?因为快速排序是整体内存算法,需要对整个数组随机访问,而磁盘上的多个临时文件只能顺序读,归并排序天然适合这种“多路有序文件合并”的外排序场景。
这个知识点直接关联到一条优化策略:如果你把 ORDER BY 字段设计进联合索引,并且 WHERE 条件已经把排序字段之前的字段固定成等值,MySQL 就可以直接省掉 filesort。比如WHERE user_id = 1 ORDER BY create_time,配合索引(user_id, create_time),user_id 定值后,create_time 在索引里已经有全局顺序,执行计划里不会出现 filesort。反过来,WHERE user_id IN (1,2,3) ORDER BY create_time就很难办,因为 IN 条件值不固定,跨多个 user_id 后 create_time 整体顺序被打乱,大概率还是要 filesort。遇到这种场景,要么改 SQL,要么接受 filesort 但严格控制结果集大小和排序字段宽度,别在排序里带一大串没用的列。
4. 实操:从慢SQL到索引验证
4.1 一个订单查询的完整优化
理论讲再多,不如跑一遍真实场景。我拿一个典型的订单表来演示。表结构大概是这样:
CREATE TABLE `orders` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `user_id` BIGINT NOT NULL, `order_no` VARCHAR(32) NOT NULL, `status` TINYINT NOT NULL DEFAULT '0', `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_user_id` (`user_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;业务上最频繁的一个查询是按用户查最近的订单:
SELECT * FROM orders WHERE user_id = 123456 AND create_time > '2024-01-01' ORDER BY create_time DESC LIMIT 20;如果只有idx_user_id单列索引,MySQL 会先通过idx_user_id把 user_id = 123456 的所有订单全部找出来,可能有几千上万行,然后再对这堆数据进行 filesort 排序,最后取前 20 条。如果该用户的订单量很大,这个流程就很慢:回表多、排序数据量大。
优化方案是改成联合索引(user_id, create_time):
ALTER TABLE `orders` ADD INDEX `idx_user_create` (`user_id`, `create_time`);为什么这个索引能解决上面两个问题?第一,user_id 是等值条件,索引第一步精确锁定到这个用户;第二,同一个 user_id 内部的 create_time 已经天然有序,所以ORDER BY create_time DESC可以直接反向扫描叶子链表,不需要 filesort;第三,如果查询字段只到 id / user_id / create_time 为止,甚至可以设计成覆盖索引,连回表都省了。我实测过同样的 SQL,优化前平均 800ms,优化后 20ms 以内,差距就是这么大。
4.2 explain 关键指标怎么看
验证索引有没有生效,永远不要靠猜,打开执行计划看一眼:
EXPLAIN SELECT * FROM orders WHERE user_id = 123456 AND create_time > '2024-01-01' ORDER BY create_time DESC LIMIT 20;输出结果里重点看四个列:type、key、rows、Extra。
type是访问类型,从好到差的顺序大致是:system>const>eq_ref>ref>range>index>ALL。能看到ref或range已经说明索引用到了;如果看到ALL,基本可以断定没走索引。index也未必是好事,它代表全索引扫描,比如SELECT COUNT(*)走一个二级索引,虽然比全表快,但数据量一大也不轻松。key列告诉你 MySQL 实际选中了哪个索引,如果不符合预期,大概率是优化器基于统计信息估算觉得其他方式更便宜,你可以用FORCE INDEX做对比实验,但不要长期依赖它。rows是优化器估算需要扫描的行数,这列越高越危险。Extra里有几个关键词要秒懂:Using index表示覆盖索引,不回表;Using index condition表示 ICP 生效;Using where表示存储引擎层交回了 Server 层还要过滤;Using filesort和Using temporary则分别代表排序和临时表,这两个出现在性能问题 SQL 里的概率极高。
还可以用key_len判断联合索引到底用到哪一列。key_len是索引里实际使用的字节长度,比如user_id是 BIGINT 占 8 字节,create_time是 DATETIME 占 5 字节(可空再加 1),如果key_len是 8,说明只用了第一个字段;是 14 左右,说明两个字段都用上了。看key_len能帮你验证范围查询后字段是否“失效”,非常实用。
4.3 常见索引失效场景速查
我把这些年亲手踩过、帮别人排查过的索引失效场景整理成一张表,直接收藏就行。
| 场景 | 表现与原因 | 正确姿势 |
|---|---|---|
| 对索引字段使用函数 | WHERE DATE(create_time) = '2024-01-01'让优化器无法利用索引排序,因为函数结果不在 B+ 树里 | 改写为create_time >= '2024-01-01' AND create_time < '2024-01-02',或建函数索引 |
| 隐式类型转换 | 手机号字段 varchar,查询WHERE phone = 13800000000时数字转字符串或反转换,导致字段被套 CAST,索引失效 | 查询参数保持和字段类型一致,写字符串就加引号 |
| LIKE 左侧模糊 | WHERE name LIKE '%张%'时,目标前缀不确定,B+ 树无法二分定位 | 改为LIKE '张%',左侧模糊只能考虑全文索引或外部搜索 |
| OR 连接非索引条件 | WHERE user_id = 1 OR status = 2,一条走索引一条不走,优化器干脆全表扫 | 拆两条 SQL 用 UNION ALL,或给 status 也加索引 |
| 联合索引缺左列 | 索引(a,b)你只查b,全局排序对 b 无用 | 按查询需求调整联合索引字段顺序,或单独建b索引 |
| 范围条件后字段 | a > 1 AND b = 2在索引(a,b)下,b 不能再用于定位 | 把等值字段放前面,范围字段放后面,必要时单独加索引 |
IS NULL与优化器 | MySQL 8.0 之前IS NULL走索引的效果不稳定,看统计信息 | 8.0 一般可用;低版本建议设计默认值避免 NULL |
表格里每一条我都建议你在本地 EXplain 验证一遍,形成肌肉记忆比背结论重要得多。
5. 常见问题与排坑实录
5.1 函数查询与隐式转换,根因还是B+树排序
很多朋友问,为什么加了函数索引就失效,明明数据还是那些数据?道理要回到 B+ 树本身。B+ 树叶子节点上的键值,是按照字段的原始存储值排好序的,所以查找时 Must 用原始值去二分定位。一旦你在查询里写了DATE(create_time),你想要的其实是“计算后的值”的有序结构,而索引里根本没有这个结构。你就算把全表 create_time 都扫一遍再算一遍 DATE 也不会慢多少,但优化器没有办法在索引树上做“跳跃定位”,只能全部扫描后过滤。隐式类型转换也是同一个问题,字符串字段和整数比较时,优化器会尝试转换,等于在索引字段头上套了一层函数,B+ 树的有序性瞬间作废。
5.2 大字段如何建索引,前缀索引与覆盖索引的取舍
对很长的字符串字段,比如邮箱、URL、文章标题,直接建全字段索引会带来两个问题:索引页能装的键值变少,B+ 树扇出降低、树变高;每次插入和查询都要比较很长的字节序列,CPU 开销增大。常见解法是前缀索引,即只取前 N 个字符进入索引。比如邮箱通常是xxx@domain.com,取前面 7 个字符一般就有很高区分度。后缀判断用前缀索引做不到,比如查@126.com,因为索引是从头开始排序的,无法直接把字符串尾部作为定位点——这也解释了为什么LIKE '%xxx'不适用于 B+ 树索引。
怎么确定 N 比较合适?一个实用公式是看区分度:COUNT(DISTINCT LEFT(email, N)) / COUNT(*)越接近 1 越好,再往上涨就说明长度加长收益不大。我一般从 5 开始试,每次加 2,跑到 0.9 以上就差不多。但要注意,前缀索引有两个先天限制:不能做覆盖索引,因为叶子节点里只存了前缀,查 SELECT 其他字段必须回表;也不能用于 ORDER BY 和 GROUP BY 的完整排序。所以只有那些大字符串但查询也只用等值匹配的字段,才适合前缀索引。
5.3 加索引会锁表吗,Online DDL 到底怎么回事
给一个大表加索引,最担心的就是业务抖动。MySQL 5.6 之前的 ALTER TABLE 加索引,基本是拷贝表数据到新表,全程加锁,几千行表还好,上亿行的表会让业务中断好几分钟。5.6 以后 InnoDB 支持 Online DDL,默认ALGORITHM=INPLACE, LOCK=NONE,允许 DDL 过程中同时进行并发的 DML 操作。所以现代 MySQL 加索引,原则上不需要再像老教程那样精心设计停机窗口。
但“原则上不需要”不等于“随便跑”。Online DDL 执行期间比平常多占用磁盘空间和 IO,而且在 DDL 的最后一个阶段,需要短暂获取排他锁来让新索引树生效,这个阶段通常很快,但如果正好赶上大量长事务,锁等待时间会被拉长。我踩过的一个坑是:业务表有大量长时间未提交的事务,加索引时 DDL 等锁等了快十分钟,期间所有写操作全被堵住,引发告警。所以上线前还是建议看一下INFORMATION_SCHEMA.INNODB_TRX,确认没有长事务再执行 DDL。表特别大、业务极其敏感时,可以用pt-online-schema-change这类工具,以触发器方式渐进式复制数据,对在线影响更小。
5.4 主键选型与页分裂预防,这个坑埋得最深
主键选型的问题在 2.2 提过,这里说点更具体的。UUID 主键不仅让聚簇索引频繁页分裂,还有一层更隐蔽的连锁反应:所有二级索引的叶子节点都存主键值,主键从 8 字节 BIGINT 换成 36 字节 VARCHAR,意味着表里每一个二级索引都变大好几倍,导致索引页缓存命中率下降、范围扫描 IO 更多。我有一个真实案例,某表本来 2000 万行,只因为从自增 ID 换成 UUID,二级索引持久化体积直接涨了 3 倍,Buffer Pool 命中率掉了近 10 个点,整个查询链路明显变慢。
这里不是说你绝对不能选 UUID。分布式场景下需要全局唯一、不想暴露自增 ID 时,完全可以选 UUID,只是要用有序列的版本,比如 UUIDv7 这种按时间排序的格式,或者直接用雪花算法生成的 BIGINT。雪花 ID 本身是递增趋势,插入时大多数是追加写,页分裂概率大幅降低。如果你已经用了 UUID 主键,也别急着改表,优先考虑加一个自增的不可见主键做聚簇索引,把原 UUID 改造成普通唯一索引,迁移成本相对可控。
5.5 索引不是越多越好:代价拆解与冗余索引清理
最后泼一盆冷水。很多团队优化慢查询的方式是“见到慢就加索引”,结果一张表堆了十几个索引,写入性能越来越差,查询也没快多少。索引的代价拆开看就知道为什么:每加一个索引,就是给表多维护一棵 B+ 树,INSERT 时要同步写入所有索引树,UPDATE 时主键不变但所有二级索引都要更新键值,DELETE 时每个索引都要做删除标记,这全是写放大和日志放大。而且 InnoDB 缓存池空间有限,索引占得越多,数据页被挤出去的概率越大,二次查询的命中率下降,反而引发更多随机 IO。
另一个容易踩的点是冗余索引。你已经有了(a, b)联合索引,再单独建一个(a)索引就是纯浪费,因为(a, b)已经能覆盖所有a等值查询。判断冗余的简洁办法:SHOW INDEX FROM table看所有索引,如果一个索引的所有字段是另一个索引的前缀子集,那它大概率冗余。清理索引前,用sys.schema_unused_indexes视图也能找到长期没有被使用的索引,先分析,再删除,别一股脑全删。这里有我比较固执的一个习惯:写完 SQL,EXPLAIN 确认key和key_len,然后才动索引;线上索引宁缺毋滥,删除比创建更需要勇气和验证。
说完这些,想起最近一次优化经历。某报表查询查一次要 3 秒,业务方每天跑几十次,我做的第一件事不是加索引,而是拿 EXPLAIN 看了一遍,发现原来那个“看起来很有用”的单列索引根本没被选上,优化器认为回表太多,选择了全表扫描。后来只做了一件事,把查询涉及的三个字段做成覆盖联合索引,查询直接降到 30ms,期间没动一行业务代码。所以每次有人问我 MySQL 索引怎么学,我都建议先把底层 B+ 树这层数据结构吃透,你理解了“索引是一棵有序树、每个叶子指向主键”,就不会再写出让索引失效的 SQL,也不会再盲目加索引。