前言
写 SQL 的人,对 DISTINCT 实在太熟悉了。统计不重复的值、列出唯一组合,第一反应都是加个 DISTINCT。语法简单,意思也清楚,可数据量一上去,它就可能悄悄变成拖慢查询的那个点。这篇文章想拆解的,是数据库内核给 DISTINCT 做的一套自动优化,不用你改一行 SQL,内核自己就把低效去重换成了高效查询。原理并不复杂,但它正好能讲清楚一件挺有意思的事:优化器是怎么从一堆条件里"想明白"结果唯一的。
这里写目录标题
- 前言
-
- [一、DISTINCT 凭什么慢](#一、DISTINCT 凭什么慢)
- 二、内核想了两条路
- 三、目标列被"钉死",是怎么回事
-
- [3.1 常量传递:把已经知道的值往前推](#3.1 常量传递:把已经知道的值往前推)
- [3.2 谓词传递:从等值关系里挤出约束](#3.2 谓词传递:从等值关系里挤出约束)
- [3.3 说穿了,它是一道证明题](#3.3 说穿了,它是一道证明题)
- 四、改写的安全底线:语义等价
- 五、实际收益有多大
- 六、看懂了这一点,也就看懂了现代优化器
- 七、写在最后
一、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 金仓数据库智能运维工具开发大赛,围绕智能运维给了个实战切磋的舞台,详情说明在这里。二是金仓社区的"同行者计划",荐商机、参与共建都有回馈,适合正在推项目或者找合作的朋友。内核的优化是个无底洞,但路子一旦走对,回报来得也实在。