一个 DISTINCT,让我在上线前多盯了三天,最后发现数据库自己就能处理
有些 SQL,看起来真不复杂,但一到压测环境就开始"整活"。
去年做一个项目,上线前例行压测。那几天大家都挺紧张,接口、缓存、数据库、Nginx、JVM 参数,能看的基本都在看。结果有个接口的响应时间一直不稳定,偶尔几百毫秒,偶尔直接飙到几秒。
开发同事先自己查了一圈,最后拿着 SQL 过来问我:
"哥,你帮我看下这个 SQL,为啥一个去重能跑这么久?"
我当时还以为是什么大查询,结果打开一看,SQL 很朴素:
sql
SELECT DISTINCT order_id, customer_id
FROM orders
WHERE order_date = '2025-01-15';
说实话,第一眼我也没觉得它有多离谱。
orders 表大概一千多万数据,当天的订单也就几万条。按常规理解,order_date = '2025-01-15' 先把范围缩小到当天,然后再对 order_id、customer_id 做一下去重。
几万条数据,怎么也不该慢到几秒。
但现场就是这样,SQL 一跑就卡,接口那边也跟着抖。
后来我让他把执行计划拿出来看,问题才慢慢露出来:数据库确实先根据日期过滤了一批数据出来,但是到了 DISTINCT 这里,它没有偷懒,而是老老实实把满足条件的数据全部拿出来,再做排序去重。
关键是,这批数据里面,order_id 和 customer_id 的组合大部分本来就是唯一的。
也就是说,它吭哧吭哧干了半天,最后发现没多少重复数据可以去。
这就有点像什么呢?
你明明知道屋里只有一个人,结果还要拿花名册从头到尾点一遍名,点完以后说:"确认了,确实只有一个人。"
没错,逻辑上没问题,但性能上就很难受。
当时我还吐槽了一句:
"这个优化器也太实在了,给它 DISTINCT,它是真去重啊。"
后来升级到新版本金仓之后,同样的 SQL,再看执行计划,发现它不再傻傻做完整去重了,而是在某些场景下直接把 DISTINCT 优化成了类似 LIMIT 1 的执行方式。
这个变化挺有意思。
因为它不是简单少扫了几行,而是优化器开始"会推理"了。
DISTINCT 慢在哪里?
先把 DISTINCT 说清楚。
DISTINCT 本身没什么神秘的,就是去掉重复行,只保留一份。
比如:
css
SELECT DISTINCT a, b FROM t;
意思就是:
a、b 两列完全一样的记录,只返回一条。
数据库一般怎么做去重?
常见就是两种:
一种是排序去重。
数据库先把结果集按相关字段排好序,然后从头到尾扫一遍,相邻的重复数据跳过。
另一种是哈希去重。
数据库维护一个哈希结构,来一行就判断之前有没有出现过。没出现过就留下,出现过就丢掉。
听起来都挺合理。
但问题是,不管排序还是哈希,前提都是:数据库得先把满足条件的数据拿出来。
如果 WHERE 后面过滤出来的是 10 万行,那就得先处理这 10 万行。哪怕最后去重之后只剩 100 行,前面那 10 万行的读取、排序、哈希判断,大概率还是逃不掉。
这就是很多 DISTINCT 慢的根源。
它慢的不是"返回结果多",而是"为了判断有没有重复,必须先把候选数据都看一遍"。
有时候这就很亏。
比如这个 SQL:
css
SELECT DISTINCT a, b
FROM s1
WHERE a = 1 AND b = 1;
你仔细看一下。
WHERE a = 1 AND b = 1 已经把 a 和 b 的值固定死了。
那 DISTINCT a, b 最终还能是什么?
只能是:
1, 1
最多就是这一行。
如果表里没有符合条件的数据,那就返回空。
如果有一条、十条、一万条符合条件的数据,去重之后也都只会剩下 (1,1)。
这种情况下,还排序干什么?
还哈希干什么?
找到第一条符合条件的数据,直接返回不就完了吗?
但很多数据库优化器不会这么想。它看到你写了 DISTINCT,就按照 DISTINCT 的执行方式来,该扫扫,该排排,该去重去重。
这就是 SQL 看起来简单,但跑起来很慢的原因。
金仓这个优化,关键不在"快",而在"会判断"
金仓这里做得比较有意思。
它不是单纯靠索引,也不是简单把参数调大,而是在优化器层面做了改写。
我理解下来,大概有两层。
第一层,是把一部分 DISTINCT 转成 GROUP BY。
比如:
css
SELECT DISTINCT a, b FROM t;
在语义上,和下面这个 SQL 是等价的:
css
SELECT a, b FROM t GROUP BY a, b;
这一步看起来没那么惊艳,但实际有用。
因为 GROUP BY 在优化器里能走的路更多。
有些情况下,它能利用索引;有些情况下,它可以配合并行执行。尤其数据量大的时候,能不能并行,差别还是挺明显的。
当然,这还不是最关键的。
真正让我觉得有意思的是第二层:
当优化器发现 DISTINCT 后面的字段已经被条件固定住了,就可以直接改写成 LIMIT 1。
还是这个例子:
css
SELECT DISTINCT a, b
FROM s1
WHERE a = 1 AND b = 1;
优化器可以推出来:
a 一定等于 1。
b 一定等于 1。
所以 DISTINCT a,b 的结果最多只有一行。
既然最多一行,那就没必要完整去重。
于是可以变成类似这样:
css
SELECT a, b
FROM s1
WHERE a = 1 AND b = 1
LIMIT 1;
这个改写的效果就很大了。
不改写的时候,数据库可能要这样干:
先扫描表。
找出所有 a = 1 AND b = 1 的数据。
然后对这些数据做去重。
最后返回一行。
改写之后就简单很多:
扫描表。
找到第一条符合条件的数据。
返回。
结束。
后面的数据不用看了。
这两个执行路径,差距不是一点半点。
尤其是满足条件的数据很多的时候,原来可能要处理几万行,优化后可能只要命中第一条就结束。
这就是为什么有些测试里能从几十毫秒降到零点几毫秒,甚至更低。
不是数据库突然"跑得快"了,而是它压根少干了很多活。
JOIN 场景也能推,这点比较实用
单表条件固定还比较好理解,多表 JOIN 里面也能用。
比如:
sql
SELECT DISTINCT s1.a, s2.b
FROM s1
INNER JOIN s2 ON s1.a = s2.b
WHERE s1.a = 5;
这个 SQL 第一眼看,好像比刚才复杂一点。
但是慢慢推一下,其实也不难。
WHERE s1.a = 5,说明 s1.a 已经固定为 5。
又因为:
ini
s1.a = s2.b
所以可以继续推出:
ini
s2.b = 5
也就是说,最终 DISTINCT s1.a, s2.b 里面的两列,其实都已经被固定住了。
结果最多也就是:
5, 5
那这种情况下,它也没必要把所有 JOIN 结果都算出来再去重。
可以走类似下面这种思路:
ini
SELECT s1.a, s2.b
FROM s1
INNER JOIN s2 ON s1.a = s2.b
WHERE s1.a = 5
LIMIT 1;
这里面的核心就是"常量传递"。
5 这个值,通过 s1.a = s2.b 这个 JOIN 条件,被传到了 s2.b 上。
优化器如果能识别这个关系,就能知道目标列其实已经确定了。
这个能力对实际业务挺有用。
因为业务 SQL 里经常有这种写法:
主表筛一个固定值,再 JOIN 其他表,最后为了保险加一个 DISTINCT。
很多时候这个 DISTINCT 是开发习惯性写上去的,怕重复,怕查出来多行。
但数据库如果能判断"你这个结果本来就最多一行",那就可以直接省掉很多没必要的工作。
但这个优化不能乱来
这里也要说一句,DISTINCT 改成 LIMIT 1 不是随便改的。
不是看到 DISTINCT 就能改。
必须保证结果集最多只有一行。
这句话很重要。
比如:
css
SELECT DISTINCT a, b
FROM t
WHERE a = 1;
这个就不能随便改。
因为这里只固定了 a,没有固定 b。
结果可能是:
1, 1
1, 2
1, 3
这时候 DISTINCT a,b 可能有多行,不能用 LIMIT 1 代替。
再比如 LEFT JOIN,也要小心。
INNER JOIN 的等值条件比较好传递。
但 LEFT JOIN、RIGHT JOIN 就复杂了,因为外连接会补 NULL。
右表匹配不到的时候,结果里可能出现 NULL,这就不是简单的等值传递能解决的。
还有子查询、聚合、窗口函数这些东西,一旦掺进来,判断就更麻烦。
所以这个优化真正难的地方,不是把 SQL 文本从 DISTINCT 改成 LIMIT 1。
难的是优化器要非常确定:
这么改以后,结果不能变。
数据库内核里做这种优化,最怕的不是不优化,而是优化错。
慢一点还能接受,结果错了就完了。
所以它必须在规则上卡得很严。能确定就改,不能确定就保持原样。
和其他数据库对比,我觉得金仓这块确实有点东西
我后来也顺手对比过一些数据库。
有些产品面对类似 SQL,执行计划还是很传统:
扫描、过滤、去重,该干的一步不少。
比如同样的测试场景下,达梦 DM8 还是正常走 DISTINCT,没有看到改写成 LIMIT 1 这一类的优化。
这个对比下来,金仓在这个点上确实更激进一点,也更聪明一点。
当然,这里不是说一个优化就能决定数据库强弱。
数据库性能这东西,得看场景。
有些场景拼执行器,有些场景拼索引,有些场景拼并行,有些场景拼优化器规则。
但就这个 DISTINCT 场景来说,金仓这个优化确实挺实用。
尤其是国产数据库迁移项目里,很多业务 SQL 都是历史代码留下来的。开发当年为了"保险",经常会加一堆 DISTINCT。
你说这些 SQL 全部人工改一遍吧,成本很高,也容易改出问题。
如果数据库本身能识别一部分无效 DISTINCT,并自动优化掉,那对迁移和性能调优来说,是能省不少事的。
实际写 SQL 的时候,还是要注意几点
虽然数据库能优化,但我不建议大家以后看到重复就无脑加 DISTINCT。
DISTINCT 不是免费午餐。
写 SQL 的时候,最好先想清楚:
这条 SQL 为什么会重复?
重复是业务上允许的,还是 JOIN 条件写多了?
是不是本来应该用唯一索引保证?
是不是应该调整表关系,而不是最后靠 DISTINCT 兜底?
很多慢 SQL,表面看是 DISTINCT 慢,实际上是前面的 JOIN 已经把数据放大了。
比如一张订单表 JOIN 订单明细表,再 JOIN 活动表、优惠券表,一不小心就从一行订单变成几十行。最后开发一看重复了,顺手加个 DISTINCT。
接口是能跑了,但数据库压力也上来了。
所以我的建议是:
能从业务关系上避免重复,就不要靠 DISTINCT 硬压。
能用唯一索引表达唯一性,就尽量交给约束。
确实需要去重,再用 DISTINCT。
如果是"目标列已经被固定"的场景,那就交给新版本优化器处理。
这样比较稳。
回到最开始那个问题
那次压测的问题,最后确实没改多少业务代码。
我们重点看了执行计划,又验证了新版本金仓的优化效果。升级之后,同类 SQL 的表现明显好了不少。
开发同事后来问我:
"你到底改了啥?怎么一下快这么多?"
我说:
"没改业务逻辑,主要是数据库自己变聪明了。"
这话听着像开玩笑,但其实差不多就是这么回事。
以前的优化器,更像一个会算账的人。
它会算全表扫描贵不贵,索引扫描划不划算,排序要不要落盘。
但现在的优化器,不能只会算账,还得会推理。
比如:
css
WHERE a = 1 AND b = 1
再配上:
css
SELECT DISTINCT a, b
人一看就知道,结果最多只有一行。
以前数据库未必知道。
现在金仓能识别出来,并把它改成更轻的执行方式。
这个变化,对业务开发来说其实挺舒服。
因为很多时候,我们写 SQL 不可能每一条都手工抠到极致。尤其老项目、迁移项目、多人协作项目,SQL 风格很难统一。
数据库优化器能多兜一点底,开发和 DBA 就少熬一点夜。
所以这个 DISTINCT 的故事,表面上看是一个 SQL 优化点,往深了看,其实是数据库优化器从"执行规则"往"理解语义"迈了一步。
说白了就是:
以前你告诉数据库"我要去重",它就真的去重。
现在它会多想一步:"你这个结果本来就只有一条,我还去什么重?"
这一步,挺关键。