一个 DISTINCT,让我在上线前多盯了三天,最后发现数据库自己就能处理

一个 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_idcustomer_id 做一下去重。

几万条数据,怎么也不该慢到几秒。

但现场就是这样,SQL 一跑就卡,接口那边也跟着抖。

后来我让他把执行计划拿出来看,问题才慢慢露出来:数据库确实先根据日期过滤了一批数据出来,但是到了 DISTINCT 这里,它没有偷懒,而是老老实实把满足条件的数据全部拿出来,再做排序去重。

关键是,这批数据里面,order_idcustomer_id 的组合大部分本来就是唯一的。

也就是说,它吭哧吭哧干了半天,最后发现没多少重复数据可以去。

这就有点像什么呢?

你明明知道屋里只有一个人,结果还要拿花名册从头到尾点一遍名,点完以后说:"确认了,确实只有一个人。"

没错,逻辑上没问题,但性能上就很难受。

当时我还吐槽了一句:

"这个优化器也太实在了,给它 DISTINCT,它是真去重啊。"

后来升级到新版本金仓之后,同样的 SQL,再看执行计划,发现它不再傻傻做完整去重了,而是在某些场景下直接把 DISTINCT 优化成了类似 LIMIT 1 的执行方式。

这个变化挺有意思。

因为它不是简单少扫了几行,而是优化器开始"会推理"了。


DISTINCT 慢在哪里?

先把 DISTINCT 说清楚。

DISTINCT 本身没什么神秘的,就是去掉重复行,只保留一份。

比如:

css 复制代码
SELECT DISTINCT a, b FROM t;

意思就是:
ab 两列完全一样的记录,只返回一条。

数据库一般怎么做去重?

常见就是两种:

一种是排序去重。

数据库先把结果集按相关字段排好序,然后从头到尾扫一遍,相邻的重复数据跳过。

另一种是哈希去重。

数据库维护一个哈希结构,来一行就判断之前有没有出现过。没出现过就留下,出现过就丢掉。

听起来都挺合理。

但问题是,不管排序还是哈希,前提都是:数据库得先把满足条件的数据拿出来。

如果 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 已经把 ab 的值固定死了。

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 优化点,往深了看,其实是数据库优化器从"执行规则"往"理解语义"迈了一步。

说白了就是:

以前你告诉数据库"我要去重",它就真的去重。

现在它会多想一步:"你这个结果本来就只有一条,我还去什么重?"

这一步,挺关键。

相关推荐
砍材农夫1 小时前
物联网 基于netty核心实战-心跳保活机制
java·后端·物联网·struts·servlet·netty
小江的记录本1 小时前
【JVM虚拟机】垃圾回收GC:垃圾判定算法:引用计数法、可达性分析算法(附《思维导图》+《面试高频考点清单》)
java·jvm·后端·python·算法·spring·面试
小小小小宇2 小时前
Go 语言高并发场景、使用方式与协程通俗讲解
后端
白宇横流学长2 小时前
基于SpringBoot实现的校园失物招领平台设计与实现【源码+文档】
java·spring boot·后端
古城小栈3 小时前
Rust Tauri:构建轻量高性能跨平台桌面应用
开发语言·后端·rust
染翰3 小时前
Linux root用户安装配置Git
linux·git·后端
正在走向自律4 小时前
金仓数据库DISTINCT优化:从全表扫描到LIMIT 1的蜕变
后端
小马爱打代码4 小时前
Spring源码 第十二篇:Spring 全套核心原理 - 完结终章
java·后端·spring
西安邮电大学4 小时前
2026华为OD机考真题附答案-准备生日礼物
java·后端