前两天帮一个朋友模拟面试,他简历上写着“熟悉 MySQL 调优”。我问了一句:EXPLAIN 出来的 type=ref 是什么意思?他答得很快:非唯一索引等值查询。然后我接着问:那 eq_ref 呢?和 ref 差在哪?复合唯一索引的最左前缀查询,type 会是 const 还是 ref?他沉默了几秒,我就知道,这个知识点要补课了。ref 这个词很有意思,它在 type 列里排在中间位置——比 range、index、ALL 好,比 const、eq_ref 略差。很多面试者能背出这个顺序,但说不清为什么。这篇文章把 ref 从头到尾拆干净,从执行计划的含义到 B+Tree 的扫描原理,再到真实优化案例和面试话术,适合正在准备 MySQL 面试的人,也适合平时用 EXPLAIN 优化慢查询但只停留在表面的开发者。
1. ref 到底是个啥:从执行计划的第一列说起
1.1 type 列就是 MySQL 访问表的方式
MySQL 执行一条 SELECT 之前,优化器会生成一份执行计划,EXPLAIN 就是把这计划摊开给你看的工具。很多初学的人盯着 select_type、table 这些列看半天,但我一直认为 type 这一列才是执行计划的灵魂,因为它直接告诉你:MySQL 到底是用什么姿势从表里取数据的。
type 的全称叫访问类型(access type),本质上描述的是存储引擎层访问数据的策略。你见过的大多数值可以按效率从高到低粗略排序:system > const > eq_ref > ref > ref_or_null > range > index > ALL。注意这个排序不是官方文档里的绝对顺序,实操中不同场景会有细微差异,但作为面试回答的大框架是没问题的。
继续说 ref 的位置:它卡在中间偏上的区域,代表的是“用上了二级索引,但是索引列不唯一,等值匹配可能命中多行”的访问方式。换句话说,ref 意味着 MySQL 确实没有闷头做全表扫描,而是顺着索引去定位了一批行,只是这批行可能不止一条。
这个“可能不止一条”特别关键。很多人把 ref 简单理解成“走索引了”,这不够。走索引有很多种走法,point 查询、范围查询、索引全扫都是走索引,但各自的开销和返回行数完全不是一个量级。ref 具体属于哪一种,我在后面的章节里展开。
1.2 ref 的官方定义与直观例子
看 MySQL 官方手册对 ref 的注释,原文大意是:如果 join 操作只用到了索引的最左前缀,或者用的是非唯一索引,那么对于前一张表的每一行组合,都会从这张表读取所有匹配索引值的行,这种访问方式就叫 ref。
拆开来看,能把 type 判为 ref 的条件有三个:
- 用的是二级索引,也就是非聚簇索引(包括普通索引和唯一索引)。
- 查询条件是等值比较,最常见的就是
WHERE 索引列 = 某个值。 - 如果用联合索引,条件必须命中最左前缀;如果索引本身有唯一性约束,也必须只用到了左前缀而不是完整联合唯一键。
举两个具体例子。假设有一张用户表user(id, name, age),其中name上有普通索引idx_name。执行EXPLAIN SELECT * FROM user WHERE name = '张三',大概率你会看到 type=ref,ref 列对应 const,意思是用一个常量去索引列上做等值匹配。
再假设有一个联合唯一索引uk(a, b),执行WHERE a = 1,这时候 type 依然会是 ref,而不是 const。为什么?因为a=1在索引里可能对应多条记录(比如(1, 100)、(1, 200)),唯一性保证不了行数是 1。这个细节如果面试能主动讲出来,比背十句八股都有用。
2. ref 和它的邻居们:const、eq_ref、ref_or_null、range 的区别
2.1 一张表对比四种访问方式
面试里最常出现的连环追问,就是让你把 ref 和前排的几位“邻居”做区分。我把它们放在同一张表里对比,这样信息密度最高,看起来一目了然。
| 访问类型 | 典型场景 | 索引要求 | 可能返回行数 | 常见 SQL 形态 |
|---|---|---|---|---|
| const | 主键或完整唯一索引等值匹配 | 唯一索引全部列 | 最多 1 行 | WHERE id = 1 |
| eq_ref | 联表查询时被驱动表走主键/唯一索引 | 唯一索引完整匹配 | 每次最多 1 行 | JOIN ... ON t2.id = t1.uid |
| ref | 普通二级索引等值匹配,或唯一索引左前缀 | 二级索引/最左前缀 | 可能多行 | WHERE name = '张三' |
| ref_or_null | ref 基础上额外查 NULL | 二级索引 | 可能多行 | WHERE name = '张三' OR name IS NULL |
| range | 索引列范围比较 | 二级索引 | 范围区间行 | WHERE age BETWEEN 20 AND 30 |
这张表可以直接背,但背完还得理解背后的逻辑。const 之所以叫“常量”,是因为优化器在做执行计划之前就能确定最多只返回一行,这行数据甚至可以被当成常量直接嵌入计划里,代价低到可以忽略。eq_ref 是 join 语境里的 const,它要求被驱动表的连接字段是唯一索引,这样驱动表每给一行,被驱动表最多回一行,不会产生行数放大。
而 ref 不保证行数唯一,所以优化器对它的代价估算要比 const 和 eq_ref 高一截。这也解释了为什么 type 排序里 ref 排在它们后面:它需要沿着索引扫描到一个“连续区间”,区间里有几条命中的索引条目,就得处理几条。
2.2 面试官最爱挖的两个坑:唯一索引左前缀和 eq_ref 的归属
第一个坑就是我前面提到的复合唯一索引。很多候选人背了“const 是唯一索引等值查询”,一到实际场景就翻车:UNIQUE INDEX uk(a, b),然后 SQL 写WHERE a = 1,他们脱口而出 type 是 const。错就错在没用“完整唯一索引”这五个字。官方对 const 的定义明确写的是“最多返回一行”,而a=1在组合唯一索引里可能有 (1, x)、(1, y) 多行,虽然索引本身唯一,但查询条件没有锁死所有组成列,行数唯一性就没了。
第二个坑是 eq_ref 和 ref 的归属。有一个我常听到的错误答案是“eq_ref 是等值引用,ref 也是等值引用,两者区别不大”。实际上 eq_ref 几乎只在多表 join 中被驱动表的位置出现,它强制要求连接列是主键或完整唯一索引,并且连接条件走的是索引列的全部列。举个例子,SELECT * FROM orders o JOIN users u ON o.user_id = u.id,如果orders是驱动表,users通过主键id被查找,那么对 users 的访问类型就是 eq_ref。你几乎不会在单表单条 SQL 里看到 eq_ref,这一点能帮你在面试时快速判断访问类型的实际含义。
ref 和 eq_ref 的底层差异也决定了优化器行为:eq_ref 每次从驱动表拿到一行,去被驱动表做一次点查,最多返回一行,代价近似 O(N);ref 从驱动表每拿到一行,去被驱动表的二级索引上可能取回多条,如果被驱动表索引选择性不好,代价可能退化成接近 O(N*M)。这也是为什么有时候 MySQL 会宁愿改走全表扫描而不选 ref,后面实战部分我会专门演示。
3. 为什么二级索引等值匹配就是 ref:B+Tree 扫描区间分析
3.1 从聚簇索引和二级索引说起
要理解 ref 为什么是现在这个样子,得先回到 InnoDB 的索引结构。InnoDB 表默认按主键聚簇,主键索引的叶子节点直接存整行数据,这叫聚簇索引。你手动在其它列上建的索引叫二级索引,二级索引的叶子节点不存整行,只存“索引列的值 + 主键值”。当你通过二级索引找数据,流程是先在二级索引的 B+Tree 里定位到目标索引条目,拿到主键,然后再回聚簇索引查完整行,这一步就是常说的回表。
这个设计能带来很多好处但也有代价:二级索引本身是一棵独立的 B+Tree,索引列有序排列;而回表是额外的一次随机 IO。ref 这个访问类型就是在这种结构下产生的标准动作——通过二级索引先定位,再看是否需要回表。
这里有个很容易被忽略的点:二级索引的叶子节点之间有链表连接,并且按索引列值顺序排列。所有索引查找的本质,都可以抽象成“在有序数组里划一个或多个区间,然后顺序读取区间内的叶子节点”。ref 对应的区间正好是“索引列值等于某个常量的所有叶子节点”,也就是一个准确定值区间。区间内有多少条目不取决于 SQL 怎么写,而取决于这个索引列在表里有多少重复值。
3.2 等值匹配如何在 B+Tree 上形成连续扫描区间
很多文章讲 B+Tree,讲得最多的是“矮胖树、减少磁盘 IO”,却很少讲清楚“扫描区间”这个概念。我第一次彻底理解 ref,是因为看了一个调优案例:一张表 1000 万行,name索引区分度很低,等值查一个名字能命中 20 万行。执行计划里 type=ref,rows 显示 20 万。当时我就意识到,ref 并不是某些人想象中的“点查”,它的效率完全取决于区间长度。
过程是这样的:优化器把WHERE name='张三'转换成对二级索引的一个区间扫描,起始位置是索引树中第一个等于‘张三’的叶子节点,终止位置是最后一个等于‘张三’的叶子节点。B+Tree 的等值查找,从根节点开始逐层下探,每一层通过二分比较,最终定位到起始叶子;然后顺着叶子节点的链表向后遍历,直到遇到的索引值不再是‘张三’为止。命中的每一条叶子记录里存的是主键值,再用这个主键值回表查整行。
这个机制的言外之意是:只要叶子节点上命中的条数变多,扫描时间就线性增长,回表次数也线性增长。所以同样显示 type=ref,rows=1的 SQL 和rows=200000的 SQL,实际性能天差地别。面试时如果能主动说出“ref 的执行时间主要取决于匹配行数和回表成本”,面试官就会觉得你是真用 EXPLAIN 排过障的人,而不是只会背概念。
3.3 ref 和覆盖索引、ICP 的组合含义
type=ref 只告诉你 MySQL 用二级索引做了定位,但没告诉你回没回表。回没回表要看 Extra 这一列。这是我观察到的另一个高频误区:很多人以为 type=ref 就代表“性能很好,不用回表”。实际上 ref 和回表是两个维度的事情。
- 如果 SQL 只 SELECT 索引列本身,比如
SELECT name FROM user WHERE name='张三',二级索引叶子节点上的数据已经足够返回结果,不需要回表,Extra 会显示Using index。这是最理想的 ref。 - 如果 SELECT 了索引以外的列,比如
SELECT *,那必须回表取整行,Extra 不显示Using index。 - 如果 WHERE 里除了等值匹配条件,还有额外的索引列过滤条件,比如联合索引 (name, age) 上执行
WHERE name='张三' AND age>20,MySQL 5.6 以后可以把 age 的条件判断下推到存储引擎层,先索引扫出来再过滤,减少回表次数,Extra 会显示Using index condition,也就是 ICP(索引条件下推)。
这三个组合能回答一个很常见的面试连环问:“同样是 ref,为什么有的 SQL 快有的 SQL 慢?”答案就在 Extra 列里。Using index完全不回表,Using index condition部分过滤后再回表,什么都没有就要老老实实逐条回表。这几个状态想清楚了,你对 ref 的理解深度已经超过 80% 的候选人。
4. 实战演示:把一条 type=ALL 的慢 SQL 优化到 type=ref
4.1 建表、造数、一条慢查询
讲了这么多理论,现在落地跑一遍。我用一个典型场景:用户表,收集 user_id、name、age、email 四个字段,模拟一百多万行数据。没有任何索引的原始状态,执行计划通常会让人绝望。
CREATE TABLE user ( id INT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(50) NOT NULL, age INT NOT NULL, email VARCHAR(100) NOT NULL ) ENGINE=InnoDB; -- 批量插入若干条数据,这里用存储过程造一百万行 -- 简单示意,实际生产用数据生成工具或脚本执行下面这条查询:
EXPLAIN SELECT * FROM user WHERE name = '赵四';在没有索引时,type 列会是 ALL,key 列是 NULL,rows 接近全表行数,Extra 显示Using where。ALL 表示这条 SQL 要把聚簇索引的叶子节点从头到尾扫一遍,这是最坏的情况。Using where也说明过滤是在存储引擎把所有数据吐出来之后、在 server 层做的,意味着大量无用数据被白白读了一遍。
这个场景在真实生产环境里太常见了:用户表越来越大,查询条件写得没问题,就是没建索引,于是一条按 name 查用户信息的简单语句,硬生生把数据库 CPU 打到 100%。很多初级 DBA 第一反应是“加内存、加缓存”,实际上你 EXPLAIN 一下,加一个索引就能解决。
4.2 加索引前后 EXPLAIN 对比
给 name 字段建一个普通二级索引,注意不是唯一索引,因为我们允许同名用户存在:
ALTER TABLE user ADD INDEX idx_name(name);再次执行 EXPLAIN:
EXPLAIN SELECT * FROM user WHERE name = '赵四';这时候 type 变成 ref,key 显示 idx_name,key_len 是 varchar(50) 在 utf8mb4 字符集下对应的字节长度,ref 列显示 const,rows 从一百多万掉到个位数。整条 SQL 的扫描范围从全表变成“索引等值区间”,性能提升是数量级的。
再看一个覆盖索引的例子:
EXPLAIN SELECT name, age FROM user WHERE name = '赵四';如果我把索引改成联合索引 (name, age),那么这条查询的 type 依然是 ref,但 Extra 会多一个Using index,表示所有需要的列都在索引里,不需要回表。这个优化在实际项目里特别实用,因为它省掉的不是一两次 IO,而是每一行命中的随机回表 IO。
4.3 为什么有时候加了索引 type 还是 ALL:选择性陷阱
这里有个特别值得拿出来讲的实战经验:加了索引,type 也不一定变 ref。如果一个索引列的重复值太多,MySQL 优化器会自己算一笔账:通过二级索引定位到海量主键,再逐条回表,成本和直接全表扫描差不多,甚至更高,于是它宁可走 ALL 也不走 ref。
拿我刚才那张表举例,如果里面只有 10 个不同的 name,每个 name 对应十万行,那么WHERE name='赵四'虽然能用 idx_name 定位,但要回表十万次,InnoDB 大概会认为全表扫描更快。这时候你在 EXPLAIN 里看到的 type 可能还是 ref,也可能变成 ALL,具体取决于优化器版本和统计信息;但 rows 会告诉你真实情况。
这种场景怎么救?核心思路不是强扭优化器,而是把索引做成覆盖索引。如果你把 SQL 改成SELECT name, age FROM user WHERE name='赵四',并且索引是 (name, age),所有数据都在索引里,不需要回表,那么即使某个 name 有十万行,也是顺序扫索引叶子节点,成本可控。这就是为什么我一直建议:想要 ref 效果稳定,优先考虑把高频查询里的字段塞进联合索引,做成覆盖索引,而不是指望一个单列索引包打天下。
5. 面试追问轰炸区:ref 相关的五个深坑
5.1 最左前缀原则下 ref 的边界
联合索引 (a, b, c),查询条件WHERE b=1 AND c=2能不能用上 ref?答案是大概率不能。二级索引先按 a 排序,a 相同再按 b,b 相同再按 c。跳过了 a 直接等值匹配 b,索引本身的有序性发挥不出来,优化器只能走 index 全索引扫描或者 ALL。这是最左前缀原则的基本盘。
再往深问一层:WHERE a=1 AND c=2会是什么 type?a 能命中索引左前缀,type=ref;c 无法直接参与索引定位,但它属于索引列,可以在索引内部做过滤,MySQL 会视情况启用 ICP,在扫描 (a=1) 区间的过程中过滤 c=2。所以这条 SQL 的 type 可能依然是 ref,但 Extra 里会出现Using index condition字样。能把这个细节讲清楚,才是真的理解最左前缀和 ref 的关系。
5.2 索引列上动手脚,ref 秒变 ALL
这是实战里最常见的“意外情况”。索引列被函数包裹,或者发生隐式类型转换,都会导致索引失效。比如 name 上有索引,但写的是WHERE UPPER(name)='ZHANGSAN',MySQL 无法直接使用 name 的 B+Tree 有序结构去定位,因为索引里存的是原始值而不是函数结果,type 直接退化。
再比如 phone 字段是 varchar,但查询写WHERE phone = 13800138000,这里的 13800138000 会被当成数字类型。MySQL 为了比较,会把 phone 字段转型成数字,一旦对列本身做隐式转换,索引定位就用不了了。刷面试题的时候,很多候选人能答出这条规律,但问“为什么”就卡住。其实原因很简单:B+Tree 的有序性依赖原始列值的排列,任何对列的加工都会破坏这个排列,索引自然就废了。
5.3 ref_or_null、ORDER BY、NULL 对 ref 的影响
有一种特殊的 ref 变体叫 ref_or_null,出现条件是WHERE key = 'abc' OR key IS NULL。普通等值匹配只需要扫索引里等于‘abc’的区间,但加上 IS NULL 之后,MySQL 还得另外把值为 NULL 的索引条目也扫一遍。Extra 里通常能看到Using where,type 显示 ref_or_null。面试时能补一句“ref_or_null 比 ref 多一次 NULL 扫描”,说明你读过官方文档。
ORDER BY 对 ref 也有影响。比如SELECT * FROM user WHERE name='张三' ORDER BY age,如果 name 上有索引但 age 没有,MySQL 拿到所有匹配行后要额外做一次 filesort;但如果联合索引是 (name, age),排序就可以直接利用索引顺序,避免额外排序。常见面试追问是“type 显示 ref 时,ORDER BY 能一定避免 filesort 吗”,答案是不能,必须看排序字段是否包含在同一个索引中。
NULL 本身比较特殊。如果索引列允许 NULL,那么等值条件WHERE name='张三'不会匹配到 NULL 行。NULL 行只能靠 IS NULL 查出来。这也解释了为什么很多时候 DBA 建议把索引列设置为 NOT NULL,一方面是避免语义混淆,另一方面是减少索引扫描的额外分支。
6. 答题话术:把 ref 答出层次感
6.1 三句话及格版
如果面试官只给了你三十秒,你可以这样答:ref 是 MySQL 执行计划里 type 列的一种访问类型,代表查询用到了非唯一二级索引,或者唯一索引的最左前缀做等值匹配,匹配结果可能返回多行。它比 range、index、ALL 好,因为至少走索引定位;但比 const、eq_ref 差,因为不保证只返回一行,可能需要回表。
这个回答能拿到及格分,因为概念准确,还点出了它在访问类型谱系里的位置。但如果面试官想深挖,这个答案撑不了太久,因为没解释为什么,也没展示你对执行计划细节的熟悉程度。
6.2 带原理的加分版
如果能多给一分钟,我会推荐这样答:访问类型里的 ref 本质上是二级索引等值匹配。InnoDB 的二级索引是一棵独立的 B+Tree,叶子节点按索引列有序排列并且存了主键值;等值条件会被优化器转换成一个扫描区间,MySQL 从索引根节点定位到区间的起始叶子,再沿叶子链表顺序扫描到值变化为止。这个区间里有多少条记录,取决于索引列的选择性。如果再往深处看,ref 和回表是两个维度的问题:SELECT 语句只取索引列时,Extra 会出现 Using index,不用回表;如果取索引列以外的字段,就要回表聚簇索引。联合索引情况下还可以配合 ICP,把部分过滤条件下推到存储引擎层。最左前缀原则也在这里生效:跳过联合索引最左侧列,通常就没法形成 ref。
这段话含金量很高,因为它把访问类型的含义、数据结构的支撑、实际执行的IO路径、相关优化特性全部串起来了。面试官想继续问,也只能往更深的方向问,但你已经证明了自己不是背题人。
6.3 被追问时的应对逻辑
面试中问完 ref,大概率会继续追 const、eq_ref、range。我的建议是不要背定义,而是记一条主线:type 的排序本质上是“定位一条数据的成本从低到高”。const 是唯一匹配一行,eq_ref 是 join 里唯一匹配一行,ref 是二级索引匹配多行,range 是一个区间,index 是扫整个索引,ALL 是扫全表。所有追问都是围绕这条主线展开的。
如果被问到“为什么 type=ref 了还是很慢”,不要慌,从三个角度排查:回表次数是不是太多,也就是索引选择性好不好;Extra 列是不是没有 Using index,说明每条命中的记录都额外回表了;ORDER BY 或 GROUP BY 有没有造成 filesort。这三个点说完,基本就把一个实际调优问题答全了。
我在实际面试里见过不少人,能准确说出 ref 的定义,但一谈到真实 SQL 调优就露馅。原因在于只背了概念,没有把执行计划、索引结构、回表成本这条链路打通。希望这篇解析能帮你把 ref 这个点真正钉在脑子里,下次无论是面试还是排查慢查询,都能自信地跟人聊出深度来。