MySQL 索引失效完全指南:从一次 8 秒慢查询看懂索引为什么没走

2022 年我维护一个订单查询系统,orders 表几百万行。某天 DBA 告警:一个按日期查订单的接口慢到 8 秒,数据库 CPU 被一条 SQL 打满。我清楚记得 created_at 列上建了索引,可 EXPLAIN 一看 type=ALL、key=NULL——索引根本没走,几百万行从头扫到尾。罪魁是 WHERE DATE(created_at)= 这种写法:对一个建了索引的列套了 DATE() 函数,索引就这么悄无声息地废了,而 SQL 语法完全合法、测试环境数据量小时还跑得飞快,根本不报错。那次之后我才认真搞明白:在 MySQL 里"建了索引"和"查询走了索引"是两码事,中间隔着一长串能让索引失效的坑。本文系统梳理:先学会用 EXPLAIN 判断索引到底走没走(盯死 type 和 key 两列);再逐个拆解六类失效——对索引列套函数或做运算、字符串列传数字触发隐式类型转换、违反联合索引最左前缀、LIKE 左模糊与 OR 带无索引列、ORDER BY/GROUP BY 没搭上索引导致 filesort、LIMIT 深分页越翻越慢;最后讲回表与覆盖索引、索引下推、统计信息过期、优化器选错索引这几个进阶工程坑。核心一句:索引列必须以原始、有序、可定位的形态出现在查询条件里,任何加工都让它失去 B+ 树的有序性;索引优化不是建完就完事,而是 EXPLAIN 驱动的持续观测。

2022 年我维护一个订单查询系统,有张 orders 表几百万行。某天 DBA 在群里甩了一条告警:一个按日期查订单的接口慢到 8 秒,数据库 CPU 被一条 SQL 打满。我第一反应是"不可能",因为我清清楚楚记得 created_at 列上建了索引。我把那条 SQL 抠出来 EXPLAIN 一看,人都麻了:type=ALL、key=NULL——它根本没走索引,几百万行从头扫到尾。问题 SQL 长这样:WHERE DATE(created_at) = '2022-06-01'。我对一个建了索引的列套了个 DATE() 函数,索引就这么悄无声息地废了,而 SQL 本身语法完全合法、测试环境数据量小时还跑得飞快,根本不报错。那天之后我才认真搞明白一件事:在 MySQL 里"建了索引"和"查询走了索引"是两码事,中间隔着一长串能让索引失效的坑,而它们都不会报错,只会让你的库慢慢变慢直到某天被一次大流量压垮。

问题背景

现象:orders 表(约 380 万行),按日期查询订单的接口 P99 从 80ms 飙到 8s
      数据库 CPU 接近 100%,慢查询日志里全是同一条 SQL

我的错误认知:
  "created_at 上建了索引 → 所有用到 created_at 的查询都会走索引"

真相:
  索引能不能用,取决于"查询条件怎么写",而不是"列上有没有索引"
  对索引列做任何加工(函数、计算、类型转换),都会让 B+ 树索引无法定位
  联合索引还有"最左前缀"规则,顺序写错一样用不上

为什么测试时没发现:
  测试库只有几千行,全表扫描也是毫秒级,看不出区别
  索引失效不报错、不告警,只是"慢",量小时根本察觉不到
  等数据涨到几百万行 + 一波真实流量,才会突然爆雷

排查的唯一可靠手段:EXPLAIN
  不要凭感觉、不要凭"我建了索引"的记忆,执行计划才是事实

一、先学会看 EXPLAIN:索引到底走没走,它说了算

# 排查索引问题,第一步永远是 EXPLAIN,而不是改 SQL
# EXPLAIN 输出里,真正要盯死的是这几列:

# type —— 访问类型,性能从好到坏大致是:
#   system > const > eq_ref > ref > range > index > ALL
#   看到 ALL 就是全表扫描,看到 index 是扫整棵索引树,这两个都要警惕
#   能稳定在 ref / range 通常就健康

# key —— 实际用上的索引名
#   key=NULL = 没走任何索引,这是最直接的失效信号

# possible_keys —— 优化器"本来可以用"的索引
#   possible_keys 有值但 key=NULL,说明优化器看到了索引却放弃了,要深究

# rows —— 预估要扫描的行数
#   这个数接近全表行数,基本就是全表扫描了

# Extra —— 附加信息,几个关键词:
#   Using index        覆盖索引,不用回表,最理想
#   Using where        在存储引擎取数后还要再过滤
#   Using filesort     额外排序,没用上索引的有序性
#   Using temporary    用了临时表,常见于 group by / distinct

# === 小结 ===
# 判断索引有没有失效,不靠猜,就看 EXPLAIN 的 type 和 key 两列
-- 当年那条慢查询,EXPLAIN 出来的样子(简化)
EXPLAIN
SELECT id, user_id, amount, status
FROM orders
WHERE DATE(created_at) = '2022-06-01';

-- 输出关键列:
-- +------+---------------+------+---------+---------+
-- | type | possible_keys | key  | rows    | Extra   |
-- +------+---------------+------+---------+---------+
-- | ALL  | NULL          | NULL | 3812044 | Using where |
-- +------+---------------+------+---------+---------+
-- type=ALL、key=NULL、rows≈全表 → 铁证:索引没走,全表扫描

-- 对照:一条走了索引的查询长什么样
EXPLAIN
SELECT id, user_id, amount, status
FROM orders
WHERE created_at >= '2022-06-01 00:00:00'
  AND created_at <  '2022-06-02 00:00:00';

-- +-------+--------------------+--------------------+------+-----------------------+
-- | type  | possible_keys      | key                | rows | Extra                 |
-- +-------+--------------------+--------------------+------+-----------------------+
-- | range | idx_orders_created | idx_orders_created | 5120 | Using index condition |
-- +-------+--------------------+--------------------+------+-----------------------+
-- type=range、key=用上了、rows 从 380 万降到 5 千 —— 这才是健康的样子

二、对索引列动手脚:函数和计算一上,索引立刻废

如果说索引失效有一个"头号杀手",那一定是对索引列做加工。它最高频也最隐蔽,因为这些加工动作往往写得理所当然——按月统计就套 YEAR()、按天查询就套 DATE()、想去掉前后空格就套 TRIM()——SQL 读起来顺理成章,逻辑也完全正确,却恰恰在这一步把索引废掉了。你不会收到任何报错,只会得到一条慢查询。

# 这是最高频、最隐蔽的索引失效原因,占了我见过的事故一大半

# 原理:B+ 树索引存的是"列的原始值"排好序的结构
#   查 created_at 索引,树里存的是一个个原始的 datetime 值
#   一旦你写 DATE(created_at),你要找的是"函数计算后的结果"
#   而树里没有"计算后的值",MySQL 没法用有序结构二分定位
#   只能把每一行都取出来、挨个算一遍函数再比较 → 退化成全表扫描

# 同理失效的写法:
#   对索引列做四则运算:WHERE amount + 100 > 1000
#   对索引列做函数:WHERE YEAR(created_at) = 2022
#   对索引列做拼接 / 截取:WHERE SUBSTRING(phone, 1, 3) = '138'

# 解法只有一个思路:别动列,把加工挪到"常量那一侧"
#   不要 DATE(created_at) = '2022-06-01'
#   而要 created_at >= '2022-06-01' AND created_at < '2022-06-02'
#   等价改写成"范围查询",列本身保持原样,索引就能用

# === 小结 ===
# 索引列必须保持"裸"的状态出现在条件里,任何加工都让它失去有序性
-- 错误:对索引列套函数,索引失效(type=ALL)
SELECT * FROM orders WHERE DATE(created_at) = '2022-06-01';
SELECT * FROM orders WHERE YEAR(created_at) = 2022 AND MONTH(created_at) = 6;

-- 正确:改写成范围查询,索引列保持裸值(type=range)
SELECT * FROM orders
WHERE created_at >= '2022-06-01 00:00:00'
  AND created_at <  '2022-06-02 00:00:00';

SELECT * FROM orders
WHERE created_at >= '2022-06-01 00:00:00'
  AND created_at <  '2022-07-01 00:00:00';

-- 错误:对索引列做运算
SELECT * FROM orders WHERE amount + 100 > 1000;
-- 正确:把运算移到常量侧
SELECT * FROM orders WHERE amount > 900;

-- 如果业务确实必须按"函数结果"查询(比如经常按月统计),
-- MySQL 5.7+ 支持函数索引 / 生成列,给计算结果单独建索引:
ALTER TABLE orders
  ADD COLUMN created_date DATE
  GENERATED ALWAYS AS (DATE(created_at)) STORED;
ALTER TABLE orders ADD INDEX idx_created_date (created_date);
-- 此后 WHERE created_date = '2022-06-01' 就能走 idx_created_date

三、隐式类型转换:一个引号没加,索引就没了

上一节的函数失效至少"看得见"——你能在 SQL 里直接看到 DATE()YEAR() 这些函数。这一节要讲的隐式类型转换更阴险:它没有任何可见的加工动作,只是一个本该加的引号没加,MySQL 就在背后偷偷给你的索引列套上了一层 CAST。代码评审时谁也不会觉得 WHERE user_id = 123 有什么问题,可它就是不走索引。

# 这个坑比函数还隐蔽,因为 SQL 里你"看不到"任何加工动作

# 场景:user_id 列是 varchar(订单表里很多 ID 字段是字符串),
#   你写 WHERE user_id = 123 —— 把字符串列和数字比较

# MySQL 的隐式转换规则:当字符串和数字比较时,
#   会把"字符串"转成"数字"再比,也就是 CAST(user_id AS ...) = 123
#   注意:被转换的是"列那一侧",等价于对索引列套了函数 → 索引失效!

# 反过来:varchar 列传字符串 '123' 不会失效,因为列那侧没被转换
# int 列传字符串 '123' 也不会失效,转换的是常量侧

# 一句话记忆:
#   字符串列,一定要传字符串(带引号),传数字必失效
#   数字列,传什么都行(传字符串只转常量侧)

# 同类问题:字符集不一致
#   两张表 join,join 列一张是 utf8mb4 一张是 utf8,
#   连接时要做字符集转换,一样会让 join 列上的索引失效

# === 小结 ===
# 字符串类型的索引列,查询条件里的值必须加引号,这不是风格问题是性能问题
-- 假设 user_id 是 varchar(32) 且建了索引 idx_user_id

-- 错误:字符串列传数字,触发隐式转换,索引失效(type=ALL)
EXPLAIN SELECT * FROM orders WHERE user_id = 100237;
-- MySQL 实际执行的是 CAST(user_id AS DOUBLE) = 100237 —— 列被加工了

-- 正确:字符串列传字符串,索引正常(type=ref)
EXPLAIN SELECT * FROM orders WHERE user_id = '100237';

-- 排查字符集不一致导致的 join 失效:先看两张表的列定义
SHOW FULL COLUMNS FROM orders WHERE Field = 'user_id';
SHOW FULL COLUMNS FROM users  WHERE Field = 'id';
-- 若 Collation 一个是 utf8mb4_general_ci 一个是 utf8_general_ci,join 必慢

-- 修复:统一字符集(以表为单位改,别只改一列)
ALTER TABLE orders CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;

四、最左前缀:联合索引不是"建了就随便用"

前面三节针对的都是单列索引,但真实业务里联合索引用得更多——一张订单表往往按"用户 + 状态 + 时间"这种组合来查。联合索引的麻烦在于:它不是"包含了这几列,这几列就能随便组合查",而是有一套严格的使用规则,叫最左前缀原则。理解不了这条规则,你精心建的联合索引会在很多查询里悄悄用不上,而 EXPLAIN 之前你完全不会察觉。

# 联合索引(a, b, c)在 B+ 树里的排序规则是:
#   先按 a 排,a 相同再按 b 排,b 也相同再按 c 排
#   就像电话簿先按姓排、姓相同按名排

# 这决定了一条铁律——最左前缀原则:
#   查询条件必须从最左列 a 开始连续使用,中间不能断
#   能用上索引:(a) / (a,b) / (a,b,c)
#   用不上:(b) / (c) / (b,c) —— 没有 a 打头,树根本无从查起
#   部分用上:(a,c) —— 只有 a 能用,c 跳过了 b 用不了

# 注意两个细节:
#   1. 这里说的"顺序"是索引定义的顺序,不是 SQL 里 WHERE 的书写顺序
#      WHERE c=3 AND a=1 AND b=2 和 WHERE a=1 AND b=2 AND c=3 等价
#      优化器会自动调整,你不用纠结 WHERE 里谁先写
#   2. 中间列用了范围查询(> < between like),
#      它右边的列就用不上索引了 —— 范围列之后索引"断"在这里

# 建联合索引的列顺序怎么定:
#   等值查询频繁、区分度高的列放左边
#   范围查询的列尽量放右边

# === 小结 ===
# 联合索引能用上多少,看条件是否从最左列起连续命中,范围查询会截断后续列
-- 建一个联合索引(user_id, status, created_at)
ALTER TABLE orders ADD INDEX idx_u_s_c (user_id, status, created_at);

-- 能用上完整索引(三列全中)
SELECT * FROM orders
WHERE user_id = '100237' AND status = 2
  AND created_at >= '2022-06-01';

-- 能用上(user_id + status,符合最左前缀)
SELECT * FROM orders WHERE user_id = '100237' AND status = 2;

-- 用不上索引!跳过了最左列 user_id
SELECT * FROM orders WHERE status = 2 AND created_at >= '2022-06-01';

-- 只能用上 user_id 一列:status 用了范围,created_at 被截断
SELECT * FROM orders
WHERE user_id = '100237' AND status > 1
  AND created_at >= '2022-06-01';
-- 优化:把范围列放最后建索引 (user_id, created_at, status) 视查询模式而定

-- WHERE 书写顺序无所谓,下面这条和第一条完全等价、一样走完整索引
SELECT * FROM orders
WHERE created_at >= '2022-06-01' AND status = 2 AND user_id = '100237';

五、范围、LIKE、OR、NOT:四个最容易翻车的写法

到这里你已经避开了函数、类型转换、最左前缀这三大坑。但还有一类失效和"加工列"没有关系,纯粹是某些查询写法本身让优化器没法用、或者算账之后不愿意用索引。下面这四种写法,是日常 SQL 里最容易不知不觉踩中的,而且它们看上去都人畜无害。

# 即使没碰函数、没踩最左前缀,这几种写法照样让索引失效:

# 1) LIKE 以通配符开头
#    LIKE 'abc%'  能走索引(相当于范围查 abc 开头的)
#    LIKE '%abc'  不能走 —— 前缀不确定,B+ 树无从定位
#    LIKE '%abc%' 不能走 —— 模糊搜索请交给 ES,别硬用 MySQL

# 2) OR 连接的条件里有一列没索引
#    WHERE a = 1 OR b = 2,只要 b 没索引,整条 OR 都退化成全表扫描
#    因为 OR 要求"任一成立",有一个条件没索引就只能逐行判断
#    解法:给 b 也建索引,或用 UNION 把两个条件拆成两条能走索引的查询

# 3) != / <> / NOT IN / NOT EXISTS
#    "排除某些值"通常意味着要扫描大部分行,优化器多半放弃索引
#    这不算 bug,是优化器算过账:扫描大半张表还不如直接全表

# 4) 索引列参与 IS NULL / IS NOT NULL
#    IS NULL 有时能走索引,IS NOT NULL 经常走不了,取决于 null 占比
#    根治办法:建表时给列加 NOT NULL 默认值,从源头少用 null

# 还有个隐形杀手:区分度太低
#    比如 status 只有 0/1/2 三个值,即使建了索引,
#    优化器算出"走索引要回表的行数 ≈ 全表",会主动放弃索引

# === 小结 ===
# LIKE 左模糊、OR 带无索引列、否定条件、低区分度列,都会让优化器放弃索引
-- LIKE:右模糊能走,左模糊不能走
SELECT * FROM orders WHERE order_no LIKE 'NO2022%';   -- type=range,走索引
SELECT * FROM orders WHERE order_no LIKE '%2022060%'; -- type=ALL,失效

-- OR:用 UNION 改写,让每个分支各自走自己的索引
-- 原写法(b_col 无索引时整条失效):
SELECT * FROM orders WHERE user_id = '100237' OR amount > 10000;
-- 改写:
SELECT * FROM orders WHERE user_id = '100237'
UNION
SELECT * FROM orders WHERE amount > 10000;

-- 否定条件:能换正向枚举就换
-- 慢:WHERE status NOT IN (0, 1)
SELECT * FROM orders WHERE status IN (2, 3, 4);

-- 验证某个索引到底值不值得建:看区分度(越接近 1 越值得)
SELECT COUNT(DISTINCT status) / COUNT(*) AS sel_status,
       COUNT(DISTINCT user_id) / COUNT(*) AS sel_user
FROM orders;
-- sel_status≈0.000001(没必要单独建)、sel_user≈0.3(适合建索引)

六、排序与分组:ORDER BY / GROUP BY 用不对,索引照样白建

# 前面五节都在讲 WHERE,但 ORDER BY 和 GROUP BY 同样吃索引
# EXPLAIN 的 Extra 里一旦出现 Using filesort,就说明排序没用上索引

# 索引天生是有序的:B+ 树的叶子节点本来就按索引列的顺序串成一条链表
# 如果 ORDER BY 的列正好和索引顺序一致,MySQL 顺着索引链表读出来就是有序的
#   —— 一次额外排序都不用,这叫"索引排序",几乎零成本
# 否则就得把符合条件的行全捞进内存(放不下就落磁盘)再排一遍
#   —— 这就是 filesort,数据量一大就明显拖慢

# ORDER BY 想搭上索引,要同时满足:
#   1. 排序列出现在某个索引里,并符合最左前缀
#   2. WHERE 的等值条件列 + ORDER BY 列,合起来仍符合索引列顺序
#      经典且最稳的组合:索引 (a, b),查询 WHERE a = ? ORDER BY b
#   3. 多列排序方向要一致(要么全 ASC,要么全 DESC)
#      MySQL 8.0 之前 ASC/DESC 混用用不上索引,8.0+ 可用降序索引破解

# GROUP BY 同理:分组本质上也需要数据有序才能高效归并
#   能借索引的有序性就不必建临时表,否则 Extra 会出现 Using temporary

# === 小结 ===
# WHERE 走了索引不等于查询就快,ORDER BY / GROUP BY 没搭上索引照样 filesort
-- 索引 idx_u_s_c (user_id, status, created_at)

-- 完美:WHERE 等值命中 user_id,ORDER BY created_at 顺着索引读,无 filesort
EXPLAIN SELECT id, status, created_at FROM orders
WHERE user_id = '100237'
ORDER BY created_at DESC;

-- filesort:ORDER BY 的 amount 根本不在这个索引里
EXPLAIN SELECT * FROM orders
WHERE user_id = '100237'
ORDER BY amount DESC;

-- filesort(MySQL 5.7 及以前):两列排序方向不一致
EXPLAIN SELECT * FROM orders
WHERE user_id = '100237'
ORDER BY status ASC, created_at DESC;

-- GROUP BY 借最左列的有序性分组,Extra 无 Using temporary
EXPLAIN SELECT user_id, COUNT(*) FROM orders
GROUP BY user_id;

七、深分页:LIMIT 100000, 20 为什么越翻到后面越慢

# 分页查询 LIMIT offset, count,翻前几页飞快,翻到第几千页慢到怀疑人生

# 根因:LIMIT 100000, 20 的语义不是"跳过 10 万行直接取 20 行"
#   而是"老老实实取出前 100020 行,再把前 100000 行丢掉,只留最后 20 行"
#   offset 越大,被取出来又被立刻扔掉的行就越多,做的全是无用功
#   如果这些行还要回表,那这 10 万行里每一行都可能回表一次,雪上加霜

# 两种主流优化:
# 1) 延迟关联(子查询定位主键)
#    先只用覆盖索引快速定位到第 100000 行往后的 20 个主键 id,
#    再用这 20 个主键去 join 回表取完整行
#    —— 把"回表 10 万次"压缩成"回表 20 次"
# 2) 游标翻页 / 书签翻页(seek method)
#    彻底不用 offset,改成记住上一页最后一行的排序值,
#    下一页直接用范围条件 WHERE 排序列 在上次值之后 LIMIT 20
#    offset 永远是 0,翻到第几页都一样快
#    代价:只能逐页翻(上一页 / 下一页),没法直接跳到第 500 页

# === 认知 ===
# 深分页慢不是数据库的锅,是 offset 这个用法本身就在大量做无用功
-- 慢:offset 10 万,实际扫了 100020 行再扔掉前 10 万行
SELECT * FROM orders
ORDER BY created_at DESC
LIMIT 100000, 20;

-- 优化一:延迟关联,先用覆盖索引拿到 20 个主键,再回表取整行
SELECT o.* FROM orders o
JOIN (
  SELECT id FROM orders
  ORDER BY created_at DESC
  LIMIT 100000, 20
) t ON o.id = t.id;

-- 优化二:游标翻页,带上上一页最后一条的 created_at
SELECT * FROM orders
WHERE created_at < '2022-05-30 12:00:00'
ORDER BY created_at DESC
LIMIT 20;
-- offset 恒为 0,无论翻到第几页耗时都一样

八、工程坑:回表、索引下推、统计信息、优化器选错索引

# 索引"走了"也不等于"快",还有几个进阶问题:

# 1) 回表
#    二级索引的叶子节点只存"索引列 + 主键",不存整行
#    SELECT * 要的字段不在索引里,就得拿主键再回聚簇索引查一次 = 回表
#    回表多了照样慢。解法:覆盖索引——让索引包含查询要的所有列,
#    EXPLAIN 的 Extra 出现 Using index 就是命中了覆盖索引,不回表

# 2) 索引下推 ICP(MySQL 5.6+,默认开启)
#    联合索引里那些"用不上最左前缀但能在索引层先过滤"的条件,
#    会被下推到存储引擎层先筛掉,减少回表次数
#    Extra 里的 Using index condition 就是 ICP 生效的标志

# 3) 统计信息过期
#    优化器靠表的统计信息(行数、区分度)估算成本来选索引
#    大批量增删改后统计信息没更新,优化器会基于旧数据选错索引
#    手动刷新:ANALYZE TABLE 表名

# 4) 优化器选错索引
#    一张表多个索引时,优化器偶尔会选一个更差的
#    临时干预:FORCE INDEX 强制指定;但优先排查统计信息,别滥用 FORCE

# === 认知 ===
# 索引优化不是"建完就完事",是 EXPLAIN 驱动的持续观测:
# 看走没走索引、走了哪个、要不要回表、统计信息新不新
-- 回表 vs 覆盖索引
-- 索引 idx_u_s_c (user_id, status, created_at)
-- 这条要 SELECT *,字段不全在索引里 → 回表
EXPLAIN SELECT * FROM orders WHERE user_id = '100237' AND status = 2;

-- 只取索引里有的列 → 覆盖索引,Extra: Using index,不回表
EXPLAIN SELECT user_id, status, created_at
FROM orders WHERE user_id = '100237' AND status = 2;

-- 想让常用查询免回表,可把高频字段加进联合索引尾部
ALTER TABLE orders DROP INDEX idx_u_s_c;
ALTER TABLE orders ADD INDEX idx_cover (user_id, status, created_at, amount);

-- 大批量数据变动后,刷新统计信息,避免优化器选错索引
ANALYZE TABLE orders;

-- 确实遇到优化器选错索引,临时强制(先确认统计信息已是最新)
SELECT * FROM orders FORCE INDEX (idx_cover)
WHERE user_id = '100237' AND status = 2;

-- 查看一张表上现有的索引
SHOW INDEX FROM orders;

命令速查

目的                         命令 / 写法
---------------------------  ----------------------------------------
看执行计划                   EXPLAIN SELECT ...
看更详细的计划(JSON)        EXPLAIN FORMAT=JSON SELECT ...
看实际执行耗时               EXPLAIN ANALYZE SELECT ...   (MySQL 8.0+)
看表上的索引                 SHOW INDEX FROM 表名
看列定义 / 字符集            SHOW FULL COLUMNS FROM 表名
刷新统计信息                 ANALYZE TABLE 表名
强制使用某索引               SELECT ... FORCE INDEX (索引名)
忽略某索引                   SELECT ... IGNORE INDEX (索引名)
建联合索引                   ALTER TABLE t ADD INDEX idx (a, b, c)
建函数索引 / 生成列          GENERATED ALWAYS AS (DATE(col)) STORED
开慢查询日志                 SET GLOBAL slow_query_log = ON
慢查询阈值(秒)              SET GLOBAL long_query_time = 1

EXPLAIN 关键列速记
---------------------------  ----------------------------------------
type=ALL                     全表扫描,几乎一定要优化
type=index                   扫整棵索引树,也偏慢
type=range / ref              健康区间
key=NULL                     没走任何索引
possible_keys 有 / key=NULL   优化器看到索引却放弃了,深究
Extra: Using index           覆盖索引,不回表,最理想
Extra: Using filesort         有额外排序,考虑让排序列进索引
Extra: Using temporary        用了临时表,排查 group by / distinct

避坑清单

  1. 排查慢查询第一步永远是 EXPLAIN,不要凭"我建了索引"的记忆下结论,执行计划才是事实。
  2. 绝不对索引列套函数或做四则运算,把加工挪到常量那一侧;真要按函数结果查就建函数索引或生成列。
  3. 字符串类型的索引列,查询条件里的值一定要加引号,传数字会触发隐式类型转换让索引失效。
  4. 多表 join 时确认 join 列的字符集和排序规则一致,不一致会在连接时转换字符集导致索引失效。
  5. 联合索引遵守最左前缀:条件要从最左列起连续命中,跳过最左列整条索引用不上。
  6. 联合索引里中间列用了范围查询(大于小于 between like),它右边的列就用不上索引,把范围列尽量排到后面。
  7. LIKE 左模糊('%abc')走不了索引,真要做模糊搜索交给 Elasticsearch,别硬用 MySQL。
  8. OR 连接的条件只要有一列没索引,整条 OR 就退化成全表扫描,补索引或用 UNION 拆开改写。
  9. 区分度极低的列(如只有几个值的 status)单独建索引基本没用,优化器会算账后主动放弃。
  10. 大批量增删改后执行 ANALYZE TABLE 刷新统计信息,否则优化器会基于过期数据选错索引;FORCE INDEX 是最后手段不是常规操作。

总结

回头看那次 8 秒慢查询,真正让我后背发凉的不是它慢,而是它"不报错"。一条 WHERE DATE(created_at) = '2022-06-01' 语法完全合法,在测试库几千行数据上跑得飞快,代码评审时没人会多看一眼,它就这样一路绿灯上了线,然后在生产库 380 万行数据和一波真实流量的合力下,毫无征兆地把数据库 CPU 打满。索引失效这类问题最危险的地方就在于此:它不是"错",它只是"慢",而"慢"在数据量小的时候是隐形的。

把这篇文章里的坑收拢一下,你会发现它们背后其实是同一个原理。B+ 树索引的全部能力,来自于"列的原始值是排好序的",所以 MySQL 才能像查字典一样二分定位。而所有让索引失效的写法,本质上都是在破坏这个前提:对列套函数,你要找的值不在树里;隐式类型转换,等于偷偷对列套了个 CAST;违反最左前缀,等于跳过字典的首字母直接翻中间;LIKE 左模糊,等于不知道首字母是什么。只要你心里装着"索引列必须以原始、有序、可定位的形态出现在条件里"这一句话,绝大多数失效场景你都能当场反应过来。

所以索引优化不是一项"建完索引就结束"的一次性工作,而是一种持续的观测习惯。写完一条稍微复杂点的查询,顺手 EXPLAIN 一下,看 type 是不是掉到了 ALL、key 是不是变成了 NULL、rows 是不是接近全表、Extra 里有没有冒出 filesort——这几秒钟的动作,就是你和那条潜伏的慢查询之间最后、也是最便宜的一道防线。等到它上线、等到数据涨上来、等到 DBA 在群里甩告警,修复的成本就完全不是一个量级了。

还要记住:索引不是越多越好。每一个索引都要占存储空间,每一次 INSERT、UPDATE、DELETE 都要顺带维护所有相关索引,索引建滥了写入会变慢。真正的目标从来不是"给每一列都建上索引",而是"让那些高频、慢、影响大的查询稳稳地走在合适的索引上"。先用慢查询日志找出真正的问题 SQL,再用 EXPLAIN 定位它为什么慢,然后有针对性地建一个能覆盖它的索引——这条"日志定位、EXPLAIN 诊断、精准建索引"的链路,比你凭感觉一口气加十个索引有用得多。当你养成了对每条查询都先问一句"它会走索引吗、会回表吗"的习惯,数据库这一层就很难再给你制造惊吓了。

—— 别看了 · 2026
声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理 邮箱1846861578@qq.com。
技术教程

大模型上下文窗口完全指南:为什么 AI 对话越聊越贵、越聊越笨

2026-5-21 16:38:54

技术教程

大模型幻觉完全指南:为什么 AI 会一本正经地胡说八道,以及怎么治

2026-5-21 16:52:31

0 条回复 A文章作者 M管理员
    暂无讨论,说说你的看法吧
个人中心
购物车
优惠劵
今日签到
有新私信 私信列表
搜索