数据库与缓存排查 列表全部文章
数据库与缓存排查

明明建了索引,SQL 却跑成了全表扫描:隐式转换、函数包裹与字符集的“四连暗杀”

一条上线半年都很快的 SQL 突然变慢,EXPLAIN 显示 type=ALL。从慢日志、pt-query-digest 到 EXPLAIN 逐列解读,逐个排查隐式类型转换、函数包裹列、前导 % 与字符集不一致导致索引失效,并给出改法。

更多

周二上午十点,我刚把第二杯咖啡续上,DBA 在群里发了一张监控截图:主库 CPU 从平时的 20% 爬到了 85%,旁边跟了一句“谁动了订单库?”。我第一反应是“不是我”,第二反应是“肯定是我”。

后来证明确实是我们团队——前一天晚上发的一个小版本,加了一个“按手机号后缀查订单”的运营查询功能。功能很小,SQL 只有一行,而且那个字段明明有索引。这篇就是那天的排查手记:一条“有索引却不走索引”的 SQL,背后到底有几种常见的死法,以及怎么用工具一步步把它揪出来。

一、现象:CPU 上去了,慢查询下不来

先说一个习惯:别急着 kill,也别急着回滚版本,先保留现场。我做了三件事:

  1. 看当前在跑什么:SHOW FULL PROCESSLIST;
  2. 看慢日志有没有开、阈值是多少;
  3. 把最耗时的那几条 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,否则下次重启又回到“摄像头没通电”的状态。

二、排查路线图

“有索引却慢”这个问题,我现在固定按下图的路线走。先证明“确实没走索引”,再判断“为什么没走”:

① 慢日志锁定最耗时的 SQL ② pt-query-digest按指纹聚合看总耗时 ③ EXPLAIN看 type / key / rows type=ALL 或 key=NULL ?是 → 进入“为什么没走”四连问 Q1 类型对不对? varchar 列传了数字(SHOW WARNINGS 看 cast) Q2 列被函数包了? DATE(col)、LEFT(col,3)col + 1、IFNULL(col,..) Q3 前导通配符? LIKE '%abc' 无法定位索引的起点 Q4 字符集/排序规则? join 两边 utf8 与 utf8mb4被驱动列被转换 另外两种“不是失效、是嫌弃”:区分度太低、优化器判断回表比全表扫描更贵

三、动手:从慢日志到 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 where

type: 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),让转换发生在常量一侧。

五、止血与修复

止血(十分钟内)

  1. 网关对“按手机号查订单”接口限流,避免继续压垮主库;
  2. 用 SHOW PROCESSLIST 找出跑最久的那批查询,评估后 KILL QUERY <id>(不是 KILL CONNECTION,先杀语句保留连接);
  3. 热修版本:后端 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:

WHERE user_phone = 13800000000(数字) 每行 user_phone 先转成数字再比较 idx_user_phone 无法定位 → type=ALL 扫描约 3187 万行,单条 6 秒 并发一多,CPU 85% WHERE user_phone = '13800000000'(字符串) 常量与列同为字符串,按 B+ 树二分定位 idx_user_phone 命中 → type=ref 扫描 1 行,毫秒级 CPU 回落到 22% 规律:类型转换发生在“列”一侧,索引就废;发生在“常量”一侧,索引照走

六、复盘:这类坑怎么提前躲开

做法为什么
慢日志常开,long_query_time 设 1 秒或更小默认关闭、阈值 10 秒,出事时没有证据链;写进 my.cnf 而不是只 SET GLOBAL
每天跑 pt-query-digest 看 Top N按指纹聚合,一眼看出“哪个模板占了大头”,比看单条日志高效得多
看 Rows_examined / Rows_sent比值巨大就是没走好索引的指纹,比“慢不慢”更早发现问题
EXPLAIN 至少看 type、key、key_len、rows、Extrapossible_keys 有而 key 为 NULL,就是“有索引不用”;key_len 告诉你联合索引用到了几列
字符串字段一律按字符串传参,用 #{}varchar 列比较数字必然隐式转换;转换在列一侧索引就废,还会有 = 0 全命中的正确性问题
别在索引列上套函数、做运算把函数移到常量一侧;确需函数查询,考虑 8.0.13+ 的函数索引
全库统一字符集 / 排序规则join 两边不一致会给被驱动列加 convert,索引失效,且很难从 SQL 文本看出来
上线前用生产量级数据做 EXPLAIN测试库几千行,优化器全表扫描也很快,永远发现不了问题

一句话备忘:有索引不走 → 先看慢日志的 Rows_examined → EXPLAIN 看 key 是否 NULL → SHOW WARNINGS 看优化器改写 → 依次检查“类型、函数、前导 %、字符集”四连问。

那天晚上我在团队群里发了一句总结:“varchar 的手机号,永远用引号,就像出门要穿裤子。”产品说这个比喻不太得体,但全组都记住了。至于那个 Long,它现在已经改名叫 String,工作愉快。

参考资料

— 感谢读到这里 —发现错误,或想补充?告诉我 →
TURN KNOWLEDGE INTO SOMETHING REAL

有想法,也想把它做出来?

从技术方案到上线交付,聊聊你的下一个项目。

聊聊需求

一起聊聊

搜索文章

输入关键词,按标题和摘要即时筛选。

浏览分类文章归档

阅读设置

字号
行宽
专注模式

专注模式会隐藏页头与侧栏,只留正文。设置保存在当前浏览器。

稍后阅读

保存在当前浏览器,留给下一次安静阅读。

总浏览 -- 次 · 总访客 -- 人 · 今日浏览 -- 次 · 今日访客 -- 人