📖 本篇简介:本篇专门收拾"建了索引却没用上"和"索引建了反而更慢"这两类经典翻车现场。索引是 MySQL 优化里性价比最高的手段,但也是被误解最深的东西——最左前缀、隐式转换、函数包裹、冗余索引,每一个都能让你辛苦建的索引瞬间失效。前置知识是你已经学过 B+Tree 索引、联合索引和 EXPLAIN 的基本字段。学完之后,你应该能对着一条慢 SQL 的 EXPLAIN 截图说出它为什么没走索引、该怎么改索引或改写法。本系列所有示例基于 MySQL 8.0 语法书写,本示例基于 MySQL 8.0 环境。
目录
- 坑 1:对索引列用函数导致索引失效
- 坑 2:字符串索引列被隐式转成数字
- 坑 3:最左前缀被跳过联合索引白建
- 坑 4:左模糊 like 走不了索引
- 坑 5:or 连接非索引列全表扫描
- 坑 6:冗余索引和重复索引拖慢写入
- 坑 7:低选择性列单独建索引
- 坑 8:不等于和 not in 让索引失效
- 坑 9:回表代价大却没建覆盖索引
- 坑 10:索引建太多写入性能崩塌
- 坑 11:联合索引排序方向不一致触发 filesort
- 坑 12:用无意义 uuid 当主键
- 速查表:索引坑点速查
- 注意事项:建索引前先想清楚三件事
- 练习题:动手自测与参考
坑 1:对索引列用函数导致索引失效
场景 / 错误示范:
-- 表:订单表,create_time 上建了普通索引 idx_create_time
CREATE TABLE orders (
id BIGINT PRIMARY KEY,
user_id BIGINT,
create_time DATETIME,
KEY idx_create_time (create_time)
);
-- 错误示范:想查"2026 年 10 月的订单",对 create_time 套了函数
SELECT * FROM orders
WHERE DATE(create_time) = '2026-10-01';
-- EXPLAIN 看 type=ALL,全表扫描!索引没走到
为什么是坑:B+Tree 索引存的是列的原始值,按原始值排序。你对列套了 DATE()、YEAR()、SUBSTRING() 之后,MySQL 必须把每一行的函数结果都算出来再比较,没法用索引的有序性做二分定位。优化器看到函数包裹列,直接放弃走这个索引。
正确做法:
-- 正确做法:把函数挪到右边,改成范围查询,让索引列保持"裸奔"
SELECT * FROM orders
WHERE create_time >= '2026-10-01 00:00:00'
AND create_time < '2026-11-01 00:00:00';
-- EXPLAIN:type=range,命中 idx_create_time
-- 再比如:想查名字前三个字是"张"的,别用 LEFT(name,3)='张'
-- 改成前缀匹配(注意是右模糊,能走索引)
SELECT * FROM users WHERE name LIKE '张%';
加分点:如果业务就是要按函数结果查(比如按月份统计),可以建"函数索引"(MySQL 8.0 支持):CREATE INDEX idx_month ON orders (DATE(create_time));。但能改写法就别加函数索引,维护成本高。
坑 2:字符串索引列被隐式转成数字
场景 / 错误示范:
-- 表:用户表,phone 是 varchar,上面建了索引
CREATE TABLE user (
id BIGINT PRIMARY KEY,
phone VARCHAR(20),
name VARCHAR(50),
KEY idx_phone (phone)
);
-- 错误示范:应用传过来的 phone 是数字 13800001111,没加引号
SELECT * FROM user WHERE phone = 13800001111;
-- EXPLAIN:type=ALL,全表扫描!索引失效
为什么是坑:字符串列和数字比较时,MySQL 会把字符串列隐式转成数字再比,相当于执行了 CAST(phone AS DECIMAL) = 13800001111。这又是对索引列套函数,索引直接失效。而且隐式转换还可能让本来不该相等的记录被误匹配(比如 '138abc' 转成 138)。
正确做法:
-- 正确做法:字符串常量加引号,类型对齐
SELECT * FROM user WHERE phone = '13800001111';
-- EXPLAIN:ref,命中 idx_phone
-- 应用层也要注意:MyBatis 里 #{phone} 是预编译带类型的,
-- 别写成 ${phone} 拼字符串,更别把 phone 定义成数字类型去存手机号
<!-- MyBatis 正确写法:用 #{} 预编译,不要用 ${} -->
<select id="findByPhone" resultType="User">
SELECT id, name FROM user WHERE phone = #{phone}
</select>
加分点:JOIN 时两边列的类型、字符集、排序规则不一致也会隐式转换导致索引失效。建表时把同语义字段(如 user_id、order_no)的类型和字符集对齐,是基本功。
坑 3:最左前缀被跳过联合索引白建
场景 / 错误示范:
-- 表:订单表,建了联合索引 idx_a_b_c (user_id, status, create_time)
CREATE TABLE orders (
id BIGINT PRIMARY KEY,
user_id BIGINT,
status TINYINT,
create_time DATETIME,
KEY idx_a_b_c (user_id, status, create_time)
);
-- 错误示范一:跳过第一列 user_id,直接用 status 查
SELECT * FROM orders WHERE status = 1;
-- 联合索引白建,走不了(或只能用到部分)
-- 错误示范二:范围查询用在了中间列,后面的列用不上
SELECT * FROM orders
WHERE user_id = 100 AND create_time > '2026-10-01';
-- user_id 能用上,create_time 用不上(跳过了 status)
为什么是坑:联合索引 (a,b,c) 是先按 a 排序、a 相同再按 b、b 相同再按 c。你不从最左列 a 开始,索引整体就是乱序的,没法二分定位。范围查询(>、<、between、like)一旦用在中间列,它右边的列在索引里就不再有序,所以也用不上。
正确做法:
-- 正确做法一:按最左前缀来,把等值条件列放前面
SELECT * FROM orders
WHERE user_id = 100 AND status = 1
AND create_time >= '2026-10-01';
-- 三列都能用上 idx_a_b_c
-- 正确做法二:业务经常只按 status 查,就单独给 status 建索引
ALTER TABLE orders ADD KEY idx_status (status);
-- 正确做法三:把范围列放联合索引的最后
-- 改成 idx_user_status_time (user_id, status, create_time)
-- 已经是这样了,配合最左前缀即可
加分点:记住一个口诀:"等值列在前,范围列在后;排序列也尽量放最后"。建联合索引前先把典型查询的 WHERE 条件列出来,按"谁等值、谁范围、谁排序"排个序,再决定列顺序。
坑 4:左模糊 like 走不了索引
场景 / 错误示范:
-- 表:商品表,name 上有索引 idx_name
CREATE TABLE goods (
id BIGINT PRIMARY KEY,
name VARCHAR(200),
KEY idx_name (name)
);
-- 错误示范:想搜"包含'键盘'的商品",写成左模糊
SELECT * FROM goods WHERE name LIKE '%键盘%';
-- EXPLAIN:type=ALL,全表扫描,索引完全没用
为什么是坑:B+Tree 按列值的字典序排好。LIKE '键盘%' 是已知前缀,可以沿着树定位到以"键盘"开头的区间。但 LIKE '%键盘%' 开头是通配符,意味着"任意前缀",索引不知道从哪开始找,只能全表扫一遍。
正确做法:
-- 正确做法一:能右模糊就右模糊
SELECT * FROM goods WHERE name LIKE '键盘%'; -- 走索引
-- 正确做法二:必须全文检索,用 FULLTEXT 索引
ALTER TABLE goods ADD FULLTEXT INDEX ft_name (name);
SELECT * FROM goods WHERE MATCH(name) AGAINST('键盘' IN BOOLEAN MODE);
-- 正确做法三:海量文本搜索交给 Elasticsearch,别硬扛 MySQL
-- MySQL 的全文索引只适合中小规模,中文还要配 ngram 解析器
加分点:中文全文检索要建 WITH PARSER ngram 的全文索引,否则按词不分。但真要做搜索框,别在 MySQL 里死磕,ES / OpenSearch 才是正解。
坑 5:or 连接非索引列全表扫描
场景 / 错误示范:
-- 表:user 表,phone 有索引 idx_phone,id_card 没索引
CREATE TABLE user (
id BIGINT PRIMARY KEY,
phone VARCHAR(20),
id_card VARCHAR(20),
name VARCHAR(50),
KEY idx_phone (phone)
);
-- 错误示范:or 两边,一边走索引一边没索引
SELECT * FROM user WHERE phone = '13800001111' OR id_card = '3708********1234';
-- 因为 id_card 没索引,优化器干脆全表扫描,phone 的索引也白搭
为什么是坑:OR 条件要求"两边任一满足"都返回。如果其中一个条件列没有索引,MySQL 没法用索引快速定位,只能把整表扫一遍,逐行判断两个条件。就算另一边有索引,也会被这个没索引的条件拖垮。
正确做法:
-- 正确做法一:给 OR 涉及的列都建索引
ALTER TABLE user ADD KEY idx_idcard (id_card);
-- 之后 OR 两边都能走索引,MySQL 会做 index merge
-- 正确做法二:改成 UNION,让两段各自走自己的索引
SELECT * FROM user WHERE phone = '13800001111'
UNION
SELECT * FROM user WHERE id_card = '3708********1234';
加分点:OR 和 IN 不一样,IN (1,2,3) 对单列是可以走索引的。能改 IN 就别写 OR;必须 OR 就保证涉及的列都有索引,或者拆 UNION。
坑 6:冗余索引和重复索引拖慢写入
场景 / 错误示范:
-- 表:已经建了联合索引 idx_a_b (a, b)
CREATE TABLE t (
id BIGINT PRIMARY KEY,
a INT,
b INT,
c INT,
KEY idx_a_b (a, b),
KEY idx_a (a) -- 冗余!idx_a_b 已经覆盖了 a 的最左前缀
);
-- 更典型的:主键 id 已经聚簇索引,又建了一个 KEY id (id)
-- 或者 (a,b) 和 (a,b,c) 同时存在,(a,b) 就是冗余的
为什么是坑:idx_a_b(a,b) 已经能满足所有按 a 单独查的需求(最左前缀),再建 idx_a(a) 是重复造轮子。每个索引在每次 INSERT/UPDATE/DELETE 时都要维护一份 B+Tree,冗余索引白白增加写放大和存储空间,还让优化器多纠结选哪个。
正确做法:
-- 正确做法:删掉冗余索引
ALTER TABLE t DROP INDEX idx_a;
-- 查一下数据库里现有的冗余索引,mysql 8.0 有现成视图
SELECT table_name, index_name, column_name
FROM information_schema.statistics
WHERE table_schema = 'shop'
ORDER BY table_name, index_name, seq_in_index;
-- 人工扫一遍:凡是 (a) 是 (a,b) 最左前缀的,(a) 就是冗余的
-- 8.0 还可以用 sys.schema_redundant_indexes 视图一键查
-- SELECT * FROM sys.schema_redundant_indexes WHERE table_schema='shop';
加分点:主键不要单独再建普通索引。InnoDB 的二级索引叶子节点存的是主键值,对主键的查询直接走聚簇索引,再建一个 KEY(id) 纯属浪费。
坑 7:低选择性列单独建索引
场景 / 错误示范:
-- 表:订单表,status 只有 0 待支付、1 已支付、2 已取消三个值
CREATE TABLE orders (
id BIGINT PRIMARY KEY,
status TINYINT,
amount DECIMAL(10,2),
KEY idx_status (status) -- 选择性极差,单独建索引意义不大
);
-- 查"已支付订单",命中 idx_status,结果是表里 70% 的行
-- 优化器算一算:回表比全表扫描还慢,干脆放弃索引
SELECT * FROM orders WHERE status = 1; -- 实际还是全表扫
为什么是坑:索引的价值在于"快速缩小范围"。如果一个列只有几个枚举值,查出来的行占整表比例很高(比如 30%+),走索引反而要先查二级索引再回表,代价比一口气全表扫还大,优化器会自动放弃。这种列单独建索引是浪费。
正确做法:
-- 正确做法:把低选择性列和高频过滤列组成联合索引
-- 比如经常按 status + create_time 查,就建联合索引
ALTER TABLE orders ADD KEY idx_status_time (status, create_time);
SELECT * FROM orders
WHERE status = 1 AND create_time >= '2026-10-01';
-- 这样既利用了 status 的等值,又用 create_time 缩小范围
-- 如果只是按 status 查大量数据,那本来就不该用索引,全表扫合理
-- 这种需求更适合做报表或走归档,别硬点查询
加分点:选择性 = 唯一值数 / 总行数,越接近 1 越好。性别、状态、是否删除这种列,单独建索引前先算一下选择性,通常都不到 0.1,老老实实放到联合索引里当第一个等值列就行。
坑 8:不等于和 not in 让索引失效
场景 / 错误示范:
-- 表:用户表,status 有索引
CREATE TABLE user (
id BIGINT PRIMARY KEY,
status TINYINT,
name VARCHAR(50),
KEY idx_status (status)
);
-- 错误示范:查"未删除的用户",用了 !=
SELECT * FROM user WHERE status != 0;
-- 或者:WHERE status NOT IN (0)
-- 这两种写法通常走不了索引,或走了也要扫大部分行
为什么是坑:!=、NOT IN、NOT EXISTS 的语义是"除了某值以外的所有",天然要扫大半个索引。B+Tree 索引适合定位"某个具体值或区间",而不是"排除某个值"。优化器面对不等于,经常选择全表扫描。
正确做法:
-- 正确做法:把不等于改写成明确的范围或枚举
-- 比如 status:0=删除,1=正常,2=冻结,要查未删除就是 status IN (1,2)
SELECT * FROM user WHERE status IN (1, 2);
-- IN 对单列是能走索引的
-- 业务上尽量用"正向枚举"代替"反向排除"
-- 别写 WHERE is_deleted != 1,写 WHERE is_deleted = 0
加分点:设计表时加一个 is_deleted TINYINT DEFAULT 0 做软删除,查询永远带 WHERE is_deleted = 0,比 != 1 友好得多,也方便走联合索引。
坑 9:回表代价大却没建覆盖索引
场景 / 错误示范:
-- 表:订单表,联合索引 idx_user_status (user_id, status)
CREATE TABLE orders (
id BIGINT PRIMARY KEY,
user_id BIGINT,
status TINYINT,
amount DECIMAL(10,2),
create_time DATETIME,
KEY idx_user_status (user_id, status)
);
-- 错误示范:查询需要 amount,但索引里没存 amount
SELECT id, user_id, status, amount FROM orders
WHERE user_id = 100 AND status = 1;
-- 走到 idx_user_status 找到主键后,还要回表查 amount,多了一次随机 IO
为什么是坑:二级索引的叶子节点存的是索引列 + 主键值。如果 SELECT 要的列不在索引里,就得拿主键再去聚簇索引查一次(回表)。回表是随机 IO,查的行一多,代价剧增。EXPLAIN 里看到 Using index condition 而不是 Using index,就是在回表。
正确做法:
-- 正确做法:建覆盖索引,把查询需要的列都塞进索引
ALTER TABLE orders ADD KEY idx_user_status_cover (user_id, status, amount);
SELECT user_id, status, amount FROM orders
WHERE user_id = 100 AND status = 1;
-- EXPLAIN:Extra 出现 'Using index',不回表,直接从索引拿数据
-- 注意:SELECT 列表里的列都要在索引里,否则还是要回表
-- 别 SELECT *,只取需要的列,覆盖索引才生效
加分点:覆盖索引是"用空间换时间"的典型。高频查询把它需要的列全塞进联合索引,是 MySQL 优化里性价比极高的一招。但别为了覆盖而把索引建得太宽,写性能会下降。
坑 10:索引建太多写入性能崩塌
场景 / 错误示范:
-- 错误示范:一张表建了七八个索引,生怕哪个查询走不到
CREATE TABLE t (
id BIGINT PRIMARY KEY,
a INT, b INT, c INT, d INT, e INT, f INT,
KEY idx_a(a), KEY idx_b(b), KEY idx_c(c),
KEY idx_d(d), KEY idx_e(e), KEY idx_f(f)
);
-- 每次 INSERT 要维护主键 + 6 个二级索引,写放大严重
-- 磁盘 IO 翻倍,主从延迟拉大,Buffer Pool 也被冷索引页挤占
INSERT INTO t VALUES(1,2,3,4,5,6,7);
为什么是坑:索引不是免费的。每多一个二级索引,每次写操作都要多维护一棵 B+Tree。读多写少的表多建索引没事,写密集的表(订单流水、消息队列)索引一多,写性能直接腰斩。而且每个索引都占 Buffer Pool 空间,挤掉了真正热点数据的位置。
正确做法:
-- 正确做法:定期审计索引,删掉没用的
-- 先看每个索引的使用情况
SELECT object_schema, object_name, index_name, rows_read
FROM performance_schema.table_io_waits_summary_by_index_usage
WHERE index_name IS NOT NULL
ORDER BY rows_read ASC;
-- rows_read 长期为 0 的索引就是没人用的,可以删
-- 原则:高频查询建联合索引覆盖,低频查询靠全表扫或报表库
-- 一张核心表的索引数量控制在 5 个以内
加分点:上线新索引前,先用 EXPLAIN 确认它真的被 SQL 用上了,再观察一周的 rows_read。建了不用的索引,对写是纯负担。
坑 11:联合索引排序方向不一致触发 filesort
场景 / 错误示范:
-- 表:订单表,联合索引 idx_user_time (user_id, create_time)
CREATE TABLE orders (
id BIGINT PRIMARY KEY,
user_id BIGINT,
create_time DATETIME,
amount DECIMAL(10,2),
KEY idx_user_time (user_id, create_time)
);
-- 错误示范:想按用户查订单,时间倒序
SELECT * FROM orders
WHERE user_id = 100
ORDER BY create_time DESC, amount ASC;
-- 索引里 create_time 是默认升序,这里又要 amount 升序
-- 两个排序列方向不一致,MySQL 只能 filesort
为什么是坑:B+Tree 索引本身是有序的,但只能按一个方向用。如果 ORDER BY 里多个列的排序方向和索引不一致(比如一个 DESC 一个 ASC),索引的有序性就用不上,MySQL 得把结果拉到 sort_buffer 里手动排一次,这就是 filesort,数据量大时很慢。
正确做法:
-- 正确做法一:排序方向尽量和索引一致,或在索引里显式指定方向
ALTER TABLE orders ADD KEY idx_user_time_desc (user_id, create_time DESC);
SELECT * FROM orders
WHERE user_id = 100
ORDER BY create_time DESC; -- 走索引顺序,无 filesort
-- 正确做法二:真要多列混合排序,把它们一起设计进索引
-- idx (user_id, create_time DESC, amount ASC)
ALTER TABLE orders ADD KEY idx_mix (user_id, create_time DESC, amount ASC);
-- EXPLAIN 里看到 Extra 没有 'Using filesort' 就是走了索引排序
加分点:MySQL 8.0 支持降序索引,早期版本写 DESC 会被忽略。EXPLAIN 的 Extra 列看到 Using filesort 别慌,小结果集 filesort 很快;只有大结果集 + 高并发才需要优化。
坑 12:用无意义 uuid 当主键
场景 / 错误示范:
-- 错误示范:为了分布式生成主键,直接用 UUID 字符串做主键
CREATE TABLE t (
id VARCHAR(36) PRIMARY KEY, -- UUID 字符串
name VARCHAR(50)
);
-- 插入时 id 是随机的,B+Tree 页分裂频繁,数据页到处乱跳
INSERT INTO t VALUES(UUID(), 'a');
INSERT INTO t VALUES(UUID(), 'b');
为什么是坑:InnoDB 聚簇索引按主键顺序组织数据页。用自增主键时,新记录总是顺序追加到最后一个页,页满了再开新页,写入极顺。用随机 UUID 做主键,每次插入都可能落到 B+Tree 的任意位置,导致页分裂、数据页碎片、Buffer Pool 命中率下降。UUID 还比 BIGINT 占空间(36 字节 vs 8 字节),所有二级索引都跟着变大。
正确做法:
-- 正确做法一:单机用自增主键
CREATE TABLE t (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
name VARCHAR(50)
);
-- 正确做法二:分布式用雪花算法(Snowflake)生成趋势递增的 BIGINT
-- 雪花 ID 本质是个 64 位长整型,时间戳在高位,整体趋势递增
-- 既保证全局唯一,又避免随机插入导致的页分裂
CREATE TABLE t (
id BIGINT PRIMARY KEY, -- 应用层用雪花算法生成
name VARCHAR(50)
);
-- 如果一定要用 UUID,至少用有序的 UUID v7(时间在前)
-- 或者把 UUID 单独存一列,主键仍然自增
加分点:InnoDB 表几乎都推荐用 BIGINT 自增或雪花 ID 做主键。UUID 做主键的表,你会明显感觉到写入性能比预期差、表空间膨胀快,这不是错觉。
速查表:索引坑点速查
| 坑号 | 坑点 | 现象(EXPLAIN 关键) | 修法 |
|---|---|---|---|
| 1 | 函数包裹索引列 | type=ALL | 把函数挪到右边,改范围查询 |
| 2 | 字符串列隐式转数字 | type=ALL | 常量加引号,类型对齐 |
| 3 | 跳过联合索引最左列 | 只用到部分索引 | 等值列在前,范围列在后 |
| 4 | 左模糊 %xx | type=ALL | 右模糊 / FULLTEXT / ES |
| 5 | OR 含非索引列 | type=ALL | 给两边建索引或拆 UNION |
| 6 | 冗余重复索引 | 索引重复 | 删掉是最左前缀的重复索引 |
| 7 | 低选择性列单建 | 优化器放弃索引 | 并进联合索引当等值列 |
| 8 | != / NOT IN | 走不了索引 | 改写成 IN 正向枚举 |
| 9 | 回表代价大 | Extra 无 Using index | 建覆盖索引,别 SELECT * |
| 10 | 索引太多 | 写放大严重 | 审计 rows_read,删废索引 |
| 11 | 排序方向不一致 | Extra: Using filesort | 降序索引 / 对齐排序方向 |
| 12 | UUID 主键 | 页分裂、写入慢 | 自增或雪花 BIGINT |
注意事项:建索引前先想清楚三件事
别一看到慢 SQL 就 CREATE INDEX,建之前先问自己三个问题:
1. 这个查询多久跑一次? 一天跑一次的报表 SQL,慢一点无所谓;一秒跑几十次的接口 SQL,才值得专门建索引优化。
2. 这个列的选择性够不够? 唯一值太少的列单独建索引没用,要并进联合索引。
3. 写放大扛得住吗? 这张表一天要写多少行?多一个索引就要多维护一份,写密集表要克制。
建完索引后,用 EXPLAIN 确认 key 列真的选了你新建的索引,再用 performance_schema 观察一周它的 rows_read,确认不是个摆设。
练习题:动手自测与参考
练习 1:WHERE DATE(create_time) = '2026-10-01' 走不上 idx_create_time,怎么改写?
<details>
<summary>参考答案</summary>
改成范围查询:WHERE create_time >= '2026-10-01' AND create_time < '2026-10-02',让索引列保持裸奔。或者建函数索引 CREATE INDEX idx_ct ON orders((DATE(create_time)));。
</details>
练习 2:联合索引 (a, b, c),下面哪条 SQL 能用上几列?①WHERE a=1 ②WHERE b=1 ③WHERE a=1 AND c=1 ④WHERE a=1 AND b=2 AND c=3。
<details>
<summary>参考答案</summary>
①用到 a 一列;②一列都用不上(跳过最左);③用到 a,c 用不上(跳过 b);④三列全用。记住最左前缀 + 范围列之后失效。
</details>
练习 3:EXPLAIN 里 type=ref 但 Extra 是 Using where; Using index condition,没有 Using index,说明什么?
<details>
<summary>参考答案</summary>
说明走了索引但还在回表(Using index condition 是索引下推,不等于覆盖索引)。要消除回表,把 SELECT 需要的列都加进联合索引做成覆盖索引,Extra 出现 Using index 才算覆盖。
</details>
下一篇预告:第 35 章 MySQL踩坑:SQL写法篇
文章回复
0 条公开回复