DISTINCT 语句优化:内核怎么把一次去重变成 LIMIT 1

前言

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

这里写目录标题

一、DISTINCT 凭什么慢

先看最朴素的一条:

sql 复制代码
SELECT DISTINCT a, b FROM s1;

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

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

二、内核想了两条路

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

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

sql 复制代码
-- 改写前
SELECT DISTINCT a, b FROM s1;
-- 改写后
SELECT a, b FROM s1 GROUP BY a, b;

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

sql 复制代码
-- 改写前
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 常量传递:把已经知道的值往前推

sql 复制代码
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 谓词传递:从等值关系里挤出约束

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

sql 复制代码
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 的全部等值条件、目标列的常量。推理规则就是常量传递、谓词传递、等价类合并。要证的结论是,每个目标列是不是都能被绑到一个具体常量上。用伪代码描述,大概是这样:

text 复制代码
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 毫秒,差不多三个数量级的提升。

sql 复制代码
-- 改写前执行计划(节选):扫描 + 排序去重
--   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 就是这种"先推理、后算账"的典型,它不是把去重做得更快,而是判定去重这个动作根本不用做。

七、写在最后

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

性能调优、智能运维,是数据库领域怎么也绕不开的两个方向,真想钻进去,光看文章不够,得动手练。要是正好有兴致,两件事可以留意一下。一是 2026 金仓数据库智能运维工具开发大赛,围绕智能运维给了个实战切磋的舞台,详情说明在这里。二是金仓社区的"同行者计划",荐商机、参与共建都有回馈,适合正在推项目或者找合作的朋友。内核的优化是个无底洞,但路子一旦走对,回报来得也实在。