聊到 MySQL 优化,索引永远是绕不开的第一话题。很多后端同学对“B+树、聚簇索引、最左前缀”这些词都能背出来,但一上完 explain 就懵,或者线上慢查询出现时不知道怎么判断“这个字段到底该不该建索引”。这篇文章我打算把 MySQL 索引的底层逻辑、日常建索引的实操原则、索引失效的排查思路、常见高频面试问题一次性讲透。内容有些长,尤其适合刚接触数据库优化的人,或者准备面试想把 MySQL 索引这个点补全的同学,全文没有废话,纯按我平时实际排查问题的思路来写。
1. 索引的底层原理:为什么索引能快这么多
1.1 从一次全表扫描说起
我遇到过不少开发同学,刚开始对索引的理解就是“给字段加个索引,查询就变快了”。如果只停留在这一层,遇到实际的慢 SQL 还是会很吃力。先想想没有索引时,MySQL 是怎么找一条数据的。
大多数情况下,InnoDB 的数据是按主键聚簇存放在表空间里的,数据的最小单位是一个个 16KB 的页。查询如果不走索引,优化器只能从第一个页开始,一页一页把数据读进来,逐行匹配 WHERE 条件,这个过程叫全表扫描。数据量小的时候没感觉,一旦表达到千万行,这种方式的耗时几乎正比于总行数,慢是必然的。
这就像一本没有目录的词典,你要找一个词,只能从第 1 页翻到最后一页。索引相当于给数据按某个字段重新整理了一份“有序目录”,而且这份目录本身也以树形结构存在硬盘上,方便快速定位。
但索引真正强的地方,不是因为建立了“目录”本身,而是目录内部采用了一种特殊的数据结构:B+ 树。
1.2 谈一下 B+ 树的排序原理
B+ 树你可以理解成一棵多叉平衡搜索树。为什么要多叉?因为 MySQL 在磁盘上读取数据的最小单位是页,一页 16KB,IO 开销按“次”算,很不便宜。树的高度越低,定位一条数据需要的磁盘随机读次数就越少。
二叉树的问题在于:数据有序插入时很容易退化成一条链表,即便用红黑树等自平衡方案,树的高度大约是 O(log2N)。一千万数据大概需要 20 多次比较,每次都对应一次磁盘 IO,这个成本是难以接受的。B+ 树每个节点可以放很多“索引键 + 子页指针”,一个 16KB 的叶子页能容纳的键数量可以上千,正常情况下两到三层就能覆盖千万级数据。
B+ 树有一个非常关键的细节:非叶子节点只存索引键和子页指针,不存业务数据。这样的好处是一个节点能放出更多的分叉,树更矮。最终所有数据都集中在叶子节点,叶子节点内部按索引键有序排列,叶子页之间又通过链表相连。所以范围查询会特别舒服:先定位到边界,然后顺着叶子链表一路往下扫,不需要频繁回溯上层。
你平时看到的 WHERE id BETWEEN 100 AND 1000 能很快执行完,底层靠的就是这个链表设计。相比普通 B 树,B+ 树在范围查询上的优势非常明显,这也是 InnoDB 选它做索引结构的重要原因。
1.3 InnoDB 中“聚簇索引”和“二级索引”
很多人分不清主键索引和普通索引,其实在 InnoDB 里,每个表的数据本身就是一棵 B+ 树,树的叶子节点存的是整行记录,这个索引就叫聚簇索引。
建表时如果指定了主键,InnoDB 一般会把主键作为聚簇索引的键;没有主键但有非空唯一索引,会选择第一个这样的唯一索引;两个都没有,InnoDB 会生成一个隐藏的 6 字节 rowid 作为聚簇索引键。
你手动添加的普通索引、唯一索引、联合索引都叫二级索引。二级索引与聚簇索引的最大区别在于:它的叶子节点不存整行数据,只存“索引键 + 主键值”。
举例来说,如果我在 user 表的 user_name 字段上建索引,那么这棵索引树的叶子节点内容是 (user_name, id)。当有一条 WHERE user_name = '张三' 的查询时,MySQL 会先去 user_name 这棵索引树找到对应的叶子,拿到主键 id,再拿着这个 id 去聚簇索引树里查完整记录。
1.4 聚簇索引回表细节
上面这个“先查二级索引,再查聚簇索引”的过程,在 MySQL 里称为回表。回表不是免费的,每回表一行,都需要走一次聚簇索引树查找。假如二级索引命中了 1000 行,可能就会产生 1000 次随机的聚簇索引查询,这个成本偏高。
所以你会经常听人建议少用 SELECT *。如果查询需要返回的列,刚好都包含在某个二级索引里,就可以只扫这棵索引树,拿到所有需要的字段后直接返回,完全不用回表,这种状态叫覆盖索引。后续我们会专门展开。
这里先记住一个结论:InnoDB 的二级索引叶子节点存储的是主键值,这是 InnoDB 设计上的一大特点。好处是当数据页发生分裂、页内记录移动时,二级索引不需要跟着改存储位置;代价就是大多数普通索引查询都会多一次回表操作。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 索引的类型与创建实操
2.1 一张表里常见索引类型
MySQL 的索引类型从使用角度可以划分为以下几类:
| 索引类型 | 特点 | 典型场景 |
|---|---|---|
| 主键索引 | 聚簇索引,非空唯一,通常每表一个 | id 字段 |
| 唯一索引 | 允许 NULL,NULL 可以有多个 | 手机号、邮箱等业务唯一标识 |
| 普通索引 | 只为了加速查询,不约束唯一性 | 高频 WHERE 字段 |
| 联合索引 | 多个字段组成一棵索引树 | 多条件等值查询、排序 |
| 全文索引 | 针对长文本做分词匹配 | 文章标题、正文搜索 |
| 前缀索引 | 只对字段前 N 个字符建立索引 | 超长字符串字段 |
主键索引和唯一索引很容易混淆:主键索引本质上也是唯一索引,但主键列不允许 NULL,而且 InnoDB 直接拿主键当聚簇索引来组织整张表的数据。唯一索引只是数据约束层面的唯一性保证,它本质上仍然是二级索引,查询非主键唯一字段时,同样可能需要回表。
2.2 SQL 创建和删除索引的写法
比较常见的方式是在建表时定义,也可以后续用 ALTER TABLE 或 CREATE INDEX 添加。
sql复制-- 建表时定义索引
CREATE TABLE `user` (
`id` bigint unsigned NOT NULL AUTO_INCREMENT,
`user_name` varchar(32) NOT NULL,
`mobile` varchar(20) DEFAULT NULL,
`status` tinyint NOT NULL DEFAULT '1',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_mobile` (`mobile`),
KEY `idx_user_name` (`user_name`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
sql复制-- 后续新增
ALTER TABLE `user` ADD INDEX `idx_status` (`status`);
ALTER TABLE `user` ADD UNIQUE INDEX `uk_mobile` (`mobile`);
-- 或者用 CREATE INDEX
CREATE INDEX idx_user_name ON `user` (`user_name`);
CREATE UNIQUE INDEX uk_mobile ON `user` (`mobile`);
sql复制-- 查看某张表的所有索引
SHOW INDEX FROM `user`;
-- 删除索引
ALTER TABLE `user` DROP INDEX idx_status;
DROP INDEX idx_user_name ON `user`;
联合索引也一样,不过字段顺序会直接影响索引效果,第三节重点讲。
对于超长 varchar、text 类型的字段,MySQL 不会让你直接建完整索引,要么选用前缀索引,要么用函数索引。
2.3 什么字段适合建索引,什么字段别乱建
判断一个字段值不值得建索引,核心看两个维度:查询频率和区分度。
高频出现在 WHERE、JOIN ON、ORDER BY、GROUP BY 后面的字段,值得优先考虑。但区分度太低就不适合单独建索引。比如 status 字段如果只有 0 和 1 两个值,就算建了索引,优化器也会认为一次扫描可能命中一半数据,最终大概率还是全表扫描。
判断区分度可以用一句 SQL 估算:
sql复制SELECT COUNT(DISTINCT user_name) / COUNT(*) AS selectivity FROM `user`;
选择度越接近 1,说明区分度越好。对于超长字段,可以计算不同前缀长度的区分度,找到合适的前缀长度后建前缀索引。
sql复制SELECT COUNT(DISTINCT LEFT(description, 10)) / COUNT(*) AS s10,
COUNT(DISTINCT LEFT(description, 20)) / COUNT(*) AS s20
FROM article;
但前缀索引有个明显缺点:索引里只存了字段的前缀部分,MySQL 无法利用它做 ORDER BY 排序、GROUP BY 分组,也无法实现真正的覆盖索引。所以如果业务经常需要排序,优先考虑其它方案,比如把长文本冗余成短编码列,或者用 MySQL 8.0 的函数索引。
另外一个很容易被忽略的原则:索引不是越多越好。每增加一个索引,INSERT、UPDATE、DELETE 时就需要额外维护一棵索引树,写入性能必然下降。索引文件也占用磁盘空间,缓存命中率还会被拖累。实际项目里单表索引数量尽量控制在 5 个以内比较稳妥。
2.4 冗余索引和重复索引
这是我在代码评审时经常提醒的一个点:重复索引和冗余索引比“缺索引”更隐蔽。
比如已经有一个联合索引 (user_id, status),你又单独加了一个 status 索引,那么 status 单独索引大概率就是冗余的。因为查询条件里如果包含 user_id 和 status,联合索引可以覆盖;如果只查 status,联合索引由于最左前缀规则用不上,所以才需要单列索引。但如果实际业务根本不存在只按 status 查询的场景,这个单列索引就属于多余开销。
MySQL 提供了 sys 库可以快速查重复或冗余索引,例如:
sql复制SELECT * FROM sys.schema_redundant_indexes;
SELECT * FROM sys.schema_unused_indexes;
生产环境发布前跑一下,能发现不少历史遗留问题。
3. 联合索引、最左前缀与索引下推
3.1 联合索引在 B+ 树里的存储顺序
联合索引是面试重灾区,也是实际开发最容易用错的地方。
假设有一张订单表 orders(id, user_id, status, created_at),我们在 (user_id, status) 上建了联合索引。这棵树的第一排序键是 user_id,就是说整棵索引树先按 user_id 全局有序;在 user_id 相同的情况下,再按 status 有序。
所以为了直观理解那个“最左前缀”规则,不要把它当成什么高深理论,只要记住“索引树的排序是从左往右逐层确立的”就能想明白:第一个字段是所有记录的第一关键字,第二个字段只有在第一个字段相等时才有意义。更严格的查询利用到的索引列,通常是最左边连续的一段。
3.2 最左前缀到底指什么
联合索引 (a, b, c),以下查询能最大化使用索引:
sql复制WHERE a = 1
WHERE a = 1 AND b = 2
WHERE a = 1 AND b = 2 AND c = 3
即使条件顺序写成 WHERE c = 3 AND a = 1,优化器也会自动调整条件顺序,所以“最左”不是指书写位置,而是指条件里有没有从左到右连续覆盖索引列。但如果跳过了中间列,比如 WHERE a = 1 AND c = 3,a 可以直接用于定位,c 这一列就很难利用索引的有序性进行快速过滤,通常只能拿到满足 a=1 的所有索引记录后再回表过滤。
MySQL 8.0 引入了一种叫 Skip Scan 的优化,某些场景下可以跳过最左列直接扫描后续列,但是它的适用范围有限,实际优化时还是老老实实按最左前缀来设计索引比较靠谱。
所以联合索引建字段顺序的常规思路是:先把等值条件字段放前面,再把需要排序或范围筛选的字段放后面。这里有一个细节:如果索引列是 (a, b),但 WHERE 里是 a = 1 AND b > 10 ORDER BY b,这个顺序完全没问题;但如果是 a > 1 AND b = 10,a 用了范围查找,a 范围之下 b 是无序的,优化器通常无法用 b 做等值匹配,会出现部分索引失效的迹象。
很多文章会说“范围条件后面的索引列全部失效”,严格说不是“整个索引失效”,而是范围条件之后的列无法继续利用索引的有序性来做高效等值或排序匹配。这个理解会更准确。
3.3 索引下推是什么
索引下推也跟联合索引有关,MySQL 5.6 推出的优化手段,英文叫 Index Condition Pushdown,简称 ICP,很多面试官喜欢问。
以联合索引 (user_id, status) 为例。假设有一条查询:
sql复制SELECT * FROM orders WHERE user_id = 100 AND status = 2;
最理想情况是 user_id 和 status 都能用来过滤。但在 MySQL 5.6 之前,InnoDB 的二级索引可能只根据 user_id 这个最左列定位,拿到叶子节点上的主键后,回表读取整行,再在服务层判断 status 是否等于 2。如果 user_id=100 命中了 1000 条,前 5.6 版本可能需要全部回表,浪费很大。
索引下推的思路很直接:既然联合索引的叶子节点上已经包含了 status 字段,那在读取索引记录时,先把 status=2 这个条件在索引层过滤一遍,上面例子中可能只剩 50 条需要回表。通过减少回表次数,IO 大幅降低。
用 explain 查看时,如果 Extra 列显示 Using index condition,说明这条查询走了索引下推。如果你的 MySQL 是 5.6 以下版本,或者关闭了 ICP,你会发现同样一条 SQL 的执行代价明显变大。
但要注意,索引下推只针对二级索引的过滤,而且过滤列必须是索引里包含的列。它和覆盖索引不是一回事,覆盖索引是完全不用回表,ICP 是少回表,含义不同。
3.4 覆盖索引的实际应用
覆盖索引本质上是一种查询思想:让一条 SQL 需要的所有列都包含在同一个二级索引中,查询时只扫索引树就能返回,不再回表。
前面说的 orders(user_id, status) 联合索引,如果查询是:
sql复制SELECT user_id, status FROM orders WHERE user_id > 100 AND status = 2;
在能走这个联合索引的情况下,叶子节点已经包含 user_id 和 status,回表完全没有必要,Extra 会显示 Using index。
这也是为什么有时候明明可以让二级索引去过滤,却把一些常用字段冗余进索引的原因。比如你高频查询是 WHERE user_id = ?,且只需要手机号 mobile,如果联合索引是 (user_id, mobile),那么这条查询就可以覆盖。要注意冗余字段会增加写入代价,需要根据实际业务取舍,不要盲目把所有查询字段都塞进索引。
3.5 ORDER BY 和 GROUP BY 如何利用索引
索引本身是有序的,所以如果查询的排序规则和索引树完全匹配,MySQL 可以直接按索引顺序扫描,省掉一次文件排序 filesort。
举例:
sql复制CREATE INDEX idx_user_status ON orders(user_id, status);
SELECT * FROM orders WHERE user_id = 100 ORDER BY status;
这条 SQL 的 WHERE 里 user_id 是等值条件,相当于把所有 user_id=100 的数据聚在一起,它们内部已经按 status 排好序,所以 ORDER BY status 可以直接利用索引的有序性。
但如果排序方向不一致,比如索引定义顺序是 (status ASC),查询是 ORDER BY status DESC,在 MySQL 8.0 之前可能无法直接利用索引正序扫描完成倒序返回。MySQL 8.0 支持降序索引,可以按需定义:
sql复制CREATE INDEX idx_status_desc ON orders(status DESC);
大多数业务并不需要频繁用降序索引,先考虑按索引顺序设计查询即可。GROUP BY 也类似,本质是分组排序,能利用索引就尽量避免临时表。
4. 索引失效常见场景与优化实战
4.1 失效场景速查
这里列一下日常最常见、也最容易被 SQL 写坏的索引失效场景。只要你在一条慢查询里看到了类似写法,可以立刻停下检查。
| 场景 | 示例 | 为什么影响索引 |
|---|---|---|
| 左侧模糊匹配 | WHERE user_name LIKE '%张三%' |
不知道字符串开头,无法确定索引树的搜索区间 |
| 对索引列做函数运算 | WHERE YEAR(created_at) = 2023 |
每一行都需要先计算结果,破坏索引键顺序 |
| 对索引列做算术运算 | WHERE id + 1 = 10 |
索引里存的是 id,不是 id+1,无法直接比较 |
| 隐式类型转换 | WHERE mobile = 13712345678 |
字符串列可能被转成数值,相当于列上套了函数 |
| 联合索引不满足最左前缀 | 索引 (a,b),却只查 b |
索引树第一排序键不是 b |
| OR 连接非索引条件 | WHERE a = 1 OR b = 2,b 无索引 |
优化器很难只走索引满足全部条件 |
| 范围条件后的索引列 | 索引 (a,b),WHERE a > 1 AND b = 2 |
b 在 a 的值范围内不保证有序 |
4.2 失效原理背后的判断机制
上面这些场景看着多,实际都能用“索引树有序性”来解释。
索引本质上是为了支持二分定位,如果查询条件无法转成“从某个位置开始到某个位置结束”的闭区间,就会退化成全量扫描。比如 LIKE '%xxx',系统不知道以哪个字符开头,没办法利用节点中键的有序性进行快速收敛。而在索引列上做函数或运算,例如 YEAR(created_at),其实是对每一行的 created_at 先计算出新值再比较,索引树中存储的原始值已经无法直接参与搜索,自然只能逐行处理。
隐式类型转换更隐蔽。如果 mobile 列是 varchar,条件是数字 13712345678,MySQL 会尝试把 mobile 列转换成数字再比较,相当于对列施加了 CAST 函数。这种情况下 explain 经常显示 type 为 ALL。反过来,如果查询列是 int,条件写字符串 '123',字符串会被转成数字比较,一般不影响 int 列索引,但在排查时建议还是保持类型一致,避免靠经验猜。
另一种“假失效”来自优化器的成本判断。即便查询条件没有函数转换,如果区分度太低、或者需要回表的行数太多,优化器觉得全表扫描顺序读比索引随机回表便宜,也会不用索引。这时候 explain 里 type=ALL,但 SQL 本身没什么毛病,属于表和统计信息的实际情况导致的。用 ANALYZE TABLE orders; 更新统计信息后可能恢复正常。
4.3 用实际 SQL 场景做一次优化改造
假设现在有一条线上慢查询,原始 SQL 是这样:
sql复制SELECT *
FROM orders
WHERE YEAR(created_at) = 2024
AND user_id = 100
ORDER BY created_at;
索引是 user_id 和 created_at 的联合索引:
sql复制ALTER TABLE orders ADD INDEX idx_user_created(user_id, created_at);
explain 一看,possible_keys 里有 idx_user_created,key 却没有,或者只用了 user_id 一个列的 key_len,type 从 ref 变成了 ALL。
问题出在 YEAR(created_at) 上,函数让 created_at 无法和索引树的排序键直接比较。改造方法是把条件改成范围查询:
sql复制SELECT *
FROM orders
WHERE user_id = 100
AND created_at >= '2024-01-01 00:00:00'
AND created_at < '2025-01-01 00:00:00'
ORDER BY created_at;
这样 user_id 用于精确匹配,created_at 用于范围匹配,联合索引的两个字段都能用上。改造后行数预期大幅下降,filesort 也可能直接消失,因为 created_at 在联合索引里已经有序。
再举个例子,深分页导致的慢查询:
sql复制SELECT *
FROM orders
ORDER BY id
LIMIT 100000, 20;
LIMIT 100000, 20 意味着 MySQL 需要先扫出前 100020 行,然后丢掉前面的 100000 行,只返回最后 20 行。前 10 万行的扫描和回表成本很夸张。
常见优化方式是把偏移量转成上一个位置的主键:
sql复制SELECT *
FROM orders
WHERE id > 100000
ORDER BY id
LIMIT 20;
或者配合子查询先拿到需要跳过的起始主键:
sql复制SELECT *
FROM orders
WHERE id >= (SELECT id FROM orders ORDER BY id LIMIT 100000, 1)
ORDER BY id
LIMIT 20;
深分页问题的根治思路是“先定位起点,再取数据”,不是让数据库把大量无用数据搬到服务端。
4.4 不建议强制索引和过度优化
实际工作中,有人一看到全表扫描就急着用 FORCE INDEX(idx),我一般不建议这种做法。FORCE INDEX 等于替优化器做决定,一旦表数据分布变化、统计信息变化、查询条件换值,原来的索引可能就不是最优路径,强推反而会让SQL变得更慢。
正确的排查顺序应该是:先看 explain 的 type、rows、Extra,分析为什么优化器没选索引,再通过改写 SQL、更新统计信息、调整索引结构来解决问题,而不是直接强制指定索引。
过度优化也需要警惕。一张只有几千行的配置表,加不加索引差距几乎可以忽略,乱加索引反而每次插入都要维护。理解索引失效不是为了让每条查询都完美走索引,而是让确实高频、有性能瓶颈的查询走上正确的路。
5. explain 排查与高频面试问题速查
5.1 读懂 explain 关键字段
先记住日常最常用的字段:
| 字段 | 含义 | 判断重点 |
|---|---|---|
| type | 访问类型 | const/eq_ref/ref/range 都还行,index/ALL 要警惕 |
| key | 实际使用索引 | 尽量不是 NULL |
| key_len | 用到的索引字节数 | 判断联合索引用了几列 |
| rows | 预估扫描行数 | 越小越好 |
| Extra | 额外信息 | 出现 Using filesort、Using temporary 要重点分析 |
type 从好到坏粗略排序:const > eq_ref > ref > range > index > ALL。全表扫描对应 ALL,如果 select 的列过多、回表代价高,即使存在索引也可能出现 ALL,要结合 rows 一起看。
配合一个例子:
sql复制EXPLAIN SELECT id, user_name FROM user WHERE user_id = 100 AND status = 2;
如果联合索引是 (user_id, status),user_id 是 int 非空,status 是 tinyint 非空,它的 key_len 大约是 4 + 1 = 5。如果说明执行计划里 key_len 只有 4,意味着 status 这个条件没有被完整用于索引匹配,你就要回头检查条件是否有隐式类型转换或函数处理。
key_len 对 varchar 的计算规则比较常考。如果字段类型是 varchar(20),字符集是 utf8mb4,可为 NULL,那么一个值最大占 20 * 4 + 2 + 1 = 83 字节。额外加 2 是因为 varchar 需要变长长度记录,加 1 是因为允许 NULL。这个不需要背死,能理解计算方法即可。
5.2 常见面试高频问题速查
| 问题 | 结论 |
|---|---|
| 主键索引和普通索引有什么区别 | 主键索引是聚簇索引,叶子存整行;普通索引叶子存主键值,一般有回表 |
| 为什么 InnoDB 表要建议显式主键 | 聚簇索引依赖主键;没有主键时 InnoDB 也会生成隐藏列,业务上不可控 |
| 最左前缀是什么 | 联合索引按从左到右的字段顺序建立索引树,查询条件最好连续覆盖左边的列 |
| 索引下推解决了什么问题 | 在二级索引层提前过滤部分行,减少回表次数 |
| 覆盖索引有什么好处 | 查询需要的列都在索引树叶子中,不用回表 |
LIKE '%abc' 能走索引吗 |
一般不能,前模糊让索引树无法确定搜索起点 |
WHERE id + 1 = 10 能走吗 |
一般不推荐,应改成 WHERE id = 9 |
FIND_IN_SET('xx', tags) 能走索引吗 |
一般不能,MySQL 没有针对字符串集合的原生倒排存储,考虑拆分表或全文检索 |
| 为什么深分页会慢 | 前 N 行会被白白扫描和丢弃,优化方向是“先求起点再取数据” |
| 索引越多越好吗 | 不是,写入维护成本、磁盘空间、优化器选择都会受影响 |
5.3 几个排查索引问题时的落地习惯
最后分享几个我平时排查慢 SQL 时比较受用的习惯。
第一,拿到慢查询后,先把真实 SQL 放到测试环境,用 explain 看执行计划,不要看几眼就猜。重点对比 type、key_len、rows 三列。type 从 ref 变成 range 还能接受,但如果变成 ALL,基本要考虑 SQL 改写或索引结构调整。
第二,MySQL 8.0.18 之后可以用 EXPLAIN ANALYZE,它会真实执行 SQL 并输出每个节点的实际耗时和行数,比传统 explain 的估算值更直观。调试一些优化器行为不明确的 SQL 时,效果非常明显。
第三,线上影响比较大又不好下结论时,我会用优化器追踪看它为什么选这条路。
sql复制SET optimizer_trace = 'enabled=on';
SELECT ...;
SELECT * FROM information_schema.OPTIMIZER_TRACE;
SET optimizer_trace = 'enabled=off';
这里能看到优化器比较各种索引成本的过程,能解释“明明有索引却不用”的大部分疑惑。不过生产环境不建议经常开 trace,偶尔调试一下就可以了。
补充一个容易被忽略的问题:字符集不一致会导致连接查询时字符集隐式转换,从而让关联字段上的索引失效。比如一张表字段是 utf8mb4,另一张表字段是 utf8,JOIN ON 的时候 MySQL 需要把两边都转成同一字符集再比较,一旦对索引列做转换,索引就废了。建表时统一用 utf8mb4,能少踩很多这种坑。索引不是银弹,但它确实是数据库优化中最基础、最值得掌握的一环。建索引时多想一下数据分布,写 SQL 时多想一下函数和类型转换,很多慢查询问题都能在源头避免。
