
处理了几年的 SQL问十个人里面有八个能把 SELECT、WHERE、JOIN 背得滚瓜烂熟但真到了要去重的时候一半人直接用 DISTINCT 一把梭另一半人抱着 GROUP BY 不放两拨人还经常在代码评审里吵起来。DISTINCT 这玩意儿看着就一个关键字实际上坑不少性能问题、语义误解、跟 ORDER BY 打架、对 NULL 的特殊处理这些都能让你在线上环境栽跟头。这篇文章我把 DISTINCT 从原理、语法、实操到优化重新捋一遍把那些文档里不会写的细节和踩过的坑摊开来讲。不管你是刚学 SQL 的新手还是天天写报表的老手这篇文章都值得花十分钟看完。1. DISTINCT到底是什么先搞清楚它在解决什么问题1.1 三个字解决重复行问题DISTINCT 的中文意思是不同的、有区别的在 SQL 里它的作用就是一句话去掉结果集中的重复行。注意我说的是重复行不是重复列。很多新手第一次用就栽在这儿——以为 SELECT DISTINCT 字段A 就能把字段A 里重复的值去掉结果发现它返回的是一整行数据只是这个行组合是唯一的。举个最典型的例子。假设有一张订单表 orders里面有客户编号 customer_id 和城市 citycustomer_idcity1001北京1002上海1001北京1003广州1002上海如果你执行SELECT DISTINCT customer_id FROM orders得到的是三个客户编号1001、1002、1003。但如果你执行SELECT DISTINCT customer_id, city FROM orders得到的是四行因为 (1001, 北京) 和 (1002, 上海) 虽然有重复但组合起来看(1001, 北京) 出现了两次被合并成一行(1002, 上海) 出现了两次被合并成一行加上另外两行一共四行。这个细节就是 DISTINCT 最容易踩的坑之一后面我会展开讲。1.2 DISTINCT背后的运行逻辑理解 DISTINCT 的性能问题得先知道它在数据库内部干了什么。说白了数据库拿到你的查询结果之后会对结果集做一次去重操作去重的方式无非两种一种是排序一种是哈希。排序去重是最常见的方式就是先把结果集按照所有 SELECT 出来的列做一次排序然后挨个比较相邻行是否相同相同就删掉。这个过程的复杂度是 O(n log n)数据量一大排序的开销就很明显。哈希去重则会建立一个哈希表遍历结果集往里塞遇到相同的就不放空间换时间但内存消耗高。不管哪种方式有一件事是确定的DISTINCT 是在查询结果生成之后才做的过滤。它不是索引层面直接帮你跳过重复值而是先把数据捞出来再折腾一遍。这就解释了为什么一到大表上DISTINCT 经常成为慢 SQL 的元凶。我见过一个真实的案例一张上亿行的流水表业务方为了查有多少个用户直接SELECT COUNT(DISTINCT user_id) FROM transactions跑了四十多秒把生产库的 CPU 打满了。后面改成了先做COUNT(DISTINCT user_id)但把查询时间窗口缩小再把历史统计结果做成中间表增量更新查询直接降到毫秒级。这个思路后面在优化章节细说。1.3 DISTINCT和GROUP BY到底谁才是去重之王这俩是 SQL 面试里出现频率最高的对比题。先说结论绝大多数情况下光看去重效果DISTINCT 和 GROUP BY 是等价的。SELECT DISTINCT department FROM employees和SELECT department FROM employees GROUP BY department结果一模一样。但在实际场景里二者有明显的使用倾向对比维度DISTINCTGROUP BY语义侧重点结果的唯一性分组统计与聚合能否搭配聚合函数可以配 COUNT不能直接 SELECT 其他聚合必须配聚合函数才常见是否隐式排序部分数据库会隐式排序同样可能排序但可用索引优化可读性简单直接表达分组意图更强我的建议是如果只是想得到有哪些不同的值用 DISTINCT语义清晰如果还需要按组算 SUM、AVG、MAX 之类的聚合指标那就必须用 GROUP BY。在做到精度去重的同时还要带出最新一条记录的完整字段这俩都不行得上窗口函数 ROW_NUMBER()这个在实操章节我会单独演示。2. DISTINCT的语法与核心细节拆解2.1 单列去重最简单也最容易翻车单列去重的语法没什么好说的SELECT DISTINCT product_type FROM products;这条 SQL 会返回 products 表里所有不重复的商品类型。看起来人畜无害但翻车点在于很多数据库不允许 DISTINCT 和 ORDER BY 组合时出现没被 SELECT 的列。比如你写SELECT DISTINCT product_type FROM products ORDER BY create_time DESC;在 MySQL 和 PostgreSQL 里这条语句会直接报错提示 ORDER BY 的列必须出现在 SELECT 列表中。因为一旦去重一条 product_type 可能对应多个 create_time数据库根本不知道按哪个时间排序。这是语义层面的矛盾不是数据库故意刁难你。正确做法是先指定到底要哪条记录比如先取每个类型最新的创建时间再按结果排序SELECT product_type FROM products WHERE (product_type, create_time) IN ( SELECT product_type, MAX(create_time) FROM products GROUP BY product_type ) ORDER BY create_time DESC;这种需求说穿了就是按某维度去重但保留指定的一条单靠 DISTINCT 是做不到的得换 GROUP BY 或者窗口函数。遇到这个场景我习惯直接上窗口函数后面 3.3 节会给出标准写法。2.2 多列去重你以为去重了其实只是组合去重多列 DISTINCT 的准确的表达是SELECT DISTINCT col1, col2, ...它判断的是多列组合是否重复而不是单独某一列不重复。这一点必须刻在脑子里。SELECT DISTINCT customer_id, order_date FROM orders;这条 SQL 返回的是 (customer_id, order_date) 的每一个不同组合。如果你想的是看看有多少个不重复的客户这个查询给不了你要的答案它返回的可能是同一个客户在不同日期的多行记录。我自己在业务中就遇到过一个报表事故。运营要一份活跃用户数开发直接写SELECT DISTINCT user_id, action_date FROM user_logs;结果出来一个 8000 行的大表运营看着懵了明明要一个数字怎么给了一张表。其实他应该写的是SELECT COUNT(DISTINCT user_id) FROM user_logs;所以我在团队里定了一条规矩DISTINCT 后面跟几个字段先想清楚这几个字段之间是什么关系你要去重的是多列的组合一致还是某一列的值一致。前者用多列 DISTINCT 没错后者要么只 SELECT 那一列 DISTINCT要么用子查询先定主键。另外提醒一点多列 DISTINCT 的索引优化比较讲究。如果经常要跑SELECT DISTINCT col1 FROM big_table WHERE col2 xxx那么建立联合索引 (col2, col1) 往往比单独建 col1 索引更有效因为索引能直接覆盖 WHERE 和 DISTINCT 的字段避免回表。2.3 NULL值处理DISTINCT和你想的不一样关于 NULLDISTINCT 的行为在几乎所有主流数据库里是一致的多个 NULL 会被当成同一个值。也就是说如果一个列里有三行都是 NULLSELECT DISTINCT col只会返回一行 NULL而不是三行。这在逻辑上是讲得通的因为 NULL 代表未知未知和未知之间没法比较数据库就统一把它们归为一类。但注意这个行为和普通 WHERE 条件完全不同。你在 WHERE 里写col NULL永远查不到数据必须写col IS NULL可到了 DISTINCT 这里NULL 反而抱团了。很多从其他语言转过来的开发者在这里会懵我第一次看到结果集里只有一个 NULL 时也愣了半天。更有意思的是 COUNT(DISTINCT) 对 NULL 的处理。执行SELECT COUNT(DISTINCT col) FROM test_table;NULL 值根本不会被计数。这是一个非常重要的细节因为它直接影响统计口径。假设你要统计订单表里有多少个不同的优惠券 ID而有一部分订单没有用券coupon_id 字段是 NULL。COUNT(DISTINCT coupon_id)返回的不会包含未用券这个情况结果会比预期少一档。如果你希望把 NULL 也作为一类统计进去需要用 COALESCE 先转换SELECT COUNT(DISTINCT COALESCE(coupon_id, -1)) FROM orders;把 NULL 统一替换成一个不可能出现的占位值这样统计口径就完整了。这个技巧在实际报表开发里非常实用。2.4 DISTINCT搭配COUNT这是最常用的统计姿势平时用得最多的场景其实是 COUNT(DISTINCT 某字段)也就是去重计数。这里有个性能重点COUNT(DISTINCT) 通常比单纯 SELECT DISTINCT 还要重因为数据库既要完成去重又要从头到尾扫完整列来计数没法走索引直接拿结果。-- 统计每个城市有多少不同客户 SELECT city, COUNT(DISTINCT customer_id) AS customer_cnt FROM orders GROUP BY city;这条 SQL 在数据量上来之后往往就是慢 SQL 排行榜的常客。优化思路不外乎几条缩小统计范围加时间分区条件让查询走分区裁剪。如果指标是历史累积型的每天算一次全量变化太大就做成增量中间表每天只统计新增部分。用近似去重算法替代精确去重比如 ClickHouse 的 uniqCombined、PostgreSQL 的 HyperLogLog 扩展在允许极小误差的场景下速度能提升几个数量级。我见过一个团队为了看全站用户数天天全表 COUNT(DISTINCT user_id)每次两分钟。后来改成每天凌晨算全量快照存结果表查询变成查一条记录秒开。需求方要的是今天的用户数和昨天的差异根本不需要实时跑全量。想清楚需求比优化 SQL 本身更值钱。3. 实操从去重需求到SQL落地3.1 场景一报表统计里的去重计数先看一个最常见的业务需求统计每个月的独立下单用户数。-- 错误示范这样只是把同一个用户的多条订单拆成多行 SELECT MONTH(order_date), user_id FROM orders GROUP BY MONTH(order_date), user_id; -- 正确写法 SELECT MONTH(order_date) AS month, COUNT(DISTINCT user_id) AS uv FROM orders GROUP BY MONTH(order_date) ORDER BY month;这个需求里最容易犯的错就是拿 GROUP BY user_id 的那版凑数以为分组了就是去重了结果跑出来的行数完全不是报表要的。解决思路其实很简单先明确统计粒度。粒度的意思是你要结果里的每一行代表什么——这里的粒度是每个月一行而不是每个月每个用户一行。粒度定清楚了SELECT 哪些列、GROUP BY 哪些列、COUNT 里套什么就都顺了。代码评审时我一般会先问提数的人这个数字的每一行是什么如果他说每个月的活跃用户数那 GROUP BY 就只能有月份用户 ID 只能出现在 COUNT(DISTINCT) 里面。3.2 场景二联表查询后的结果去重联表查询加 DISTINCT是重复数据的重灾区。原因很简单JOIN 本身就会因为一对多关系产生重复行如果 SELECT 的字段不全来自主表你看到的重复可能根本不是数据真的重复而是 JOIN 产生的。举个例子一个用户表 users 和一个订单表 orders一个用户有多张订单SELECT DISTINCT u.user_id, u.user_name FROM users u JOIN orders o ON u.user_id o.user_id WHERE o.order_date 2024-01-01;这条 SQL 的目的可能是找出今年下过单的所有用户但因为 JOIN 产生了一对多user 信息被重复返回所以外层套了 DISTINCT。功能上它能跑通但这属于典型的治标不治本。更优的做法是用 EXISTS 做半连接SELECT u.user_id, u.user_name FROM users u WHERE EXISTS ( SELECT 1 FROM orders o WHERE o.user_id u.user_id AND o.order_date 2024-01-01 );EXISTS 的语义是只要存在就算匹配”它天然不会产生多行也就无所谓去重。性能上EXISTS 通常在优化器里转换成 semi join比先 JOIN 全部行再 DISTINCT 一遍高效得多。这是我在慢 SQL 优化里最常用的一招如果 DISTINCT 后面跟的列全是主表的赶紧检查你是不是在用 JOIN 造重复然后换成 EXISTS 或 IN。3.3 场景三窗口函数替代DISTINCT做按维度去重真正的去重王者是窗口函数 ROW_NUMBER()。当你需要按某个维度去重同时保留每个维度里最新或最旧、得分最高的完整记录时DISTINCT 和 GROUP BY 都不好使只有窗口函数能干净利落地解决。需求还原一下一张支付流水表 payments每个订单号 order_no 可能有多条支付记录需要找出每个订单号的最后一条支付记录。WITH ranked AS ( SELECT *, ROW_NUMBER() OVER ( PARTITION BY order_no ORDER BY pay_time DESC ) AS rn FROM payments ) SELECT * FROM ranked WHERE rn 1;这条 SQL 可以封成模板直接用。PARTITION BY 指定按哪个维度分组ORDER BY 指定组内怎么排序rn 1 取每组第一条。想保留最旧的一条就把 ORDER BY 改成 ASC想保留金额最大的一条就 ORDER BY amount DESC非常灵活。对比一下传统写法要用子查询先找到每组最大时间再 JOIN 回来SQL 长一倍还容易踩坑。窗口函数出来后这种需求基本一统天下了。MySQL 8.0、PostgreSQL、SQL Server 2012、Oracle 都支持老版本的 MySQL 5.7 没有这个函数就得退回 GROUP BY 子查询的写法。对了使用窗口函数去重时需要注意 PARTITION BY 的粒度一定得和业务口径对齐。这里订单一 TP 是 order_no如果你把用户 ID 也加进 PARTITION BY那同一个订单号的不同用户记录就不会合并了结果和预期直接背离。3.4 场景四慢SQL优化中的DISTINCT改造慢 SQL 优化里DISTINCT 是高频嫌疑人。当你发现一条 SQL 慢EXPLAIN 里又有 Using temporary、Using filesort 的字段多半和 DISTINCT、GROUP BY 脱不了干系。排查和改造的路径我按自己的经验给个顺序第一步确认能不能去掉 DISTINCT。重新读一遍业务需求问这个去重真的是必须的吗很多时候重复是因为 JOIN 或 WHERE 范围太大造成的缩小 WHERE 范围或修正 JOIN 条件重复行消失了DISTINCT 也就没存在必要了。第二步换成 EXISTS / IN。如果 DISTINCT 是为了消除一对多 JOIN带来的重复直接改成 EXISTS 或 IN这是收益最大的一步。第三步加索引。如果必须 DISTINCT那就要让去重发生在索引层面。比如SELECT DISTINCT category FROM products WHERE brand 某品牌在 brand、category 上建联合索引 (brand, category)数据库可以直接走覆盖索引扫出唯一值不用回表排序。第四步预聚合。对非常频繁的统计指标别让 SQL 每次都全量跑用中间表、物化视图或者凌晨定时任务生成快照。方案设计永远比 SQL 调优更能解决根子上的问题。下面是优化前后的对比示例一个典型的去重计数 SQL-- 优化前17秒 SELECT COUNT(DISTINCT user_id) FROM user_behavior_log WHERE log_date BETWEEN 2024-01-01 AND 2024-01-31; -- 优化后先建中间结果表再查快照 CREATE TABLE daily_active_user_202401 AS SELECT log_date, user_id FROM user_behavior_log WHERE log_date BETWEEN 2024-01-01 AND 2024-01-31 GROUP BY log_date, user_id; -- 之后所有统计都走这张小表毫秒级返回这个思路说穿就是把每次都做的大活变成只做一次的大活 无数次的小活。做数据的人要有这种工程化的思维别老指望数据库硬扛。4. DISTINCT的坑与优化建议汇总4.1 常见报错与误区速查表我把实操中经常遇到的 DISITINCT 相关问题和错误整理成一张速查表方便你以后排查问题现象根本原因解决办法ORDER BY 的列不在 SELECT 中报错去重后该列值不唯一用 GROUP BY 或窗口函数先指定记录多列 DISTINCT 结果比预期多理解成单列去重了明确多列组合唯一的语义COUNT(DISTINCT) 少了 NULL聚合函数忽略 NULL用 COALESCE 转换占位值JOIN DISTINCT 性能差JOIN 一对多产生重复行换成 EXISTS / 半连接大表 DISTINCT 内存爆了哈希/排序内存不足加索引、缩范围、预聚合DISTINCT 结果排序不稳定部分数据库依赖物理存储顺序显式加 ORDER BY且 ORDER BY 列必须可唯一标识这张表你可以在团队文档里留一份大部分去重问题都能对号入座。4.2 大表去重性能问题的排查思路排查 DISTINCT 慢 SQL我的标准流程是先在 EXPLAIN 里看三件事是否出现 Using temporary。出现说明数据库要用临时表来去重数据量一大磁盘会爆。是否出现 Using filesort。出现说明排序无法走索引DISTINCT 或 GROUP BY 的列上缺合适的索引。扫描行数是否远大于返回行数。如果 DISTINCT 一个建了唯一索引的列扫描行数还那么大说明 SQL 根本没走到索引。针对这三种情况解决手段分别是对应加索引、改写 SQL、缩查询范围。我给一个具体的排查示例EXPLAIN SELECT DISTINCT user_id FROM orders WHERE status paid;如果执行计划显示走了全表扫描且 orders 表百万级那你可以在 (status, user_id) 上建联合索引。这样 WHERE 条件 status paid 命中索引前缀DISTINCT 的 user_id 也能直接从索引里取到唯一值大概率变成 Index Only Scan性能提升几个数量级。还有一个很多人忽略的点DISTINCT 的列如果类型不一致会导致隐式转换索引直接失效。比如左边是字符串右边传了数字数据库为每个值做转换再去重慢得离谱。调 SQL 前先检查字段类型和查询参数类型是否对齐这个检查成本几乎为零但非常容易被忽视。4.3 面试高频题DISTINCT和GROUP BY到底怎么选这块单独拿出来讲是因为面试官太爱问了而且很多人答不到点子上。我的答案分三层第一层结论单纯去重用 DISITINCT按维度聚合统计用 GROUP BY。前者语义直观后者可以搭配聚合函数。第二层原理两者在内部都可能走排序或哈希但优化器对 GROUP BY 的支持往往更成熟尤其在有索引时GROUP BY 走 loose index scan 比 DISTINCT 更高效。所以DISTINCT 一定比 GROUP BY 快是错的反过来也不成立具体看执行计划。第三层反例如果去重的数据还要和其他表 JOIN或者要做复杂的筛选那别纠结这俩了用 EXISTS 或者窗口函数可能是更优解。工具服务于场景不是场景服务于工具。面试官如果追问COUNT(DISTINCT) 为什么慢你把上面讲的内存排序/哈希、无法走索引、忽略 NULL 这些点答出来基本就能过关。如果再能补一句在允许误差的场景可以用 HyperLogLog 这类近似算法替代那就是加分项了。我个人在实际操作中的体会是DISTINCT 从来不是一个需要背语法的知识点它考察的是你有没有把重复的定义想清楚。多写几年 SQL 你就会发现大多数去重需求翻车都不是关键字用得不对而是业务口径没对齐。技术永远是为了准确表达业务而存在的。最后再分享一个小技巧写任何去重 SQL 之前先在纸上划一下每行结果代表什么粒度再想想行内字段之间的关系是一对一还是一对多这两步想清楚了DISTINCT、GROUP BY、窗口函数选哪个根本不用犹豫。你要是也踩过 DISTINCT 的坑或者有更好的去重优化方案欢迎一起交流。