少了个引号查询慢 80 倍:MySQL 隐式转换让索引废了

场景:用户查询手机号接口从 100ms 飙升到 8 秒,phone 字段有索引但 EXPLAIN 显示 type=ALL 路径:EXPLAIN → type=ALL 但 key 列空 → SHOW WARNINGS → CAST(phone AS SIGNED) → 隐式类型转换

上篇讲了两个 JOIN 列都有索引但查询跑了 38 秒的案例——驱动表选错的问题。这次我们来看另一种让索引失效的方式:索引没坏,是 MySQL 偷偷改了你写的条件。

SELECT * FROM users WHERE phone = 13800138000;

——users 表 50 万行,phone 字段有索引,跑了 8 秒。

不是 SQL 写得不对——是 MySQL 帮你"自动转型"的时候,把索引弄丢了。

以下分析基于 MySQL 8.0.32。

【现象】同一个 phone 字段,有索引不等于走索引

业务背景

告警平台弹了一条消息:用户查询接口(根据手机号查用户信息)首次调用耗时 8.2 秒。这是个高频接口,之前一直稳定在 100ms 左右。

表结构很简单:

CREATE TABLE users (
  id BIGINT PRIMARY KEY AUTO_INCREMENT,
  phone VARCHAR(20) NOT NULL,
  name VARCHAR(50) NOT NULL,
  email VARCHAR(100),
  created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
  INDEX idx_phone (phone)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

phone 字段定义为 VARCHAR(20),并建有索引 idx_phone。表里存了大约 50 万行数据,手机号分布均匀,每条 phone 基本唯一。

EXPLAIN 一跑,type=ALL

现场 SQL 是这样的:

EXPLAIN SELECT * FROM users WHERE phone = 13800138000;

不走索引的 EXPLAIN 输出

输出关键字段:

id select_type table type key rows filtered Extra
1 SIMPLE users ALL NULL 498732 10.00 Using where

type=ALL 全表扫描,扫描 50 万行,key 列 NULL 表示没有使用任何索引。

但等一下——phone 字段有索引 idx_phone,WHERE 条件也是 phone=常量,MySQL 为什么不用?

第一次看到这个结果的人会下意识想:是不是索引坏了?还是 phone 的索引基数(cardinality,即唯一值比例)太低,优化器觉得走全表更快?

给同一个查询补上引号试一下:

EXPLAIN SELECT * FROM users WHERE phone = '13800138000';

走索引的 EXPLAIN 输出

id select_type table type key rows filtered Extra
1 SIMPLE users ref idx_phone 1 100.00 NULL

type=ref,key=idx_phone,预估扫描 1 行。

同一个表,同一个字段,同一个值——唯一区别是一个写了引号,一个没写。50 万行的全表扫描 vs 索引精确匹配,耗时差了 80 倍。

等等,索引在,WHERE 也在——那 MySQL 为什么就是不走索引?

【解析】隐式转换:MySQL 帮你改的条件

用 SHOW WARNINGS 看 MySQL 实际执行的 SQL

MySQL 在 EXPLAIN 之后可以用 SHOW WARNINGS 看到它重写后的查询:

mysql> EXPLAIN SELECT * FROM users WHERE phone = 13800138000;
mysql> SHOW WARNINGS;

SHOW WARNINGS 输出

Level: Note
Code: 1003
Message: /* select#1 */ SELECT * FROM `test`.`users` WHERE (CAST(`phone` AS SIGNED) = 13800138000)

关键一行CAST(phone AS SIGNED)

MySQL 帮你把 phone 字段用 CAST() 包起来了。当你在 WHERE 中写 phone = 13800138000 时: - phone 是 VARCHAR - 13800138000 是整数(INT) - 两个操作数类型不一致,MySQL 需要做类型转换 - INT 的优先级高于 VARCHAR,所以 MySQL 把 VARCHAR 列转成 INT——CAST(phone AS SIGNED)

转换发生在列上,而不是字面量上。 一旦列被函数包裹,索引就用不上了——这和 WHERE DATE(created_at) = '2026-01-01' 不走 created_at 索引是一个道理。

为什么 MySQL 转列不转字面量?

这涉及 MySQL 的 类型转换优先级

类型转换优先级决策流程图

规则很简单:当两个操作数类型不一致时,MySQL 将优先级较低的类型向优先级较高的类型转换

类型优先级(从高到低): 1. DATETIME / TIMESTAMP 2. DOUBLE 3. DECIMAL 4. INT / BIGINT 5. CHAR / VARCHAR 6. ENUM

INT(优先级 4)高于 VARCHAR(优先级 5),所以 VARCHAR → INT,列被 CAST 包裹。

如果把条件反过来写——字符串列 phone 和字符串字面量 '13800138000' 类型一致,MySQL 就不需要做任何转换,索引正常使用。

B+ 树索引 vs CAST 流程图

理解了类型优先级,就能解释常见的隐式转换场景:

场景 SQL 写法 MySQL 实际执行 索引是否可用
VARCHAR vs INT phone = 13800138000 CAST(phone AS SIGNED) = 13800138000
VARCHAR vs DECIMAL price = '19.99' CAST(price AS DECIMAL) = '19.99' 或反过来
DATETIME vs STRING created_at = '2026-01-01' CAST(created_at AS DATETIME) = '2026-01-01' ✅(字面量转)
STRING vs DATETIME varchar_date = '2026-01-01' 同上,取决于字段类型
不同字符集 a.name = b.name(utf8 vs utf8mb4) CONVERT(a.name USING utf8mb4) = b.name

注意 DATETIME 列和字符串字面量比较时,MySQL 将字符串转成 DATETIME(发生在字面量侧),所以索引不受影响。而 VARCHAR 和 INT 比较时,INT 优先级高,转换发生在列上——这条规则区分了"安全的隐式转换"和"毁索引的隐式转换"

用 EXPLAIN FORMAT=JSON 验证转换

传统 EXPLAIN 能看到 type、key、rows,但看不到转换细节。JSON 格式能给出更多线索:

EXPLAIN FORMAT=JSON SELECT * FROM users WHERE phone = 13800138000;

EXPLAIN FORMAT=JSON 输出

关注两个输出差异: - 不走索引的 access_type: "ALL" + attached_condition: (phone) = 13800138000(左边是裸列名,右边是 INT) - 走索引的 access_type: "ref" + attached_condition: (phone) = '13800138000'(右边带引号)

数据分布分析

本案例的数据分布极不均衡:

维度
全表行数 ~50 万
WHERE phone = 13800138000 匹配行数 1 行
预期扫描方式 精确匹配(type=ref, rows=1)
实际扫描方式 全表扫描(type=ALL, rows=50 万)
扫描比 500000 : 1

按 phones 的选择性,索引回表成本极低(1 行),全表扫描成本极高(50 万行 + 网络传输)。优化器不是"算错了成本"——在这个 case 里,优化器知道只能用全表扫描,因为它没法对 CAST(phone) 这个表达式做索引查找。问题不出在优化器,出在我们写 SQL 时没意识到类型对比会触发列级转换。

【路径】🔍 三类隐式转换的排查路径

排查决策表

下次你看到某个 SQL 比预期慢,EXPLAIN 发现 type=ALL 但表上有索引时,按这个顺序查:

隐式转换排查决策表

上表覆盖了 4 种 type=ALL 场景的排查路径和对应修复方案。

排查信条

"EXPLAIN 不会骗你——type=ALL 不是索引坏了,是你问 MySQL 的方式它答不出来。"

每次看到 type=ALL,先问自己三个问题:

  1. 类型一致吗? WHERE 等号左边的列类型和右边的值类型是否匹配?VARCHAR vs INT、DECIMAL vs INT、CHAR vs VARCHAR 是最常见的三种不匹配
  2. MySQL 重写了什么? SHOW WARNINGS 的输出里有 CASTCONVERT 包裹了列名吗?这是隐式转换的直接证据
  3. 这个索引真的能用吗? 即使 type=ALL,也要区分"有索引但不可用"(隐式转换/函数包裹)和"根本没索引"。前者改 SQL,后者加索引——两条路不能搞混

第三个问题最重要。很多人看到 type=ALL 就条件反射去加索引。但如果根因是隐式转换,加了索引也没用——phone = 13800138000 这个写法,给你一个 100 列的复合索引照样全表扫。

TYPE 字段优先级速查

EXPLAIN 的 type 字段有 8 个可能的取值,性能从好到差:

TYPE 字段优先级速查表

EXPLAIN type 性能排名:system/const/eq_ref 最优 → ref → range → index → ALL 最差。

"加索引不是基本功——在哪个字段上加索引才是。"

当 type=ALL 时,先确认索引是否真的能用(列级转换/函数包裹),而不是盲目加索引。

【重构】改 SQL + 改表结构,两条腿走路

方案一:SQL 加引号(立即生效)

最直接的修复:把应用程序中的参数用引号包起来。

Before(JDBC / MyBatis):

// ❌ 1: JDBC 拼接 SQL——INT 参数,SQL 生成 phone=13800138000
Integer phone = 13800138000;
String sql = "SELECT * FROM users WHERE phone = " + phone;

// ❌ 2: MyBatis 参数类型不匹配——Mapper 传了 Integer,但列是 VARCHAR
// @Param("phone") Integer phone → phone = #{phone} → MySQL 收到 INT

After:

// ✅ 1: JDBC — PreparedStatement 自动处理类型
PreparedStatement ps = conn.prepareStatement("SELECT * FROM users WHERE phone = ?");
ps.setString(1, "13800138000");   // setString, 不是 setInt

// ✅ 2: MyBatis — Mapper 参数类型改为 String
// @Param("phone") String phone   → phone = #{phone} → MySQL 收到 VARCHAR

注意:PreparedStatement 和 MyBatis #{ } 不会自动修复类型不匹配——它们按 Java 参数类型传递给 MySQL。Java 传 Integer,MySQL 就收到 INT,隐式转换照旧发生。关键在代码层面确保参数类型与列类型一致。

SQL 改前改后对比

但这是治标——下次其他开发写的查询还是可能不带引号。更好的做法是从源头消除类型不匹配的可能。

方案二:改表字段类型(长期根治)

如果 phone 字段的业务含义是纯数字组成的字符串(手机号),最佳实践是把字段类型改为与查询一致:

-- Before: VARCHAR(20),有索引
ALTER TABLE users MODIFY phone BIGINT NOT NULL;

注意: - 改字段类型是阻塞 DDL(MySQL 8.0 用 INPLACE 算法,LOCK=NONE 可在线执行) - 修改前确认所有历史数据可以转成 BIGINT(比如没有电话号码存了 +86-13800138000 这种格式) - 修改后索引 idx_phone 仍然有效——BIGINT 列上的索引对 BIGINT 查询直接用

优化前后对比

指标 Before(隐式转换) Before(SQL 加引号) After(改 BIGINT)
type ALL ref ref
rows 498732 1 1
耗时 8.2 秒 ~100ms ~100ms

EXPLAIN 优化前后对比

改完后的核心变化:type 从 ALL→ref,rows 从 50 万→1,耗时从 8.2 秒→~100ms。

"隐式转换不是 MySQL 在帮你——是它在改你的查询条件。"

【标记】🗝 在代码库里搜隐式转换

搜 SQL 文件

grep 搜隐式转换模式

搜 Java 代码

# 搜 String 参数拼接到 SQL 但没加引号
grep -rn "String.*sql.*=.*\"SELECT.*WHERE.*= \" +" src/main/java/

# 搜 MyBatis 参数类型不匹配 @Param 定义和字段类型不一致
grep -rn "jdbcType=VARCHAR\|jdbcType=INTEGER" src/main/java/com/xxx/mapper/

搜历史慢 SQL(MySQL)

-- 从慢查询日志找 type=ALL 的 SQL
SELECT
  db,
  query_time,
  rows_examined,
  sql_text
FROM mysql.slow_log
WHERE sql_text NOT LIKE '%EXPLAIN%'
  AND rows_examined > 10000
ORDER BY query_time DESC
LIMIT 20;

检查 EXPLAIN 预警脚本

建议加一条定期检测:

-- 找所有 production 库中 type=ALL 的查询
SELECT * FROM performance_schema.events_statements_current
WHERE sql_text LIKE 'SELECT%'
  AND (sql_text LIKE '%WHERE % = %' AND sql_text NOT LIKE '%''%')
LIMIT 10;

这个模式的核心思想:任何 WHERE 等号右边出现裸数字(无引号),而左边是已知的 VARCHAR 列,就是隐式转换的高危区


每次写 SQL 前记住三句话: - 等号左右两边类型不一致 → 索引可能被 CAST 吃掉 - SHOW WARNINGS 能告诉你 MySQL 改了什么 - 隐式转换不是 MySQL 在帮你——是它在改你的查询条件

上篇:两个 JOIN 列都有索引,MySQL 还是跑了 38 秒——驱动表选错的排查路径 下篇我们聊分页查询深度翻页性能优化:从 limit 到游标——用 WHERE 条件代替 OFFSET 的翻页技巧


📺 公众号「Ai拆代码的曹操」 🌟 知识星球「Ai拆代码的曹操」