Mysql

关注公众号 jb51net

关闭
首页 > 数据库 > Mysql > MySQL DISTINCT优化

MySQL中DISTINCT语句去重优化机制详解

作者:鸽芷咕

本文深入解析了SQL中DISTINCT的优化机制,介绍了内SQL内内核如何自动将DISTINCT改写为GROUPBY或LIMIT以提高查询效率,通过具体案例展示了优化器如何通过语义推理确定目标列是否被常量固定,从而实现高效去重

写 SQL 的人,对 DISTINCT 实在太熟悉了。统计不重复的值、列出唯一组合,第一反应都是加个 DISTINCT。语法简单,意思也清楚,可数据量一上去,它就可能悄悄变成拖慢查询的那个点。这篇文章想拆解的,是数据库内核给 DISTINCT 做的一套自动优化,不用你改一行 SQL,内核自己就把低效去重换成了高效查询。原理并不复杂,但它正好能讲清楚一件挺有意思的事:优化器是怎么从一堆条件里"想明白"结果唯一的。

一、DISTINCT 凭什么慢

先看最朴素的一条:

SELECT DISTINCT a, b FROM s1;

意思是从 s1 里把那些不重复的 (a,b) 组合挑出来。要是内核没什么特殊处理,执行路径基本是固定的:全表扫一遍,再排序或者建个哈希表,然后在这上面去掉重复,输出。

数据量一大,扫描和排序这两步哪个都不便宜。更可惜的是另一种情况,查询里明明带着很强的过滤条件,目标列的值早就被锁死了,结果集其实最多就一行。可 DISTINCT 不懂这些,它照样扫完全表、排完序、再去一次重,整段去重,成了纯粹的空转。

二、内核想了两条路

这种空转,内核想了办法对付,归纳起来就两条路。

第一条,把 DISTINCT 改写成 GROUP BY。GROUP BY 这条路平时就跑得比较成熟,背后站着键值消除和并行执行两样现成的本事。键值消除说穿了也简单,分组键里要是包含主键,主键已经能唯一确定一行了,分组自然可以化简。

-- 改写前
SELECT DISTINCT a, b FROM s1;
-- 改写后
SELECT a, b FROM s1 GROUP BY a, b;

第二条更狠,把 DISTINCT 或 GROUP BY 改写成 LIMIT 1。要是能判定目标列已经被常量卡死、结果最多一行,完整去重就完全没必要了,找到一条满足条件的,马上返回。

-- 改写前
SELECT a, b FROM s1 WHERE a=1 AND b=1 GROUP BY a, b;
-- 改写后
SELECT a, b FROM s1 WHERE a=1 AND b=1 LIMIT 1;

第一条好理解。第二条的关键和难点,全在"要是能判定"这几个字上,目标列到底有没有被常量卡住,往往不是一眼能看出来的。下面重点拆这一条。

三、目标列被"钉死",是怎么回事

所谓目标列被常值固定,意思就是经过分析能确定,目标列的取值已经是具体常量了。一旦所有目标列都成了常量,结果集顶多一行,去重自然就多余了。

常量可能从几个地方冒头。WHERE 里直接给的,比如 a=1。JOIN 等值条件里间接推导出来的,比如 s1.a = s2.b。几个谓词之间互相传递出来的,A 等于 B,B 又等于 C,那 A 自然就等于 C。还有一种最省事的,目标列本身就是常量,连列都没引用。

这背后其实是编译原理里的两门老手艺,一个叫常量传递,也有人叫常量传播,另一个叫谓词传递。

3.1 常量传递:把已经知道的值往前推

SELECT DISTINCT a, b FROM s1 WHERE a=1 AND b=1;

优化器会把 WHERE 拆成一棵逻辑表达式树:

          AND
         /   \
       a=1   b=1

从这棵树一眼能读出来,a 这会儿是常量 1,b 也是常量 1。两个目标列都钉死了,(a,b) 这个组合顶多一种取值,就是 (1,1)。结果集的行数被推理卡在了"至多一行",于是可以放心改写成 LIMIT 1。改写之后,扫描时撞见第一条满足条件的记录就返回,排序和去重节点整个消失。

3.2 谓词传递:从等值关系里挤出约束

常量传递只管那种直接赋值的。可真实查询里,约束常常藏得很深,得靠等值关系一层层往下挖。它靠的是个叫等价类的概念,说白了就是,彼此相等的一组列归在同一类里,这一类里只要有谁被绑到了常量,整组人也就全跟着绑了。

SELECT s1.a, s2.b
FROM s1 INNER JOIN s2 ON s1.a = s2.b AND s1.a = 5
GROUP BY s1.a, s2.b;

推演过程是这样。第一步,从 s1.a = s2.b 看出 s1.a 和 s2.b 属于同一个等价类。第二步,s1.a = 5 又把这个等价类整体绑到了常量 5 上。第三步,靠谓词传递就能得出,s2.b 也必然等于 5。

s1.a = s2.b          ┐
                     ├─ 等价类 { s1.a, s2.b }
s1.a = 5   ──────────┘ 常量 5 扩散到全体成员
                     ⇒ s2.b 也等于 5

这里有个细节值得注意,原始 SQL 里谁也没直接说"s2.b 等于某个常量",这个结论是优化器自己推出来的。两个目标列都被固定之后,分组去重就能改写成 LIMIT 1。

3.3 说穿了,它是一道证明题

这套判断,可以当成一道证明题来看。已知条件是 WHERE 的全部谓词、JOIN 的全部等值条件、目标列的常量。推理规则就是常量传递、谓词传递、等价类合并。要证的结论是,每个目标列是不是都能被绑到一个具体常量上。用伪代码描述,大概是这样:

function 可否改写为_LIMIT_1(查询 Q):
    常量绑定表 = {}
    # 1. 常量传递:记下 WHERE 里的直接赋值
    for 谓词 in Q.WHERE:
        if 谓词 形如 (列 = 常量):
            常量绑定表[列] = 常量
    # 2. 谓词传递:建等价类,常量向同类成员扩散
    等价类 = 由所有等值条件合并而成()
    for 类 in 等价类:
        if 类中任一成员 已在 常量绑定表:
            把该常量绑定扩散给类内全部成员
    # 3. 逐个检查目标列,看是不是全部被卡死
    for 目标列 in Q.目标列:
        if 目标列 不在 常量绑定表:
            return False          # 有一列没固定,改写不安全
    return True                   # 全部固定,可以放心改写

结论成立,"结果唯一"就是个被严格证明出来的事实,不是拍脑袋猜的。

四、改写的安全底线:语义等价

任何 SQL 改写的前提,都是不能改变结果。DISTINCT 要变 GROUP BY,再变 LIMIT 1,每一步都在动 SQL 的结构。可真实业务里的语句,常常带着 JOIN、子查询、聚合,乱改一下结果就错了。所以内核必须立一套很严的约束条件,只有能证明"改写前后结果完全一致"的时候才动手。这一下,就把干这活的难度从"会套规则",提到了"得会做证明"。

五、实际收益有多大

落到真实执行计划上,改写的效果很直观。下面是两组最小化测试的数据,具体数字会随数据规模和硬件变化。

路径一,DISTINCT 改写成 GROUP BY,查询时间从大约 464 毫秒降到了 249 毫秒,收益主要来自更成熟的并行执行和键值裁剪。

路径二,改写成 LIMIT 1,效果尤其明显。简单场景从大约 30 毫秒降到 0.03 毫秒,带 JOIN 的复杂场景从大约 12 毫秒降到 0.08 毫秒,差不多三个数量级的提升。

-- 改写前执行计划(节选):扫描 + 排序去重
--   Sort
--     Sort Key: a, b
--     ->  Seq Scan on s1

-- 改写后执行计划(节选):命中即返回,去重节点消失
--   Limit
--     ->  Seq Scan on s1
--           Filter: ((a = 1) AND (b = 1))

排序和去重这些节点,从执行计划里整个抹掉,这就是改写落在执行层面的样子。

六、看懂了这一点,也就看懂了现代优化器

传统优化器靠的是"比代价"。它把若干候选执行计划列出来,挨个估个代价,CPU、IO、内存各多少,然后挑最便宜的那个。它会挑,但不会"想",规则库里没有的能力,它变不出来,代价算得再准,也只会在几种排列里挑个相对不那么差的。

现代优化器则额外多了一手"做推理"。它在算代价之前,先判定一件事,这个操作到底要不要发生。DISTINCT 改写成 LIMIT 1 就是这种"先推理、后算账"的典型,它不是把去重做得更快,而是判定去重这个动作根本不用做。

七、知识扩展

在 MySQL 中,DISTINCT 的执行原理本质上是排序去重临时表去重。如果数据量较大,这两个操作都会带来显著的 I/O 和内存开销(文件排序、创建隐式临时表)。

优化的核心逻辑是:让 DISTINCT 操作尽可能利用索引(有序性),或通过改写 SQL 逻辑来避免对全表进行无谓的排序去重。

以下是 7 条高效的优化技巧与实战方案:

1. 黄金法则:利用索引规避排序(Index Skip Scan)

这是最高效的优化方式。如果 DISTINCT 的字段有索引,MySQL 可以利用索引的有序性,直接扫描索引去重,无需创建临时表

注意:如果 DISTINCT 涉及多个字段(如 SELECT DISTINCT col1, col2),必须建立 (col1, col2) 的联合索引才能触发松散扫描。

2. 逻辑改写:用 EXISTS 替代 DISTINCT(针对多表关联)

当使用 JOIN 查询时,DISTINCT 通常是为了消除因“一对多”关系产生的重复主表数据。这种情况非常消耗资源,因为你先关联了庞大的数据集,再去重。

场景:查询“下过订单的所有用户”。

优化前(低效)

SELECT DISTINCT u.id, u.name 
FROM users u 
JOIN orders o ON u.id = o.user_id;

(MySQL 需关联所有数据,再对结果集去重)

优化后(高效)

SELECT u.id, u.name 
FROM users u 
WHERE EXISTS (SELECT 1 FROM orders o WHERE o.user_id = u.id);

(半连接(Semi-Join)机制,一旦找到一条匹配记录立即返回,无需排序去重)

3. 逻辑改写:用 GROUP BY 替代 DISTINCT(特定场景)

在某些复杂查询(如涉及聚合函数)或特定 MySQL 版本中,GROUP BY 的优化器路径可能更优,且显式分组更容易利用索引。

场景:查询指定条件下的唯一用户 ID。

写法对比

-- 方式一
SELECT DISTINCT user_id FROM orders WHERE status = 1;

-- 方式二(通常等价且更利于索引排序)
SELECT user_id FROM orders WHERE status = 1 GROUP BY user_id;

如果 (status, user_id) 有联合索引,GROUP BY 可以直接利用索引顺序完成分组,无需回表。

4. 利用 SQL_BIG_RESULT / SQL_SMALL_RESULT 提示优化器

MySQL 允许你向优化器提供提示,告知它结果集的大小,从而决定使用内存临时表还是磁盘临时表。

5. 缩小数据范围:先过滤,再去重

将 DISTINCT 放在最内层子查询中,先利用索引缩小数据范围,再对外层进行去重,可以极大减少临时表的大小。

优化前SELECT DISTINCT user_id FROM logs;

优化后

SELECT DISTINCT user_id 
FROM (
    SELECT user_id FROM logs 
    WHERE create_time > '2026-01-01' 
      AND create_time < '2026-06-01'
) AS recent_logs;

6. 参数调优:增大临时表阈值

如果无法完全避免临时表,可以通过调整配置参数来降低磁盘 I/O 开销:

7. 特殊场景:使用 DISTINCT 与 LIMIT 结合

如果你只需要前 N 个不重复的值,LIMIT 配合索引可以让 MySQL 在找到满足条件的 N 条记录后立即停止扫描。

八、写在最后

DISTINCT 优化这件事,表面是让去重变快,骨子里是优化器在做逻辑推理。它用常量传递把已知值推到目标列上,用谓词传递从等值关系里挤出隐藏的约束,再证明结果唯一,最后用 LIMIT 1 把去重整个短路掉。传统优化器和现代优化器的差距,也就在这儿,一个会算代价,一个还会做证明。

以上就是MySQL中DISTINCT语句去重优化机制详解的详细内容,更多关于MySQL DISTINCT优化的资料请关注脚本之家其它相关文章!

您可能感兴趣的文章:
阅读全文