周二上午十点,我刚把第二杯咖啡续上,DBA 在群里发了一张监控截图:主库 CPU 从平时的 20% 爬到了 85%,旁边跟了一句“谁动了订单库?”。我第一反应是“不是我”,第二反应是“肯定是我”。
后来证明确实是我们团队——前一天晚上发的一个小版本,加了一个“按手机号后缀查订单”的运营查询功能。功能很小,SQL 只有一行,而且那个字段明明有索引。这篇就是那天的排查手记:一条“有索引却不走索引”的 SQL,背后到底有几种常见的死法,以及怎么用工具一步步把它揪出来。
一、现象:CPU 上去了,慢查询下不来
先说一个习惯:别急着 kill,也别急着回滚版本,先保留现场。我做了三件事:
- 看当前在跑什么:
SHOW FULL PROCESSLIST; - 看慢日志有没有开、阈值是多少;
- 把最耗时的那几条 SQL 捞出来,逐条
EXPLAIN。
mysql> SHOW FULL PROCESSLIST;
+--------+------+-----------------+-----------+---------+------+-----------+--------------------------------------------------------------+
| Id | User | Host | db | Command | Time | State | Info |
+--------+------+-----------------+-----------+---------+------+-----------+--------------------------------------------------------------+
| 918231 | app | 10.0.3.21:53412 | order_db | Query | 14 | executing | SELECT * FROM t_order WHERE user_phone = 13800000000 ... |
| 918244 | app | 10.0.3.22:41876 | order_db | Query | 11 | executing | SELECT * FROM t_order WHERE user_phone = 13900000000 ... |
| 918260 | app | 10.0.3.21:53590 | order_db | Query | 9 | executing | SELECT * FROM t_order WHERE user_phone = 13700000000 ... |
...
| 918301 | app | 10.0.3.23:60122 | order_db | Query | 3 | executing | SELECT * FROM t_order WHERE user_phone = 13600000000 ... |
+--------+------+-----------------+-----------+---------+------+-----------+--------------------------------------------------------------+
23 rows in set (0.00 sec)(手机号是我编的示例,不要对号入座。)关键信息:同一个模板的 SQL 在大量并发执行,每条都跑了好几秒。一张表 3000 多万行,用手机号精确查一条,本来应该是毫秒级。
确认慢日志的状态
mysql> SHOW VARIABLES LIKE 'slow_query_log%';
+---------------------+-----------------------------+
| Variable_name | Value |
+---------------------+-----------------------------+
| slow_query_log | OFF |
| slow_query_log_file | /var/lib/mysql/db01-slow.log|
+---------------------+-----------------------------+
mysql> SHOW VARIABLES LIKE 'long_query_time';
+-----------------+-----------+
| Variable_name | Value |
+-----------------+-----------+
| long_query_time | 10.000000 |
+-----------------+-----------+没错,慢日志是关的,阈值是官方默认的 10 秒。MySQL 官方文档写得很清楚:slow_query_log 默认关闭,long_query_time 默认 10 秒。这相当于家里装了监控摄像头但没通电。运行时动态打开,不需要重启:
mysql> SET GLOBAL slow_query_log = 1;
mysql> SET GLOBAL long_query_time = 1; -- 新连接生效,已有连接要重连
mysql> SET GLOBAL log_queries_not_using_indexes = 1; -- 没走索引的也记,短期开,别一直开
mysql> SET GLOBAL log_slow_extra = 1; -- 8.0.14+,多记一些字段(Rows_examined 等之外的细节)注意两个坑:SET GLOBAL long_query_time 只对之后新建的连接生效,我们用的是连接池,所以得等连接轮换或者滚动重启应用;log_queries_not_using_indexes 会让全表扫描的小表也刷屏,只适合排查期间临时开。同时别忘了把这几项写进 my.cnf,否则下次重启又回到“摄像头没通电”的状态。
二、排查路线图
“有索引却慢”这个问题,我现在固定按下图的路线走。先证明“确实没走索引”,再判断“为什么没走”:
三、动手:从慢日志到 EXPLAIN
第 1 步:慢日志长什么样
慢日志打开几分钟后,我 tail 了一眼:
$ tail -n 8 /var/lib/mysql/db01-slow.log
# Time: 2026-09-29T10:12:41.512345Z
# User@Host: app[app] @ [10.0.3.21] Id: 918231
# Query_time: 6.284117 Lock_time: 0.000094 Rows_sent: 1 Rows_examined: 31872645
SET timestamp=1790590361;
SELECT * FROM t_order WHERE user_phone = 13800000000 AND status = 1;三个数字看“体检报告”:Query_time 6.28 秒;Rows_sent 1 行;Rows_examined 3187 万行。返回 1 行、扫描三千万行,这是索引失效的经典指纹:Rows_examined / Rows_sent 的比值巨大。顺带说一句,官方文档提醒过,Lock_time 不计入执行时间,而且慢日志是在语句执行完并释放锁之后才写的,所以日志顺序不一定等于执行顺序。
第 2 步:mysqldumpslow / pt-query-digest 聚合
慢日志一多,人眼就看花了。先用 MySQL 自带的 mysqldumpslow 快速聚合:
$ mysqldumpslow -s t -t 3 /var/lib/mysql/db01-slow.log
Reading mysql slow query log from /var/lib/mysql/db01-slow.log
Count: 1284 Time=5.87s (7537s) Lock=0.00s (0s) Rows=1.0 (1284), app[app]@10.0.3.x
SELECT * FROM t_order WHERE user_phone = N AND status = N
Count: 37 Time=1.92s (71s) Lock=0.00s (0s) Rows=120.0 (4440), app[app]@10.0.3.x
SELECT ... FROM t_order_item WHERE DATE(create_time) = 'S'
...-s t 按总时间排序,-t 3 只看前三;-s c 是按次数排。N 和 S 是它把数字和字符串抽象掉之后的占位符——同一个模板的不同参数被合并了。更细的分析用 Percona Toolkit 的 pt-query-digest,它按“指纹(fingerprint)”聚合,默认按总响应时间降序,官方说明里的报告分三块:头部 Profile 总览、每类查询的属性表(次数、最小/最大/平均/95 分位、Lock time、Rows sent、Rows examined 等)、以及附带样例和 EXPLAIN。
$ pt-query-digest /var/lib/mysql/db01-slow.log --limit 5 > digest.txt
$ head -40 digest.txt
# 12.3s user time, 90ms system time, 35.10M rss, 120.44M vsz
# Overall: 1.36k total, 5 unique, 0.45 QPS, 2.41x concurrency ____________
# Profile
# Rank Query ID Response time Calls R/Call V/M
# ==== ============================= =============== ===== ====== =====
# 1 0xA1B2C3D4E5F60718293A4B5C6D7 7537.1234 91.3% 1284 5.8700 0.42 SELECT t_order
# 2 0x9F8E7D6C5B4A39281706F5E4D3C 71.0000 0.9% 37 1.9200 0.11 SELECT t_order_item
...
# Query 1: 0.42 QPS, 2.41x concurrency, ID 0xA1B2... at byte 812345
# Attribute pct total min max avg 95% stddev median
# ============ === ======= ======= ======= ======= ======= ======= =======
# Count 94 1284
# Exec time 91 7537s 3s 9s 6s 8s 1s 6s
# Rows sent 0 1284 1 1 1 1 0 1
# Rows examine 99 39.02G 30.4M 31.9M 31.1M 31.9M 0 31.1M结论非常直白:一个查询模板占了 91% 的总耗时,且 99% 的扫描行数都是它贡献的。抓到凶手了,接下来问“它为什么没走索引”。(示例输出为示意格式,字段与官方描述一致,具体数值随环境不同。)
第 3 步:EXPLAIN,逐列读
mysql> EXPLAIN SELECT * FROM t_order WHERE user_phone = 13800000000 AND status = 1\G
*************************** 1. row ***************************
id: 1
select_type: SIMPLE
table: t_order
partitions: NULL
type: ALL
possible_keys: idx_user_phone
key: NULL
key_len: NULL
ref: NULL
rows: 31872645
filtered: 5.00
Extra: Using where按官方文档的列定义逐个看:
type: ALL:全表扫描,连接类型里最差的一档。好坏顺序大致是system > const > eq_ref > ref > range > index > ALL;possible_keys: idx_user_phone:优化器认为可以用的索引,有;key: NULL:实际选用的索引,没有。“可能有、实际没用”,就是本文的主角;rows: 31872645:估算要检查的行数,基本等于全表;filtered: 5.00:表条件过滤后大概剩百分之几;Extra: Using where:Server 层在过滤。配合type=ALL出现,官方文档也把它列为“可能需要优化”的信号。
再看表结构:
mysql> SHOW CREATE TABLE t_order\G
...
`user_phone` varchar(20) NOT NULL DEFAULT '' COMMENT '下单手机号',
...
KEY `idx_user_phone` (`user_phone`),
...字段是 varchar(20),我们传进去的是数字 13800000000。案发。
四、根因:四种“索引明明在,就是不用”
1)隐式类型转换:varchar 列 = 数字
MySQL 官方文档“Type Conversion in Expression Evaluation”里的规则:比较时,如果两边不是同类型,最终会“按浮点数比较”。字符串列和数字比较时,是把列里每一行的字符串转换成数字再比。文档还特别举了例子:数字 1 可以对应 '1'、' 1'、'00001'、'01.e1' 等多个字符串,所以索引(按字符串排序的 B+ 树)根本没法用来定位。等价于:
-- 我们写的
WHERE user_phone = 13800000000
-- 优化器实际做的(概念上)
WHERE CAST(user_phone AS DOUBLE) = 13800000000 -- 每一行都要转,索引废了更吓人的是文档里的另一个例子:SELECT * FROM t1 WHERE c3 = 0,如果 c3 是 varchar,里面存的 'grape'、'apple' 会被转成 0,结果全部行都命中。这不只是性能问题,还是正确性问题。反过来,int 列传字符串 '123' 是没事的:转换发生在常量一侧,索引照走。这个不对称性,是很多人“测试环境没事、线上出事”的根源。
可以用 SHOW WARNINGS 看到优化器改写后的语句:
mysql> EXPLAIN SELECT * FROM t_order WHERE user_phone = 13800000000;
mysql> SHOW WARNINGS\G
*************************** 1. row ***************************
Level: Note
Code: 1003
Message: /* select#1 */ select ... from `order_db`.`t_order` where (`order_db`.`t_order`.`user_phone` = 13800000000)如果是 join 里的类型不一致,你会在这里看到 cast(... as double) 或 convert(... using ...) 之类的字样,一看就明白。修法:把参数按字符串传。
// MyBatis 里最容易踩:${} 拼接、或者参数类型写成 Long
// 错误:
// <select id="byPhone"> SELECT * FROM t_order WHERE user_phone = ${phone} </select>
// 正确:让 phone 是 String,并且用 #{}
@Select("SELECT * FROM t_order WHERE user_phone = #{phone}")
List<Order> byPhone(@Param("phone") String phone);这次事故的直接原因就是:前端把手机号当 number 传,后端 DTO 图省事写成了 Long,MyBatis 传给 JDBC 时就是数值。改成 String 之后:
mysql> EXPLAIN SELECT * FROM t_order WHERE user_phone = '13800000000' AND status = 1\G
id: 1
select_type: SIMPLE
table: t_order
type: ref
possible_keys: idx_user_phone
key: idx_user_phone
key_len: 82
ref: const
rows: 1
filtered: 10.00
Extra: Using wheretype: ref、key 用上了、rows: 1。顺便说一下 key_len: 82:varchar(20) 在 utf8mb4 下最多 20×4=80 字节,加 2 字节长度前缀,正好 82。key_len 是判断联合索引到底用了几列的好办法。
2)在索引列上套函数或做运算
索引存的是列的原始值,不是函数结果。WHERE DATE(create_time) = '2026-09-01',引擎没法在 B+ 树上定位,只能逐行算。同类写法还有 LEFT(name,2)='张'、id + 1 = 100、IFNULL(col, 0) = 0。改法就是把函数挪到常量那一侧,让列保持“裸奔”:
-- 坏:索引 idx_create_time 用不上
SELECT * FROM t_order_item WHERE DATE(create_time) = '2026-09-01';
-- 好:改成范围,走 range
SELECT * FROM t_order_item
WHERE create_time >= '2026-09-01 00:00:00' AND create_time < '2026-09-02 00:00:00';
-- 坏
SELECT * FROM t_user WHERE id + 1 = 100;
-- 好
SELECT * FROM t_user WHERE id = 99;MySQL 8.0.13 起支持函数索引(functional key part),确实想按函数查,可以 CREATE INDEX idx_d ON t_order_item ((DATE(create_time)))。但我个人更倾向于先改写 SQL——索引是有维护成本的,别让每个奇怪的写法都变成一个新索引。
3)LIKE 的前导通配符
B+ 树按前缀有序。LIKE 'abc%' 能定位到 “abc” 开头的起点,是 range 扫描;LIKE '%abc' 或 '%abc%' 没有起点,只能全扫。运营要“按手机号后四位查”,我们最初的实现就是 LIKE '%1234'。几种替代思路:
- 业务上要求至少输入前缀,或者同时带上其它强过滤条件(如时间范围 + 商户 ID),把扫描范围收窄;
- 冗余一个反转字段:存
REVERSE(phone)并建索引,后缀查询改写成前缀查询reverse_phone LIKE '4321%'; - 模糊检索场景本来就该交给 Elasticsearch(我们栈里有),别让 OLTP 库干这事。
补充一个小知识,就是文章开头提到的索引下推(ICP,Index Condition Pushdown)。官方文档里有个例子:有联合索引 (zipcode, lastname, firstname),查 zipcode='95054' AND lastname LIKE '%etrunia%'。lastname LIKE '%...' 虽然不能用来定位,但它的值就在索引里。开启 ICP 后,存储引擎在读索引项时就先用它过滤,符合的才回表,不符合的直接跳过,省下大量回表 IO。此时 EXPLAIN 的 Extra 显示 Using index condition。要注意两点:ICP 仅适用于 range、ref、eq_ref、ref_or_null 这几种访问方式,且仅对 InnoDB 的二级索引有意义;默认开启,可以用 SET optimizer_switch='index_condition_pushdown=off' 关闭做对比实验。所以 Using index condition 不是坏事,恰恰是“少回表”的好信号;别把它跟 Using index(覆盖索引,完全不用回表)混为一谈。
4)字符集 / 排序规则不一致导致 join 不走索引
这个最阴险,因为两边都是 varchar、都有索引,连 SQL 都看不出问题。官方文档明确写了:比较不同字符集的列,比如 utf8mb4 列和 latin1 列,会阻止使用索引。老项目里最常见的是:早期表是 utf8(即 utf8mb3),新表是 utf8mb4,两表一 join:
mysql> EXPLAIN SELECT o.id, u.nick FROM t_order o JOIN t_user_old u ON u.phone = o.user_phone WHERE o.id < 100\G
*************************** 1. row ***************************
table: o
type: range
*************************** 2. row ***************************
table: u
type: ALL
possible_keys: idx_phone
key: NULL
rows: 8210043
Extra: Using where; Using join buffer (hash join)
mysql> SHOW WARNINGS\G
Message: ... where (convert(`order_db`.`u`.`phone` using utf8mb4) = `order_db`.`o`.`user_phone`) ...看 SHOW WARNINGS 里的 convert(... using utf8mb4):被驱动表的列被套了一层转换函数,等价于第 2 类问题,索引报废。而且 Extra 里出现 Using join buffer (hash join)(8.0.18+)或 Block Nested Loop(更老版本),也是没有可用索引时 join 的典型表现。同理,排序规则(collation)不同,例如 utf8mb4_general_ci 对 utf8mb4_0900_ai_ci,也可能导致隐式的 collation 转换或直接报 “Illegal mix of collations”。
修法有三种,从治本到治标:ALTER TABLE t_user_old CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;(大表要走 Online DDL 或者 gh-ost,见下一篇);建库建表时统一字符集;实在改不动,在 SQL 里把驱动侧转成被驱动侧的字符集:ON u.phone = CONVERT(o.user_phone USING utf8mb3),让转换发生在常量一侧。
五、止血与修复
止血(十分钟内)
- 网关对“按手机号查订单”接口限流,避免继续压垮主库;
- 用
SHOW PROCESSLIST找出跑最久的那批查询,评估后KILL QUERY <id>(不是KILL CONNECTION,先杀语句保留连接); - 热修版本:后端 DTO 的
Long改String,同时在 Mapper 里强制#{phone}。发版后 CPU 十分钟内从 85% 回落到 22%。
根治
- 把 SQL 审核前置:用
EXPLAIN检查type为ALL且rows超过阈值的语句直接卡住不给上线; - 全库扫一遍字符集与排序规则,出清单逐步统一;
- 慢日志常开:
slow_query_log=1、long_query_time=1(或 0.5),加上pt-query-digest每日报表推送到群里; - 单测里增加“大写”“空格”“前导零”等参数,防止字符串与数值比较的正确性问题(上面
c3 = 0的例子)。
另外补一张“前后对比”,用来给同事解释为什么“类型不对”会拖垮 CPU:
六、复盘:这类坑怎么提前躲开
| 做法 | 为什么 |
|---|---|
慢日志常开,long_query_time 设 1 秒或更小 | 默认关闭、阈值 10 秒,出事时没有证据链;写进 my.cnf 而不是只 SET GLOBAL |
每天跑 pt-query-digest 看 Top N | 按指纹聚合,一眼看出“哪个模板占了大头”,比看单条日志高效得多 |
看 Rows_examined / Rows_sent | 比值巨大就是没走好索引的指纹,比“慢不慢”更早发现问题 |
EXPLAIN 至少看 type、key、key_len、rows、Extra | possible_keys 有而 key 为 NULL,就是“有索引不用”;key_len 告诉你联合索引用到了几列 |
字符串字段一律按字符串传参,用 #{} | varchar 列比较数字必然隐式转换;转换在列一侧索引就废,还会有 = 0 全命中的正确性问题 |
| 别在索引列上套函数、做运算 | 把函数移到常量一侧;确需函数查询,考虑 8.0.13+ 的函数索引 |
| 全库统一字符集 / 排序规则 | join 两边不一致会给被驱动列加 convert,索引失效,且很难从 SQL 文本看出来 |
| 上线前用生产量级数据做 EXPLAIN | 测试库几千行,优化器全表扫描也很快,永远发现不了问题 |
一句话备忘:有索引不走 → 先看慢日志的
Rows_examined→EXPLAIN看key是否 NULL →SHOW WARNINGS看优化器改写 → 依次检查“类型、函数、前导 %、字符集”四连问。
那天晚上我在团队群里发了一句总结:“varchar 的手机号,永远用引号,就像出门要穿裤子。”产品说这个比喻不太得体,但全组都记住了。至于那个 Long,它现在已经改名叫 String,工作愉快。
参考资料
- MySQL 8.0 参考手册:EXPLAIN 输出格式——各列含义、type 由优劣排序、Extra 常见取值。
- MySQL 8.0:表达式求值中的类型转换——字符串列与数字比较的规则、
c3 = 0全命中的例子。 - MySQL 8.0:MySQL 如何使用索引——类型不同、字符集不同的列比较无法使用索引。
- MySQL 8.0:索引条件下推(ICP)优化——原理、适用访问方式、
optimizer_switch开关。 - MySQL 8.0:慢查询日志——默认值、动态开启方法、日志格式、mysqldumpslow 用法。
- Percona Toolkit:pt-query-digest——按指纹聚合的报告结构。
一起聊聊