做SQL的人十有八九都栽过同一个跟头满心欢喜跑了一条查询结果结果集里刷出一排一模一样的订单号排查半天才发现是JOIN的时候把一对多关系带进来了。这种重复可以说是SQL世界里出现频率最高、又最容易被低估的问题。DISTINCT就是专门用来干掉结果集重复行的关键字字面上理解起来很简单——去重但真要用得明白得搞清楚它和GROUP BY、UNION、窗口函数之间那些微妙的边界还得知道它在性能、NULL处理、排序约束上藏着的各种细节。这篇内容适合刚开始学SQL的入门者也适合那些已经会用DISTINCT但踩过坑、想弄明白为什么的开发者。我会从重复数据怎么来、DISTINCT基础语法怎么写、和GROUP BY怎么选、NULL怎么处理、性能怎么优化、以及去重后如何取最新数据这几个角度把我这些年实际用下来的心得一次性讲清楚。1. 重复数据是怎么混进结果集的先搞懂DISTINCT到底在解决什么问题我见过太多人一看到结果集里有重复就条件反射写DISTINCT但如果你不清楚重复是从哪个环节进来的DISTINCT就可能只是把问题盖住而不是真正解决。1.1 一对多JOIN导致的行膨胀最常见的一种重复是查询多张表的时候JOIN出来的。举个例子假设有订单表和订单明细表-- 订单表一个订单对应一行 SELECT * FROM orders; -- 订单明细表一个订单可以有多条明细 SELECT * FROM order_items;如果你要查订单的金额、状态同时还想带出明细的商品名称很自然地会这样写SELECT o.order_id, o.customer_name, i.item_name FROM orders o JOIN order_items i ON o.order_id i.order_id;只要一个订单下挂了两条明细这个订单在结果集里就会出现两行。此时你看到的重复其实并不是数据错乱而是表之间关系的天然结果。很多新手到这里就开始懵为什么我的订单表里明明只有一行查出来却有两行这种场景下直接用DISTINCT确实能把重复的行合并掉但我更建议你先想清楚业务上到底要什么。如果只是要订单维度的信息应该用聚合或子查询把明细先收敛而不是查完明细再DISTINCT。因为明细一旦多了DISTINCT的代价会成倍上升而且你丢失了明细本身的价值。1.2 表本身的数据质量问题另一种情况是物理表里真的存在重复行。常见于没有主键约束、或者是从多个数据源导入时没有做幂等控制的历史表。比如一张用户触达记录表同一用户同一时间点被插入了两次运行下面这条语句时就会出现完全相同的两行SELECT user_id, touch_time, touch_channel FROM user_touch_log;这种情况下DISTINCT是合理的止血手段。但我要提醒一句用DISTINCT掩盖数据质量问题之前先确认一下你的下游表、报表、对账逻辑是不是也会被同一个数据源污染。如果源头不清理每个查询都得挂DISTINCT系统会变得越来越慢而且一旦某天漏写了一个数据又脏了。1.3 UNION ALL带来的伪重复还有一种制造重复的现场是UNION ALL。UNION ALL的本意就是把两个查询的结果纵向拼接它不会做任何去重。而UNION则会在拼接之后自动去重。很多人记不住这两个的区别导致写出来的报表里出现重复汇总。-- 两个查询各自返回一部分用户拼接时可能出现同一用户出现在两边 SELECT user_id FROM vip_users UNION ALL SELECT user_id FROM active_users;如果同一用户既在VIP名单里又在活跃名单里UNION ALL会把他输出两次。这时候你要是反应快改成UNION就能去重但也要掂量一下UNION的去重是要付出排序或哈希代价的。能用UNION ALL保证两侧数据本身互斥就不必让数据库白做一次去重。2. DISTINCT基础语法单字段、多字段与表达式的去重差异聊完重复的来源回到DISTINCT本身。它最基础的形态其实一句话就能说清写在SELECT关键字后面让结果集中每一行都唯一。2.1 单字段去重最朴素的用法SELECT DISTINCT department FROM employees;这条语句的作用是把员工表里所有不重复的部门列出来。结果里每个部门只会出现一次哪怕这个部门有一百个员工。这个场景最简单也最不容易出错。2.2 多字段去重整个组合要被当成一行真正容易出问题的写法是多个字段一起用。很多人会误以为SELECT DISTINCT a, b是只对a去重b随便取一条这是SQL入门阶段最大的误解之一。SELECT DISTINCT city, region FROM store_locations;这条语句的含义是把(city, region)当成一个整体只有两个字段的组合完全一样才被合并。比如(北京, 朝阳)和(北京, 海淀)是两个不同的组结果集中都会保留。你在结果集里会看到北京重复出现多次但每一行和另一行的(city, region)组合都不相同。理解了这个逻辑你就能明白为什么DISTINCT在保留一条记录这件事上非常无能为力。它只能告诉你某个组合是否存在不能告诉你组合里其他字段取什么值。比如你想查每个城市的第一家门店DISTINCT做不到它只能把城市列表列出来。这个需求得交给窗口函数或分组聚合后面第6部分会专门讲。2.3 对表达式去重换一种分组口径DISTINCT后面跟的不一定是列名也可以是表达式。这个技巧在处理时间、文本规范化时非常实用。-- 按年份去重查出有哪些年份产生过订单 SELECT DISTINCT EXTRACT(YEAR FROM order_date) FROM orders; -- 把姓名转成大写后去重避免大小写差异导致的假重复 SELECT DISTINCT UPPER(customer_name) FROM customers;第一条用于快速盘点数据覆盖的时间范围比先查全部订单再在应用层做去重要轻量得多。第二条在清洗数据时特别好用很多系统录入时大小写不统一ZhangSan和zhangsan在数据库里会被当成两行用UPPER统一口径后再DISTINCT才能看清真实的人数。这种写法本质上是把表达式的结果当成一个新的值来参与去重判断。2.4 SELECT ALL被遗忘的对立面和DISTINCT相对的是SELECT ALL它表示不去重也是SQL的默认行为。你写SELECT name FROM users和SELECT ALL name FROM users效果完全一样。大多数数据库的优化器会自动把ALL当作默认所以平时没人写它但你知道它的存在再看别人代码里偶尔冒出来的SELECT ALL就不会觉得奇怪了。3. DISTINCT与GROUP BY、UNION的三角关系怎么选才不会后悔很多人学到后面会问DISTINCT能干的事GROUP BY不是也能干吗确实这两个语法在很多场景下结果一样但它们的定位完全不同。我倾向于把DISTINCT理解为存在性检查而GROUP BY是分组后再计算。3.1 等价但不等价一个简单的对比-- 写法一直接去重 SELECT DISTINCT department FROM employees; -- 写法二按部门分组 SELECT department FROM employees GROUP BY department;在不带任何聚合函数时这两个语句的结果集基本一样。但GROUP BY真正的价值在于它可以一边分组一边算统计量比如SELECT department, COUNT(*), AVG(salary) FROM employees GROUP BY department;这种需求用DISTINCT就完全做不到了。反过来如果你只是想把某个字段的枚举值拉出来用GROUP BY反而显得重。实际工作中我更推荐按语义选择如果只是我要看有哪些不同的值用DISTINCT如果还要按这些值分别做计算用GROUP BY。3.2 COUNT(DISTINCT)的正确姿势DISTINCT和聚合函数结合时威力就出来了。最常见的组合是COUNT(DISTINCT 列名)用来统计一共有多少个不同的值-- 统计有订单产生的日期有多少天 SELECT COUNT(DISTINCT order_date) FROM orders; -- 统计每个城市的客户数 SELECT city, COUNT(DISTINCT customer_id) FROM customer_address GROUP BY city;这里有个细节COUNT(DISTINCT customer_id)统计的是客户ID去重后的数量而不是明细行数。如果同一客户在同一个城市有多条地址记录不加DISTINCT就会把他算成多个人。这种错误在出报表时特别致命轻则让老板看到虚高的数字重则影响到运营决策。我在带新人时经常强调涉及人数客户数订单数这类统计时先问一句主键是不是唯一的不唯一就立刻上COUNT(DISTINCT)。另外一个值得注意的点是COUNT(DISTINCT ...)在计算时会额外做一次去重排序数据量大时并不便宜。如果这张表有上千万行每一行都要参与去重判断查询可能会跑好几秒甚至几十秒。后面第5部分我会讲怎么通过改写来优化。3.3 UNION自带隐形DISTINCT前面提到UNION会自动去重这其实就是在内部做了一次DISTINCT操作。你可以把UNION理解为UNION ALL再加上一次去重。所以如果两个查询结果本身就不会重复优先用UNION ALL省掉这次多余的去重。如果确实需要合并两个来源并去除重复才用UNION。-- 把两个渠道的注册用户合并同时自动去重 SELECT user_id FROM channel_a_users UNION SELECT user_id FROM channel_b_users;4. NULL值在DISTINCT眼里的地位一个经常被误解的细节NULL大概是SQL里最容易引发争议的值。它既不是0也不是空字符串而是一个未知的标记。DISTINCT碰见NULL时有一套自己的规则不懂的人很容易踩坑。4.1 NULL被当成同一个值来合并先看一个例子-- 客户联系方式表部分客户手机号是NULL SELECT DISTINCT phone_number FROM customers;如果这张表里有3行phone_number都是NULLDISTINCT结果里只会出现一个NULL行而不是三行。听起来很合理但这和SQL的等值比较逻辑其实是矛盾的——在WHERE phone_number NULL里NULL和NULL不相等任何等值比较都会返回未知但DISTINCT去重时却把所有NULL当成同一组。这个反差让不少初学者困惑。实际含义是什么就是无法区分具体值的数据会被归为一类。你在做数据清洗时如果想看看有多少种不同的手机号用上面这条就够了。但如果想统计有多少客户没留手机号得用WHERE phone_number IS NULL而不是COUNT(DISTINCT)。4.2 COUNT(DISTINCT)自动忽略NULL这个特性在某些场景下很省心在某些场景下却会误导人。默认情况下COUNT(DISTINCT col)不会把NULL计进去。比如一张订单表里有100行其中30行这个字段是NULL另外70行里有50个不同的非NULL值那么COUNT(DISTINCT col)的结果是50而不是51更不是80。-- 结果只统计非NULL的不同值 SELECT COUNT(DISTINCT coupon_code) FROM orders;如果你想知道使用了多少种优惠券这个结果是正确的。但如果你想统计有优惠券记录的订单有多少种状态可能要把NULL单独拎出来看。我在做数据质量报告时经常用下面这条语句同时看非空去重数和NULL数量SELECT COUNT(DISTINCT coupon_code) AS distinct_not_null, COUNT(*) - COUNT(coupon_code) AS null_count FROM orders;COUNT(coupon_code)只统计非NULL的行数用总行数减去它就能得到NULL的数量。两个指标放一起数据的基本盘就清楚了。5. 实战踩坑DISTINCT性能与排序、子查询的经典矛盾把DISTINCT当成一把万能钥匙到处用迟早会遇到性能问题。我见过一个数据量撑到两千万行的业务表原本一条查询只要两秒加了个DISTINCT之后直接变成四十秒还把数据库CPU打到接近100%。这一部分把我实际踩过的坑和排查链路完整过一遍。5.1 DISTINCT与ORDER BY的隐形约束有些数据库对DISTINCT和ORDER BY的组合有严格限制。以SQL Server为例使用SELECT DISTINCT时ORDER BY后面的列必须出现在SELECT列表中否则直接报错-- SQL Server中会报错ORDER BY的列不在SELECT列表中 SELECT DISTINCT department FROM employees ORDER BY hire_date;原因很好理解DISTINCT会在排序之前先去重去重后hire_date可能对应多个值数据库根本不知道该用哪一条来排。MySQL在这点上相对宽松但语义同样容易让人懵——MySQL会先取DISTINCT结果再按ORDER BY排序如果ORDER BY列不在选择列表中排序结果可能与预期不一致。我的建议是只要在去重查询里用了ORDER BY就把排序列也放到SELECT列表中或者改成用聚合函数取一个确定值。比如想按部门分组后按最早入职日期排序SELECT department, MIN(hire_date) AS earliest_hire FROM employees GROUP BY department ORDER BY earliest_hire;这样既去重了又排序了语义还清清楚楚。5.2 大数据量下DISTINCT为什么慢DISTINCT的实现原理通常依赖排序或哈希。数据库需要把涉及去重的所有行都读出来然后对它们做排序或构建哈希表最后只保留每组的第一行。这个过程的代价取决于去重字段的基数基数低比如性别字段只有几个值排序很快哈希表很小基数高比如订单号或手机号值几乎都不同几乎所有行都要参与排序或哈希的代价接近O(n log n)甚至更高更麻烦的是如果去重中间结果超过了内存限制数据库会把临时数据写到磁盘这时候查询速度会断崖式下降。我排查过一条慢SQL执行计划里显示Sort Method: external merge Disk日志里明确写着内存不够用了。这种情况下光改SQL写法还不够得从数据量和索引上去找方案。5.3 用EXISTS替代DISTINCT的场景有一类经典场景是查哪些客户下过单。很多人的第一反应是JOIN订单表再DISTINCTSELECT DISTINCT c.customer_id, c.customer_name FROM customers c JOIN orders o ON c.customer_id o.customer_id;这个写法在订单表数据量大的时候非常吃亏因为JOIN会先把两张表做匹配可能产生大量中间行然后再去重。换个思路用EXISTS改成存在性判断数据库一旦找到一条满足条件的订单就能立刻停下SELECT c.customer_id, c.customer_name FROM customers c WHERE EXISTS ( SELECT 1 FROM orders o WHERE o.customer_id c.customer_id );两条语句的结果一样但后者避免了一次先膨胀再压缩的过程。在订单表有几百万行时这个改写经常能让查询从几十秒降到一两秒。这是个很值得养成习惯的优化点DISTINCT不是不能用而是要想清楚它处理的数据量到底有多大。5.4 养成看执行计划的习惯我排查DISTINCT相关慢SQL基本流程固定先EXPLAIN看执行计划确认是不是出现了Sort、HashAggregate、Unique这类关键节点再看有没有走索引。大多数情况会看到类似下面这样的输出- Sort (cost... sort method...) - Result (cost... rows...)一旦发现排序的rows特别大就可以确定瓶颈在去重本身。这时候要么减少参与去重的列要么在应用层分页处理要么用聚合改写。坚持这个排查习惯远比死记硬背几条优化口诀靠谱。6. 去重之后还想取最新数据ROW_NUMBER()和DISTINCT ON的进阶思路DISTINCT最大的遗憾是它只负责告诉你有哪些不同的值却无法帮你决定保留哪一行。但在真实业务里我们经常遇到的是更复杂的去重需求一组相同业务键的数据里只保留最新的一条。6.1 用ROW_NUMBER()按组取最新假设有一张商品价格变更记录表同一个商品有多次调价你想查每个商品当前最新的价格。DISTINCT做不到GROUP BY也不好写此时窗口函数是最顺手的工具SELECT product_id, price, effective_date FROM ( SELECT product_id, price, effective_date, ROW_NUMBER() OVER ( PARTITION BY product_id ORDER BY effective_date DESC ) AS rn FROM product_price_history ) t WHERE rn 1;这里的逻辑是先把每个商品的所有价格记录按生效时间倒序编号同一个商品的第一条就是最新的一条然后外层过滤rn 1。整个过程把去重和取最新两个需求在一条SQL里一次做完。窗口函数不会像DISTINCT那样只输出一行去重结果而是可以在每个分组内保留完整字段这是它最大的优势。6.2 PostgreSQL的DISTINCT ON如果你用的是PostgreSQL还有更简洁的专用语法DISTINCT ON。它能让你明确指定按哪些字段去重以及去重后按什么顺序取第一条SELECT DISTINCT ON (product_id) product_id, price, effective_date FROM product_price_history ORDER BY product_id, effective_date DESC;注意这里的写法顺序是有讲究的ORDER BY里必须以DISTINCT ON的字段打头然后才是决定取哪条的排序字段。上面这条语句会按product_id分组在每个组内按effective_date倒序取最早生效日期的记录——不对倒序取的是最新的一条。这种写法比嵌套子查询简洁不少但它是PostgreSQL的方言MySQL和SQL Server不认跨数据库开发时要小心。6.3 物理表存在完全重复行时怎么清理还有一种更头疼的场景表本身没有主键同一行数据被插入了多次导致完全重复。处理这种数据时先查出来看看规模SELECT key_col, count(*) FROM dirty_table GROUP BY key_col HAVING count(*) 1;确定有重复后常规做法是把不重复的数据捞进临时表删掉原表再插回去。PostgreSQL里还有一个基于ctid的物理删除技巧DELETE FROM dirty_table a USING dirty_table b WHERE a.ctid b.ctid AND a.key_col b.key_col;ctid是PostgreSQL里每行数据的物理位置标识a.ctid b.ctid表示保留物理位置更靠前的那一行删除后面的重复行。这个方法不用建临时表执行效率很高但第一次用的时候一定要先在测试环境验证并且提前备份数据——删数据这种事谨慎永远不过分。6.4 我的实际取舍经验把几种去重方式放在一起对比我的选择逻辑通常是这样需求类型推荐写法理由只列出不同的枚举值DISTINCT代码简洁语义清晰分组后做统计计算GROUP BY 聚合函数一边分组一边计算一次搞定确认某条件下是否存在记录EXISTS数据量大时性能优势明显每组保留最新一条或完整字段ROW_NUMBER() PARTITION BY通用性强各数据库都支持PostgreSQL专用快速去重DISTINCT ON写法简洁适合PG环境清理物理重复行临时表或ctid删除从源头解决脏数据这些年用下来的体会是DISTINCT本身没有原罪问题往往出在没搞清楚重复数据的生成路径和没想清楚去重后还要不要其他字段。前者决定你该不该用DISTINCT后者决定你该用DISTINCT还是窗口函数。顺手再分享一个习惯每次写完去重查询先用EXPLAIN看一遍执行计划再数据量级上做个粗略估算这样大部分坑都能在SQL上线之前被挡掉。