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