1. 为什么“去重”这件事在MySQL里总让人翻车
做业务查询的时候,只要表里出现了重复数据,几乎所有人第一反应都是加个DISTINCT。这个思路本身没错,但问题在于,DISTINCT作用于整行结果集,而不是你心里想的那一个字段。举个最常见的场景:一张订单表里同一个用户下了多笔单,你想按用户维度拉一份名单,结果SELECT DISTINCT user_id, order_no FROM orders出来的还是几十万行,因为order_no不一样,整行就不算重复。这时候你才意识到,标题里说的“根据指定字段去重”和“整行去重”根本是两码事。
这篇内容就是围绕这个差异展开的。我会把 MySQL 里按指定字段去重的几种主流写法拆开讲清楚:GROUP BY、窗口函数ROW_NUMBER()、DISTINCT配合子查询、以及NOT EXISTS反连接。每一种都有它适合的边界,选错了轻则结果不对,重则查询慢到拖垮数据库。适合已经会写基础 SQL、但在真实业务里被重复数据折磨过的开发者和数据分析同学看,也适合刚接手别人写的“能跑但看不懂”的 SQL 的人对照排查。
先说一个反直觉的结论:大多数“按字段去重”的需求,本质上不是去重,而是“每个分组挑一行”。你真正想要的是“每个 user_id 保留最新那条订单”或者“每个手机号保留最早注册的那条记录”,而不是简单地把重复值删掉。想清楚这一点,后面的方案选择就顺了。下面我从最容易踩的坑开始,一层层往下拆。
2. DISTINCT 的真实作用域:它到底在去什么
2.1 整行去重与单字段去重的本质区别
很多人对DISTINCT的误解来自它的语法位置——它写在SELECT后面,看起来像是修饰后面那个字段的。实际上它修饰的是整个结果集。SELECT DISTINCT a, b FROM t的语义是:把(a, b)这个组合当成一个整体,组合重复的才去掉。所以只要b有差异,a相同的行全部保留。
我用一个模拟场景说明。假设有一张user_login表记录登录日志:
| id | user_id | login_time | device |
|---|---|---|---|
| 1 | 1001 | 2024-03-01 09:00 | ios |
| 2 | 1001 | 2024-03-02 10:30 | android |
| 3 | 1002 | 2024-03-01 11:00 | ios |
| 4 | 1002 | 2024-03-03 08:15 | ios |
执行SELECT DISTINCT user_id FROM user_login,得到 1001 和 1002 两行,这是单字段去重,没问题。但一旦你为了“顺便把登录时间也带出来”写成SELECT DISTINCT user_id, login_time FROM user_login,结果还是 4 行,因为时间戳各不相同。这就是最典型的翻车点:你想按 user_id 去重,却因为多带了一个字段导致去重失效。
提示:判断
DISTINCT是否按你的预期工作,只需要问自己一句——我 SELECT 出来的这些列,组合起来是不是真的会重复?如果答案是否定的,那DISTINCT对你毫无帮助。
2.2 为什么不能“先 DISTINCT 再随便取一个字段”
有人会想,那我先SELECT DISTINCT user_id, login_time,再在外面套一层只取user_id不就行了?逻辑上确实能得到去重后的 user_id 列表,但这里有个隐蔽的问题:你丢掉的那个字段(login_time)是随机保留的。MySQL 在没有明确排序的情况下,返回哪一行是不确定的,今天跑出来是 3 月 1 日,明天可能变成 3 月 2 日。如果这个时间后续要参与业务判断(比如算用户首登时间),结果就不可复现。
这个坑我在早期项目里踩过。当时做一个活跃用户报表,用DISTINCT加子查询取用户最近登录设备,上线后发现同一批数据两次跑出来的设备分布对不上,排查了半天才定位到是“随机保留行”导致的。后来全部改成显式排序加窗口函数,问题才消失。所以记住一条经验:只要去重后还需要保留某个具体字段的值,就必须明确“保留哪一行”的规则,不能交给数据库随机决定。
2.3 DISTINCT 的性能代价在哪里
DISTINCT的执行方式通常是排序或哈希去重,这意味着它需要对结果集做一次额外的整理。当结果集很大、又没有合适索引时,这个开销会非常明显。尤其是SELECT DISTINCT后面跟了好几个字段,MySQL 需要按这些字段的组合做临时表或文件排序,内存不够就落磁盘。
实测下来,一张千万级的日志表,SELECT DISTINCT user_id, DATE(login_time) FROM user_login这种带函数的去重,基本都会走临时表,耗时是普通查询的好几倍。如果只是想要“有哪些 user_id”,老老实实SELECT DISTINCT user_id,让优化器只处理单列,效率会好很多。字段越少、类型越简单(整型优于字符串),去重越快。
3. GROUP BY 按指定字段去重:最经典也最容易写错
3.1 GROUP BY 的语义其实比 DISTINCT 更贴合需求
GROUP BY user_id的语义是“把 user_id 相同的行归成一组”,这天然就是按指定字段去重。而且它比DISTINCT多了一个能力:你可以在每组里做聚合,比如MAX(login_time)、COUNT(*)、MIN(id)。这就解决了上一节说的“保留哪一行”的问题——用聚合函数明确指定规则。
SELECT user_id, MAX(login_time) AS last_login FROM user_login GROUP BY user_id;这条语句的含义非常清晰:每个 user_id 一行,取该用户最晚的登录时间。结果确定、可复现,而且语义自解释。这也是我在绝大多数“按字段去重并保留某列”场景下的首选写法。
3.2 ONLY_FULL_GROUP_BY 模式下的经典报错
如果你写的是SELECT user_id, login_time FROM user_login GROUP BY user_id,在 MySQL 5.7 之后默认开启ONLY_FULL_GROUP_BY的情况下会直接报错:
ERROR 1055 (42000): Expression #2 of SELECT list is not in GROUP BY clause and contains nonaggregated column ...这个报错让很多人第一反应是去关掉sql_mode,我强烈不建议这么做。这个模式的存在恰恰是在保护你——它阻止你写出“login_time 到底取哪一行说不清”的模糊查询。正确的做法是把login_time包进聚合函数,或者把它加进GROUP BY。前者是“每组挑一个值”,后者是“按组合去重”,两者语义完全不同,想清楚你要哪个。
注意:线上库不要为了图省事关
ONLY_FULL_GROUP_BY。一旦关掉,历史 SQL 里潜藏的“随机取值”问题会集体暴露,排查成本远高于现在改一条语句。
3.3 用 GROUP BY 实现“每组取最新一行”的完整套路
单纯MAX(login_time)只能拿到时间,拿不到那一行对应的其他字段(比如 device)。想要“每个用户最新登录的那条完整记录”,常见做法是先用子查询求出每组的最新时间,再回表关联:
SELECT t1.* FROM user_login t1 INNER JOIN ( SELECT user_id, MAX(login_time) AS max_time FROM user_login GROUP BY user_id ) t2 ON t1.user_id = t2.user_id AND t1.login_time = t2.max_time;这个写法的逻辑是:子查询算出每个用户的“最新时间戳”,外层再用(user_id, login_time)这个组合回原表匹配。它成立的前提是同一用户同一时间不会出现两条记录。如果存在时间完全相同的重复行,这个 JOIN 会把它们都带出来,去重就失败了。所以用之前一定要确认业务上时间字段是否唯一,不唯一就得再加一个id做兜底排序。
这种“聚合子查询 + 回表”的写法在数据量中等时表现不错,但表一大,子查询的临时结果集和回表关联都会成为瓶颈。我一般会在(user_id, login_time)上建联合索引,让子查询走覆盖索引,回表时也能用上索引,实测能快一个数量级。
4. 窗口函数 ROW_NUMBER:现代 MySQL 里最干净的去重方案
4.1 ROW_NUMBER 的分组编号思路
MySQL 8.0 引入窗口函数之后,“按指定字段去重并保留某一行”终于有了一个语义最清晰的写法。核心是ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY login_time DESC):按 user_id 分组,组内按登录时间倒序编号,最新那条编号为 1。外层只要筛rn = 1,就精确拿到了每组的第一行。
SELECT * FROM ( SELECT *, ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY login_time DESC) AS rn FROM user_login ) t WHERE rn = 1;对比上一节的 JOIN 写法,这个方案的优势很明显:不需要回表,不需要担心时间戳重复(即使两条记录时间一样,ROW_NUMBER也会强行编出 1 和 2,只有一条能留下),而且排序规则完全由你控制。想保留最早的就ORDER BY login_time ASC,想按 id 兜底就ORDER BY login_time DESC, id DESC。
4.2 PARTITION BY 多字段组合去重的写法
“根据指定字段去重”里的“指定字段”经常不止一个。比如要按(user_id, device)组合去重,取每个组合下最新的一条记录,只需要把PARTITION BY后面写成多列:
SELECT * FROM ( SELECT *, ROW_NUMBER() OVER ( PARTITION BY user_id, device ORDER BY login_time DESC ) AS rn FROM user_login ) t WHERE rn = 1;这等价于“按 user_id 和 device 的组合去重”。理解PARTITION BY的关键是把它当成“分组”,组内独立编号,每组都从 1 开始。所以多字段去重对它来说就是多写几个列名,没有任何额外复杂度,这也是它比GROUP BY加聚合更灵活的地方。
4.3 窗口函数的性能边界与索引配合
窗口函数不是银弹。它的执行需要对数据排序,PARTITION BY和ORDER BY涉及的列如果没有索引支撑,MySQL 会做一次全表扫描加文件排序。数据量上到百万级、千万级时,这个排序开销可能比 JOIN 方案还大。
我的经验是:给PARTITION BY的列和ORDER BY的列建联合索引,顺序按“分区列在前、排序列在后”。比如上面的例子建(user_id, login_time)联合索引,优化器就有机会利用索引的有序性减少排序。另外,如果外层还有WHERE过滤条件,尽量把能下推的过滤条件写进子查询里,减少参与排序的行数。窗口函数本身没法在WHERE里直接用(因为WHERE执行早于窗口计算),必须套一层子查询,这一点写的时候要习惯。
5. 反连接与子查询去重:老版本 MySQL 的务实选择
5.1 NOT EXISTS 实现“保留每组最小 id”
在 MySQL 8.0 普及之前,或者在一些不允许用窗口函数的环境里,NOT EXISTS是“每组挑一行”的经典写法。思路是:对于每一行,检查是否存在同一分组下“更优”的另一行,如果不存在,说明自己就是最优的那行。
SELECT t1.* FROM user_login t1 WHERE NOT EXISTS ( SELECT 1 FROM user_login t2 WHERE t2.user_id = t1.user_id AND t2.login_time > t1.login_time );这段 SQL 读起来就是一句话:保留那些“找不到比自己登录更晚的同用户记录”的行,也就是每个用户的最新记录。语义非常直观,而且不依赖窗口函数,兼容性好。
5.2 关联子查询的执行代价与优化方向
NOT EXISTS的问题在于它是关联子查询,理论上外层每一行都要触发一次内层查询。如果内层查询能用上(user_id, login_time)索引,每次查找是索引定位,整体还能接受;如果没有索引,那就是外层行数乘以内层扫描,复杂度爆炸。
我做过一个对比测试,同一张 500 万行的表,NOT EXISTS写法在有联合索引时耗时约 2 秒,去掉索引后直接飙到几十秒。所以用这个方案的前提很明确:分组字段和比较字段上必须有合适的索引。另外,如果每组数据量很小(比如每个用户就几条记录),这个方案的效率其实相当不错,不必迷信窗口函数。
5.3 几种去重方案的横向对比
把前面讲的方案放在一起对比,选型时心里就有数了:
| 方案 | 适用场景 | 能否指定保留行 | 性能特点 | 版本要求 |
|---|---|---|---|---|
| DISTINCT | 整行去重、单字段去重 | 否,随机 | 小结果集快,大结果集需排序 | 全版本 |
| GROUP BY + 聚合 | 每组取聚合值 | 只能取聚合结果 | 有索引时快 | 全版本 |
| GROUP BY 子查询 + JOIN | 每组取完整行 | 是,但依赖字段唯一 | 中等,回表有开销 | 全版本 |
| ROW_NUMBER 窗口函数 | 每组取完整行、多字段组合 | 是,规则完全可控 | 需排序,索引可优化 | 8.0+ |
| NOT EXISTS 反连接 | 每组取完整行 | 是 | 依赖索引,无索引很慢 | 全版本 |
选型的核心判断就两条:要不要保留完整行、数据库版本支不支持窗口函数。要保留完整行且版本够,优先ROW_NUMBER;版本不够,用NOT EXISTS或聚合子查询加 JOIN;只是要个去重后的字段列表,DISTINCT或GROUP BY足够。
6. 真实业务里的去重陷阱与排查链路
6.1 空值参与去重时的意外结果
NULL在 SQL 里是个特殊存在。GROUP BY会把所有NULL归成一组,DISTINCT也会把多个NULL当成一个,但NOT EXISTS里的等值比较遇到NULL会返回未知,导致逻辑失效。我遇到过一个案例:按phone字段去重用户,结果所有phone为NULL的记录被NOT EXISTS全部保留了下来,因为NULL = NULL不成立,子查询永远查不到“更优行”。
处理办法是在比较前用COALESCE或IFNULL把NULL转成哨兵值,或者在业务层保证去重字段非空。如果去重字段允许为空,务必单独验证空值场景的结果,别只看有值的数据。
6.2 字符集与排序规则导致的“假重复”
还有一种隐蔽的坑:两个看起来一样的字符串,因为字符集或排序规则不同,被当成不同的值。比如utf8mb4_general_ci是大小写不敏感的,'ABC'和'abc'在GROUP BY时会被归为一组;而utf8mb4_bin是二进制比较,两者就是两组。如果你的去重结果和预期对不上,先查一下字段的collation。
排查方法很简单,SHOW FULL COLUMNS FROM 表名看目标字段的Collation,再用SELECT COUNT(DISTINCT 字段)和SELECT COUNT(*) FROM (SELECT 字段 FROM 表 GROUP BY 字段) t对比数量,两者不一致往往就是排序规则或空值在作怪。
6.3 一次完整的排查过程复盘
说一个我实际处理过的排查链路。现象是:按order_no去重统计订单数,结果比业务系统显示的多了几百条。第一步,确认去重字段有没有空值——查出来order_no为NULL的有 300 多条,这些在GROUP BY里被归成一组,但业务上它们根本不该算订单。第二步,检查是否有前后空格,用SELECT COUNT(*) FROM orders WHERE order_no != TRIM(order_no)查出几十条带空格的脏数据,'A001'和'A001 '被当成两个值。第三步,确认字符集一致后,把空值和空格问题在数据清洗层解决,再去重数量就对上了。
这个链路说明一件事:去重结果不对,先别怀疑 SQL 写法,先怀疑数据本身。空值、空格、大小写、全半角,这四类脏数据能解释大部分“去重不干净”的问题。养成先SELECT抽样看原始数据的习惯,比反复改 SQL 高效得多。
7. 去重之后还要做什么:删除重复数据的稳妥姿势
7.1 先查后删,永远不要直接 DELETE
查询去重和物理删除重复数据是两件事。很多人查出去重结果后,直接写DELETE FROM t WHERE id NOT IN (SELECT MIN(id) FROM t GROUP BY user_id),结果要么报错(MySQL 不允许在子查询里引用正在删除的表),要么误删。稳妥的做法分三步:先用同样的条件SELECT出“要保留的 id 集合”,人工抽样核对;再把它落到临时表;最后用临时表做关联删除。
CREATE TABLE keep_ids AS SELECT MIN(id) AS id FROM user_login GROUP BY user_id; DELETE FROM user_login WHERE id NOT IN (SELECT id FROM keep_ids);用临时表的好处是可回溯、可复核,删错了还能从备份恢复。直接一条 DELETE 下去,出问题就只能靠 binlog 捞数据了。
7.2 大表删除要分批,避免长事务锁表
千万级表上执行一条大 DELETE,会生成巨大的 undo log,锁住大量行,还可能触发主从延迟。我的做法是按主键范围分批删,每批几千到一万行,批间稍作停顿:
DELETE FROM user_login WHERE id IN ( SELECT id FROM ( SELECT id FROM user_login WHERE id NOT IN (SELECT id FROM keep_ids) LIMIT 5000 ) tmp );循环执行直到影响行数为 0。这样每次事务都很短,对线上影响可控。删之前记得确认keep_ids临时表是最新的,别拿旧数据删新记录。
7.3 用唯一索引从源头堵住重复
去重做得再熟,也不如从源头不让重复产生。如果业务上某个字段组合本就应该唯一,直接加唯一索引,让数据库帮你兜底:
ALTER TABLE user_login ADD UNIQUE KEY uk_user_device (user_id, device);加之前先用第 6 节的排查方法确认存量数据没有冲突,否则建索引会失败。加之后,重复插入会直接报错,应用层捕获后做幂等处理即可。这比每次查询都去重、定期跑脚本清理要省心得多。我现在的习惯是:凡是明确“一个组合只能有一条”的业务表,建表时就把唯一索引带上,后面能省掉大量去重逻辑。
8. 几个我反复用到的实操心得
关于COUNT(DISTINCT 字段)和COUNT(*)配合子查询的选择:前者适合快速统计“有多少个不同的值”,后者适合需要同时看总数和去重数的场景。COUNT(DISTINCT a, b)在 MySQL 里是支持的,表示按组合去重计数,很多人不知道这个用法,白白多套一层子查询。
关于执行计划:任何去重 SQL 上线前,务必EXPLAIN看一眼。重点看type是不是ALL(全表扫描)、Extra里有没有Using temporary和Using filesort。有临时表和文件排序不一定是错,但要知道它发生在哪一步,数据量涨上去会不会失控。
关于结果可复现:只要去重后保留了具体字段值,排序规则就必须写死。ROW_NUMBER的ORDER BY、GROUP BY配合的MAX/MIN、NOT EXISTS里的比较条件,都是在定义“保留哪一行”。规则不明确,今天对明天错,这种问题最难排查。
关于测试数据:验证去重 SQL 时,一定要构造包含空值、重复时间戳、大小写差异、前后空格的脏数据。只用干净数据测出来的“正确”,到了生产环境大概率翻车。我自己维护了一套几十行的边界测试数据,每次写去重逻辑都拿它跑一遍,省了很多事后救火的时间。