开头先讲一件我上周实际处理的事一个运营统计需求要算某活动页的近七天访问用户数。表里user_id字段明明加了索引第一版 SQL 写得也很顺手SELECT DISTINCT user_id FROM page_visit WHERE visit_date CURRENT_DATE - 7;跑出来 108 万行运营拿去一对比发现比埋点后台的数值多出将近一倍。我第一反应是埋点有脏数据查了半小时才发现问题根本不是 DISTINCT 不会去重而是这个字段里同一个人的 ID 既有数字10086又有字符串10086还有带不可见空格的 10086。DISTINCT 老老实实把三种看起来是一个人的数据当成了三种不同的值。这其实就是大多数人对DISTINCT的误解集中点它确实在做去重但它的去重规则比很多人想象的严格得多。这篇内容我就把 SQL 里DISTINCT的原理、用法、注意事项和底层执行逻辑一次性讲清楚适合刚学 SQL 的新人也适合写过一两年 SQL 但被奇怪重复数据坑过的同学。1. DISTINCT的去重真相它比较的是整条投影行不是某列1.1 一张表看明白两行数据什么时候算重复DISTINCT的作用范围不是你关心的那一列而是SELECT关键字后面出现的所有列组合。光说概念不好理解看一个具体例子。假设有一张订单表CREATE TABLE orders ( order_id INT, customer_id INT, product_name VARCHAR(50) ); INSERT INTO orders VALUES (1, 101, 键盘), (2, 101, 键盘), (3, 102, 鼠标), (4, 103, 键盘);执行SELECT DISTINCT customer_id, product_name FROM orders;结果是什么样的customer_id | product_name ------------------------- 101 | 键盘 102 | 鼠标 103 | 键盘customer_id 101虽然出现了两次但因为两次的product_name都是键盘组合后的投影行完全一致只保留一行。这才是 DISTINCT 做判断的真实粒度它先把 SELECT 列表里的每一行组合成一个比较串再比较这些串是否完全相同。1.2 只针对某一列去重是理解DISTINCT最大的误区很多人写SELECT DISTINCT a, b FROM t时心里想的是对 a 去重b 随便拿一条。这跟 DISTINCT 的实际语义完全是两回事。我们用同一张订单表演示一下这个误区SELECT DISTINCT customer_id, product_name FROM orders WHERE customer_id 101;结果customer_id | product_name ------------------------- 101 | 键盘因为 101 的两条记录 product_name 都是键盘所以这里看不出差异。但如果数据是这样的(1, 101, 键盘), (2, 101, 鼠标)那SELECT DISTINCT customer_id, product_name FROM orders WHERE customer_id 101的结果会同时返回两行customer_id | product_name ------------------------- 101 | 键盘 101 | 鼠标这时候很多人的第一反应是DISTINCT 失效了其实不是失效是 DISTINCT 根本没有只对 customer_id 去重这种语义。它永远是对整条投影行做完全匹配。想按 a 分组再取任意一条应该用GROUP BY a配合MIN/MAX之类的聚合函数或者用窗口函数ROW_NUMBER()这些在后面的章节会说。1.3 别把DISTINCT当排序用DISTINCT不是ORDER BY。它不会改变行之间的先后顺序也不保证去重后结果按某个字段有序。在很多数据库里DISTINCT 的底层实现会走排序或哈希所以你在结果里看到的顺序可能恰好是排过序的但这只是实现细节不是 SQL 语义承诺。如果查完去重后必须按某个字段排序请显式加ORDER BYSELECT DISTINCT customer_id FROM orders ORDER BY customer_id;这里有一个非常容易踩的坑SELECT DISTINCT customer_id FROM t ORDER BY customer_namecustomer_name 不在 SELECT 列表里在 MySQL 的ONLY_FULL_GROUP_BY模式下会直接保错在 SQL Server 里也会报ORDER BY 项必须出现在选择列表中。原因是 DISTINCT 会把结果压缩成 customer_id 的唯一值集合而排序时已经没有 customer_name 可以用了。这不是数据库抽风是语义上根本排不了。2. 从SELECT DISTINCT到COUNT(DISTINCT)细数它的常规打开方式2.1 单一列去重最简单的场景这是最基础、也是用得最多的形式SELECT DISTINCT department FROM employees;返回所有不重复的部门名。这种查询在数据体量不大、目标列有索引时效率很高但如果表很大、列上又没有合适的索引执行计划里往往会出现Using temporary或filesort。这个后面讲性能时专门展开。2.2 多列组合去重唯一组合里的应用多列去重常用于判断哪些组合是真实存在的。举一个真实业务例子一个用户可能对应多个角色一个角色可能对应多个用户想知道用户-角色一共有多少种有效组合SELECT DISTINCT user_id, role_id FROM user_role_map;这种查询在权限系统、标签系统里非常常见本质是查出这张关联表的唯一组合全集。2.3 COUNT(DISTINCT)的计数逻辑统计去重后的数量是DISTINCT在报表场景里最常用的一种打开方式SELECT COUNT(DISTINCT user_id) AS active_users FROM user_login_log;这里有两个细节必须说明第一COUNT(DISTINCT col)会忽略NULL。如果user_login_log.user_id允许为空那么这条 SQL 统计出来的活跃用户数不会包含任何NULL行。在很多数据清洗不严格的表里缺失 ID 的日志行可能被静默忽略这在对比不同来源数据时容易产生差异。第二COUNT(DISTINCT col1, col2)这种写法在标准 SQL 里是支持的它统计的是(col1, col2)组合去重后的行数。但 MySQL 从 8.0.29 之前的版本对多列COUNT(DISTINCT)的支持存在一些优化不足的情况SQL Server 对COUNT(DISTINCT col1, col2)的支持则要看版本老版本直接报语法错误。想在 SQL Server 里实现同样效果更稳妥的写法是先子查询去重再计数SELECT COUNT(*) FROM ( SELECT DISTINCT user_id, login_date FROM user_login_log ) t;第三COUNT(DISTINCT ...)在数据量达到千万级、亿级时代价是很大的。它必须对所有唯一值做完整去重后才能计数没法凭空预估。所以日活这类指标在超大表上要么依赖预聚合表如按天汇总后的结果表要么接受一定的统计延迟。2.4 不同数据库里的DISTINCT 变体标准 SQL 的 DISTINCT 实现流程大同小异但不同数据库会加一些自己的变体。PostgreSQL 提供了DISTINCT ON (expr)它允许你指定按哪些字段去重同时从每组里返回自己想要的那一行这在取每组最新一条场景里非常好用SELECT DISTINCT ON (customer_id) customer_id, product_name, order_date FROM orders ORDER BY customer_id, order_date DESC;这条 SQL 的意思是按 customer_id 分组每组取ORDER BY排序后的第一行也就是每个客户最近一次买的商品。注意这里有个硬性要求ORDER BY的开头部分必须包含DISTINCT ON里的表达式否则数据库无法决定每组的第一行是哪个。MySQL 没有DISTINCT ON但你可以通过窗口函数模拟这个放到第四节讲。还有一个容易混淆的兄弟UNION本身自带去重语义。SELECT a FROM t1 UNION SELECT a FROM t2会把两张表的 a 合并后去重如果不想去重要用UNION ALL。很多人习惯写UNION而不知道它默默执行了一次 DISTINCT在数据量大时白白浪费一轮排序去重建议明确用UNION ALL。2.5 与WHERE、ORDER BY、LIMIT的搭配顺序DISTINCT与WHERE、ORDER BY、LIMIT一起使用时执行顺序大概是先WHERE过滤再对过滤后的结果做 DISTINCT 去重然后ORDER BY排序最后LIMIT取前几条。SELECT DISTINCT user_id FROM user_login_log WHERE login_date 2024-01-01 ORDER BY user_id LIMIT 10;这条 SQL 的逻辑是先筛出 2024 年之后的登录记录再取唯一 user_id排序后输出前 10 个。理解这个顺序对你排查为什么我 LIMIT 之后数量还是不对这类问题很有帮助——LIMIT是在去重结束后才执行的所以结果里的10 条是 10 个唯一用户而不是 10 行原始日志。3. 查询结果里多出来的重复大小写、空白、NULL与隐式类型转换这一节是全文最有实战价值的部分。很多人问为什么我用了 DISTINCT 还是有重复十有八九原因落在下面几个分类里。3.1 NULL在去重里算一个值吗算而且所有 NULL 在去重时被视为同一个值。执行SELECT DISTINCT user_id FROM user_login_log;如果表里有多行user_id NULL最终结果里只会出现一个NULL。这在逻辑上是合理的数据库把 NULL 当作一个特殊的未知值未知值和未知值在去重时被视为相等统一保留一行。但这里有一个隐蔽的坑如果你在 DISTINCT 后的结果集里做进一步处理比如把结果导出、逐行判断这行是 NULL 就跳过是没问题的但如果用WHERE user_id NULL去匹配这个结果那永远匹配不上。NULL 的等值判断必须用IS NULL这是 SQL 基础但也是最容易在大规模数据处理脚本里出问题的点。3.2 大小写敏感度由排序规则决定DISTINCT判断两个字符串是否相同时是否区分大小写由数据库的排序规则collation决定。不同数据库、不同版本、不同默认配置行为差异很大。拿最常见的场景举例。MySQL 里如果你建表时用的是utf8mb4_general_ci或utf8mb4_0900_ai_ci这类_ci结尾的排序规则那么SELECT DISTINCT name FROM users;如果表里有Alice和alice结果只会出现一个。因为_ci表示 case-insensitive大小写不敏感Alice 和 alice 被视为同一个字符串。但在 PostgreSQL 里默认排序规则通常是区分大小写的同样的查询会把Alice和alice当成两个不同的值全都会返回。SQL Server 则取决于库或列的 collation 设置SQL_Latin1_General_CP1_CI_AS不敏感SQL_Latin1_General_CP1_CS_AS敏感。所以你在排查DISTINCT 去重后怎么还有重复时先确认数据本身的字符是否只有大小写差异。如果业务上确实要把Alice和alice视为同一人有两个方向一是把列改成大小写不敏感的排序规则二是查询时主动归一化SELECT DISTINCT LOWER(name) FROM users;注意用LOWER后去重结果里输出的是统一小写的值原数据的大小写样式就丢了。3.3 空格和不可见字符数据清洗的头号敌人我刚开头说的那个案例就是典型的不可见字符问题。10086和 10086在数据库看来是两个完全不同的字符串——一个长度为 5一个长度为 6。当你从不同系统导入数据、或者人工录入手误时很容易混进空格、全角空格、\t、\n等不可见字符。排查方法很直接看看去重前后记录数差距再把疑似重复的字段用HEX()或者LENGTH()比对。比如SELECT LENGTH(user_id), HEX(user_id), COUNT(*) FROM user_login_log WHERE user_id IN (10086, 10086) GROUP BY LENGTH(user_id), HEX(user_id);一旦确认是空白类字符差异数据清洗时统一使用TRIM()、REPLACE()归一化后再做 DISTINCTSELECT DISTINCT TRIM(user_id) FROM user_login_log;但TRIM()只能去掉行首和行尾的空格数据中间的多个连续空格还是洗不掉必要时配合REPLACE(user_id, , )或者正则函数处理。3.4 隐式类型转换造成的看起来重复数字和字符串是另一个重灾区。假设user_id是 VARCHAR 类型里面同时存了数字10086和字符串10086。在 MySQL 里因为它会把字符串和数字比较时做隐式转换所以你直接SELECT DISTINCT user_id时这两个值通常会被视为相同因为比较时10086被转换成了数字10086。但如果字段里同时存在10086、 10086、10086 、010086情况就复杂了。字符串比较和数字转换的规则在不同数据库里不完全一致很可能出现用等值查询能查到同一行但 DISTINCT 后却输出了多个的诡异现象。我给你的建议是做去重之前先强制类型统一。用CAST(user_id AS CHAR)把所有值统一成字符串后再去重或者反过来统一成数字CAST(user_id AS UNSIGNED)再去重。这样至少保证比较基准一致不会因为隐式转换玩双标。3.5 排查口诀先看数据源再看排序规则最后看函数如果你遇到DISTINCT 没去干净按照下面顺序排查基本能覆盖 95% 的情况先确认 SELECT 列表里到底有几个字段。多列 DISTINCT 的结果是组合去重不是你心里想的只对某一列去重。再看数据本身。用LENGTH、HEX检查是否有不可见字符用LOWER/UPPER检查是否只有大小写差异。最后看排序规则和隐式转换。确认数据库当前 collation 对大小写是否敏感确认字段类型是否混入了数字和字符串。这几步走完绝大多数假重复都能定位到根因。4. DISTINCT的性能账单什么时候它不再是首选4.1 EXPLAIN视角DISTINCT在底层怎么干活DISTINCT 不是千篇一律的实现。MySQL 里一条SELECT DISTINCT col FROM t的典型执行计划会出现Using filesort或Using temporary。原因是优化器通常选择排序去重把目标列的数据全部取出排序后相邻值比较发现相同的就跳过。数据量大时排序这一步要写临时文件代价很高。还有一种实现方式是哈希去重把每一行投影结果计算成哈希值用哈希表记录是否出现过。这种方式内存消耗大但在某些场景下比排序快。三种主流数据库MySQL、PostgreSQL、SQL Server的优化器会基于数据量、可用内存、索引情况自动选择。你不需要记住所有优化细节但要知道一个关键结论DISTINCT 的成本差不多等于一次对所有目标列的全量扫描 排序/哈希它不是免费的。4.2 DISTINCT vs GROUP BY没聚合时谁更快经典问题SELECT DISTINCT a, b FROM t和SELECT a, b FROM t GROUP BY a, b在没有聚合函数时语义相同那性能有差别吗从执行计划角度两者通常没有本质区别甚至优化器可能生成完全相同的执行计划。因为 GROUP BY 在无聚合函数时本质上也是按 a、b 分组后每组取一行去重逻辑一样。但有一个微妙的差异GROUP BY 如果你配合HAVING COUNT(*) 1之类的条件可以做更多统计分析纯粹去重场景下两者选哪个都行我习惯用 GROUP BY因为它的语义更明确到了后面要加聚合条件比如只保留出现次数大于 1 的)时不用改写 SQL。注意别相信网上GROUP BY 一定比 DISTINCT 快这种话。在一百多万行的表上我两种都测过执行时间几乎一致真正的差距来自索引、数据分布和排序字段设计而不是那个关键字本身更高级。4.3 用EXISTS做半连接去重有一些去重场景表面上可以用 DISTINCT但用EXISTS改写后性能好得多。典型场景是查在 A 表出现过、且在 B 表有匹配记录的 A 表字段列表。最开始的写法往往是这样SELECT DISTINCT a.customer_id FROM orders a JOIN customers b ON a.customer_id b.customer_id;当 orders 表很大、customers 表不大的时候这条 JOIN 会先把两张表连接出大量中间行再做 DISTINCT 去重中间结果可能是最终结果的几十倍。用 EXISTS 改写SELECT a.customer_id FROM orders a WHERE EXISTS ( SELECT 1 FROM customers b WHERE b.customer_id a.customer_id );EXISTS 在右边匹配到第一条记录时就会短路返回不再继续扫描整体扫描行数通常远小于 JOIN DISTINCT。这就是我用 ORDER 表关联客户表时常用的优化手段。4.4 窗口函数ROW_NUMBER去掉每组取最新一条的痛点前面提到 PostgreSQL 的DISTINCT ON很好用。如果你在 MySQL 或 SQL Server 里想按客户分组取每个客户最新一笔订单用 DISTINCT 完全做不了因为 DISTINCT 只能去重不能保留某一条。这时候用ROW_NUMBER()窗口函数WITH ranked AS ( SELECT customer_id, product_name, order_date, ROW_NUMBER() OVER (PARTITION BY customer_id ORDER BY order_date DESC) AS rn FROM orders ) SELECT customer_id, product_name, order_date FROM ranked WHERE rn 1;PARTITION BY customer_id表示按客户分组ORDER BY order_date DESC表示每组按日期倒序排rn 1取出每组第一条。这种写法在任何支持窗口函数的数据库里都能用也比 DISTINCT 子查询关联的方案清晰得多。4.5 索引优化与COUNT(DISTINCT)的代价想提升 DISTINCT 查询性能最直接的办法是让目标列有合适的索引。比如SELECT DISTINCT department FROM employees如果 department 上有索引数据库可以走索引扫描直接获得有序的唯一值列表跳过排序和临时表速度会快很多。从执行计划看这时Using index for group-by或者类似的分支会出现说明优化器利用了索引本身的有序性。COUNT(DISTINCT) 的代价更值得单独说。它面对大表时必须把所有非 NULL 且唯一的取值全部找出来再计数索引的作用往往只是把扫描全表变成扫描索引但去重这一步依然要完整做。我测过一个 2000 万行的日志表COUNT(DISTINCT user_id)即便走了索引也要 20 多秒后来改成凌晨跑批预聚合、前台只查汇总表响应时间从 20 多秒降到几十毫秒这才符合业务预期。结论DISTINCT 适合数据量可控、偶尔跑一次的场景频繁查询、超大表、实时报表优先走预聚合或数仓分层别把 DISTINCT 当成长效方案。5. 我实际踩过的坑从重复订单到日活统计的排查复盘5.1 重复订单定位GROUP BY HAVING才是主角遇到怀疑有重复订单的排查需求我的第一选择往往不是 DISTINCT而是GROUP BY ... HAVING COUNT(*) 1SELECT order_no, COUNT(*) AS cnt FROM orders GROUP BY order_no HAVING COUNT(*) 1;这条 SQL 直接告诉你哪些单号重复了、各重复了几次。如果表很大可以加LIMIT 100先看一眼。这是数据质量检查和面试里最常用的去重定位手段和 DISTINCT 的区别在于DISTINCT 只告诉你有哪些唯一值不告诉你哪些值重复了、重复了几次。想找出“重复的那些”必须用 GROUP BY HAVING COUNT。5.2 删除重复记录保留一条MIN(id)的经典写法确认重复后真要动手清理往往需要每组保一条、删其余。以订单表为例同样的 order_no 重复了三行想保留 id 最小的一行DELETE t1 FROM orders t1 INNER JOIN ( SELECT order_no, MIN(id) AS keep_id FROM orders GROUP BY order_no, customer_id HAVING COUNT(*) 1 ) t2 ON t1.order_no t2.order_no AND t1.id t2.keep_id;这属于先用 GROUP BY 定位再用 JOIN 删除的组合拳。在执行删除前强烈建议先把DELETE改成SELECT *确认影响行数再包一层事务删完检查无误后提交。这是数据库操作的通用安全习惯跟 DISTINCT 本身关系不大但清理重复数据这个场景里真的见过太多人一把梭哈删到只剩零行的悲剧。5.3 日活与新增用户统计中的DISTINCT日活统计最朴素的做法就是COUNT(DISTINCT user_id)。但很多报表口径的差异根因就在 DISTINCT 的组合键上。比如统计按天维度下每天有多少用户登录正确组合键是(login_date, user_id)SELECT login_date, COUNT(DISTINCT user_id) AS daily_active_users FROM user_login_log GROUP BY login_date;如果把login_date换成了时间戳带时分秒或者直接在user_id上 DISTINCT口径就全乱了。更隐蔽的是统计连续两天活跃用户时要用两个日期的INTERSECT或自连接而不是把两天的 user_id 拼一起 DISTINCT——拼一起的话用户只要两天里活跃过一天就会命中根本不符合两天都活跃这个约束。5.4 面试追问为什么你的DISTINCT不生效面试现场常会给你一张表里面有重复行让你用 DISTINCT 去重然后追问我的查询是这样的为什么还有重复这时候一定要先说清楚 DISTINCT 是整行比较。更进一步的加分回答是把SELECT DISTINCT name FROM t和SELECT DISTINCT name, age FROM t区分开指出前者只看 name后者看 name 和 age 的组合组合中其他列的差异会保留多余行。还可以补一句SELECT DISTINCT *只有在整行所有列完全相同时才去重这往往不是业务想要的重复业务里的重复通常是某一列或某几列相同其余列不同这用 DISTINCT 根本处理不了正确姿势是 GROUP BY 目标列 聚合函数取代表值。能讲清这个层次比死记DISTINCT 去重有价值得多。另一个高频追问是 DISTINCT 和 GROUP BY 的区别。核心回答就三句话一GROUP BY 可以配合聚合函数DISTINCT 不行二在无聚合函数且投影列完全一致时两者语义几乎等价但 GROUP BY 语义更适合扩展统计需求三DISTINCT 的语义集中在消除重复行GROUP BY 的语义是分组后再处理表达意图不同。5.5 我在生产环境观察到的两个DISTINCT优化案例第一个案例是用户标签去重。业务方每天拉取全量标签关系中每个用户的标签列表原始 SQL 是SELECT DISTINCT user_id, tag_id FROM tag_map。tag_map 表接近 800 万行每天全量跑一次要 40 多秒。后来发现这张表的变更很小改成了每日只处理增量 汇总结果 persist 到结果表全量 DISTINCT 不再每天发生查询时间直接降到 1 秒内。这不算 SQL 写法优化是架构层面避免无意义全量计算但思路值得参考。第二个案例是导出去重。运营要从订单表导出每个客户最近一次购买的商品一开始写的是SELECT customer_id, product_name FROM orders GROUP BY customer_id, product_name结果一个客户多次购买不同商品时会导出多行。这就是把 GROUP BY 当 DISTINCT 用的典型误伤。后来改成子查询 关联SELECT o.customer_id, o.product_name FROM orders o INNER JOIN ( SELECT customer_id, MAX(order_date) AS max_date FROM orders GROUP BY customer_id ) m ON o.customer_id m.customer_id AND o.order_date m.max_date;数据立刻变成每客户一行最近购买记录。虽然这个写法在某些边界条件下一个客户在同一个最大日期买了两次会多出几行但业务上已经可接受。如果要绝对精确就得引入窗口函数 ROW_NUMBER 方案把多行都标号后只取 rn1。DISTINCT 本身并不复杂复杂的是你把它用在什么场景、对它的预期是否合理。一个小技巧收尾写任何去重 SQL 前先在心里回答三个问题——我要对什么字段去重这些字段里有没有 NULL 和不可见字符这个查询多久跑一次、数据多少行。这三个问题答清楚了DISTINCT 基本不会给你挖坑答不清楚它就会用各种意外重复来教你怎么长记性。