MySQL中的数据去重,该用DISTINCT还是GROUP BY?

作者:默语佬

CSDN技术博主

原创文章,转载请注明出处

前言

上周在做代码Review时,看到一段让我印象深刻的SQL查询:

sql 复制代码
SELECT DISTINCT user_id FROM orders WHERE status = 'completed';

我建议改成GROUP BY,理由是"性能更好"。结果小伙伴反问了一句:"这不都是去重吗?DISTINCT写起来更简单,为啥要用GROUP BY?"

这个问题让我意识到,很多开发者对DISTINCT和GROUP BY的理解还停留在表面。他们知道两者都能"去重",但不清楚背后的执行原理、性能差异、以及各自适用的场景。

作为一名在数据库优化领域深耕多年的架构师,我见过太多因为误用DISTINCT或GROUP BY导致的性能问题:慢查询、索引失效、额外的排序开销。今天,我将从SQL执行原理、性能分析、实战场景三个维度,深度剖析DISTINCT与GROUP BY的本质差异,帮助大家写出更高效的SQL。


目录

  1. 去重操作的本质:从执行计划看原理
  2. DISTINCT详解:简单高效的去重利器
  3. [GROUP BY剖析:强大的分组聚合引擎](#GROUP BY剖析:强大的分组聚合引擎)
  4. 性能对决:五个维度的深度分析
  5. 实战场景:如何选择正确的去重方案
  6. 性能优化:索引与执行计划调优
  7. 踩坑经验与最佳实践

去重操作的本质:从执行计划看原理

SQL去重的底层实现

很多人认为DISTINCT和GROUP BY是完全不同的两个特性,但实际上,在MySQL的执行层面,它们使用了相似的去重算法。让我们通过执行计划来理解这个本质:

我的深度理解:很多开发者误以为DISTINCT是一个"神奇的关键字",能瞬间去重。实际上,去重是一个计算密集型操作,需要比较每一行数据。MySQL的优化器会根据是否有索引、结果集大小等因素,选择不同的执行策略。理解这个底层原理,是写出高性能SQL的前提。

DISTINCT与GROUP BY的执行计划对比

让我们通过一个实验来验证两者的相似性:

sql 复制代码
-- 准备测试表
CREATE TABLE test_dedup (
  id INT PRIMARY KEY AUTO_INCREMENT,
  category VARCHAR(50),
  city VARCHAR(50),
  amount DECIMAL(10,2),
  INDEX idx_category (category),
  INDEX idx_city (city)
) ENGINE=InnoDB;

-- 插入10万条测试数据
INSERT INTO test_dedup (category, city, amount)
SELECT 
  CONCAT('Category', FLOOR(1 + RAND() * 100)),
  CONCAT('City', FLOOR(1 + RAND() * 50)),
  ROUND(RAND() * 1000, 2)
FROM 
  (SELECT 1 UNION SELECT 2 UNION SELECT 3 /* ... 生成足够行数 */) t1,
  (SELECT 1 UNION SELECT 2 UNION SELECT 3 /* ... */) t2;

对比实验1:有索引的列

sql 复制代码
-- DISTINCT查询
EXPLAIN SELECT DISTINCT category FROM test_dedup;

-- GROUP BY查询
EXPLAIN SELECT category FROM test_dedup GROUP BY category;

执行计划结果(MySQL 8.0)

字段 DISTINCT GROUP BY 说明
type index index 都使用索引扫描
key idx_category idx_category 都使用同一索引
Extra Using index for group-by (scanning) Using index for group-by (scanning) 完全相同!

关键发现:在有索引的情况下,DISTINCT和GROUP BY使用完全相同的执行计划!MySQL优化器将DISTINCT内部转换为GROUP BY执行。

对比实验2:无索引的列

sql 复制代码
-- DISTINCT查询(无索引列)
EXPLAIN SELECT DISTINCT amount FROM test_dedup;

-- GROUP BY查询(无索引列)
EXPLAIN SELECT amount FROM test_dedup GROUP BY amount;

执行计划结果(MySQL 5.7)

字段 DISTINCT GROUP BY 差异分析
type ALL ALL 都是全表扫描
Extra Using temporary Using temporary; Using filesort GROUP BY多了排序!

关键发现:在MySQL 5.7及之前版本,GROUP BY会触发额外的排序操作,这是性能差异的根源。


DISTINCT详解:简单高效的去重利器

DISTINCT的核心特性

DISTINCT是SQL标准中的去重关键字,语义清晰,用法简单。让我们深入理解它的特性:

DISTINCT的NULL处理机制

这是一个容易被忽略但非常重要的特性:

sql 复制代码
-- 创建测试数据
CREATE TABLE test_null (
  id INT,
  city VARCHAR(50)
);

INSERT INTO test_null VALUES
  (1, 'Beijing'),
  (2, 'Shanghai'),
  (3, NULL),
  (4, NULL),
  (5, 'Beijing');

-- DISTINCT查询
SELECT DISTINCT city FROM test_null;

-- 结果:
-- Beijing
-- Shanghai
-- NULL     <- 只保留一个NULL

我的经验总结:在实际业务中,NULL值的处理常常是数据质量问题的来源。DISTINCT将多个NULL合并为一个,可能掩盖了数据问题。建议在去重前先清洗NULL值:

sql 复制代码
-- 推荐做法:排除NULL后再去重
SELECT DISTINCT city 
FROM test_null 
WHERE city IS NOT NULL;

DISTINCT的性能陷阱

陷阱1:大结果集的全表扫描

sql 复制代码
-- 危险的查询(100万行数据)
SELECT DISTINCT user_id 
FROM large_order_table;  -- 可能触发磁盘临时表

优化方案

sql 复制代码
-- 方案1:添加索引
ALTER TABLE large_order_table ADD INDEX idx_user_id(user_id);

-- 方案2:限制结果集
SELECT DISTINCT user_id 
FROM large_order_table 
WHERE order_date >= '2024-01-01';  -- 先过滤再去重

陷阱2:多列组合的基数爆炸

sql 复制代码
-- 危险的查询(组合基数可能很大)
SELECT DISTINCT user_id, ip_address, device_id 
FROM access_log;  -- 可能产生数百万唯一组合

优化建议:评估组合列的基数,如果接近总行数,说明去重意义不大,考虑是否真的需要DISTINCT。


GROUP BY剖析:强大的分组聚合引擎

GROUP BY的核心能力

GROUP BY不仅能去重,更重要的是提供了强大的分组聚合能力:

GROUP BY的高级特性

特性1:多列分组的层次关系

sql 复制代码
-- 按城市和类别两个维度统计
SELECT 
  city,
  category,
  COUNT(*) AS product_count,
  SUM(sales) AS total_sales
FROM products
GROUP BY city, category
ORDER BY city, category;

-- 结果示例:
-- city      category    product_count  total_sales
-- Beijing   Electronics 150           500000
-- Beijing   Clothing    200           300000
-- Shanghai  Electronics 120           450000
-- Shanghai  Clothing    180           280000

特性2:WITH ROLLUP生成汇总行

sql 复制代码
-- 生成多层级汇总
SELECT 
  city,
  category,
  SUM(sales) AS total_sales
FROM products
GROUP BY city, category WITH ROLLUP;

-- 结果示例:
-- city      category    total_sales
-- Beijing   Electronics 500000
-- Beijing   Clothing    300000
-- Beijing   NULL        800000    <- 北京小计
-- Shanghai  Electronics 450000
-- Shanghai  Clothing    280000
-- Shanghai  NULL        730000    <- 上海小计
-- NULL      NULL        1530000   <- 总计

我的实战经验:WITH ROLLUP在生成报表时非常有用,可以一次查询得到明细+小计+总计,避免多次查询或应用层计算。但要注意,ROLLUP生成的汇总行用NULL表示,前端展示时需要特殊处理。

GROUP BY在MySQL 8.0的重要变化

这是一个影响性能的关键改进:

性能对比实验(100万行数据):

版本 SQL 执行时间 Extra
MySQL 5.7 SELECT city FROM orders GROUP BY city 3.2秒 Using filesort
MySQL 5.7 SELECT city FROM orders GROUP BY city ORDER BY NULL 1.1秒 无filesort
MySQL 8.0 SELECT city FROM orders GROUP BY city 1.0秒 无filesort
MySQL 8.0 SELECT DISTINCT city FROM orders 1.0秒 性能相当

我的经验总结 :如果你的生产环境还在用MySQL 5.7,记得在不需要排序的GROUP BY查询中添加ORDER BY NULL,这是一个简单但有效的优化技巧。升级到MySQL 8.0后,这个优化就不需要了。


性能对决:五个维度的深度分析

性能测试实战

测试环境

  • MySQL 8.0.32
  • 表数据:100万行
  • 服务器:8核16GB
  • 存储:SSD

测试1:有索引的列去重

sql 复制代码
-- 准备测试表
CREATE TABLE perf_test (
  id INT PRIMARY KEY AUTO_INCREMENT,
  category VARCHAR(50),
  city VARCHAR(50),
  amount DECIMAL(10,2),
  INDEX idx_category (category),
  INDEX idx_city (city)
) ENGINE=InnoDB;

-- 插入100万行测试数据(100个唯一类别)
-- ...

-- 测试DISTINCT
SELECT DISTINCT category FROM perf_test;
-- 执行时间:0.05秒
-- 执行计划:Using index for group-by

-- 测试GROUP BY
SELECT category FROM perf_test GROUP BY category;
-- 执行时间:0.05秒
-- 执行计划:Using index for group-by

-- 结论:性能完全相同!

测试2:无索引的列去重

sql 复制代码
-- 测试DISTINCT(无索引的amount列)
SELECT DISTINCT amount FROM perf_test;
-- 执行时间:1.2秒
-- 执行计划:Using temporary

-- 测试GROUP BY
SELECT amount FROM perf_test GROUP BY amount;
-- 执行时间:1.3秒
-- 执行计划:Using temporary

-- 结论:MySQL 8.0中性能相当(都约1.2秒)

测试3:配合聚合函数

sql 复制代码
-- DISTINCT无法实现
-- SELECT DISTINCT city, COUNT(*) FROM perf_test;  -- 语法错误

-- GROUP BY轻松实现
SELECT city, COUNT(*) AS cnt, SUM(amount) AS total
FROM perf_test
GROUP BY city;
-- 执行时间:0.8秒(有索引)
-- 执行计划:Using index

-- 结论:聚合场景GROUP BY是唯一选择

实战场景:如何选择正确的去重方案

基于我多年的实战经验,总结了以下场景的最佳实践:

场景实战案例

场景1:用户城市统计(只去重)

sql 复制代码
-- 需求:查询系统覆盖的所有城市
-- 特点:只需去重,不需聚合

-- ✅ 推荐方案:DISTINCT
SELECT DISTINCT city 
FROM users 
WHERE status = 'active';

-- 理由:
-- 1. 语义清晰(就是要去重)
-- 2. 代码简洁
-- 3. 性能足够好(如果city有索引)

场景2:各城市用户数统计(去重+聚合)

sql 复制代码
-- 需求:统计每个城市的活跃用户数
-- 特点:需要分组统计

-- ✅ 必须使用:GROUP BY
SELECT 
  city,
  COUNT(*) AS user_count,
  COUNT(DISTINCT user_id) AS unique_user_count
FROM user_activity
WHERE activity_date >= '2024-01-01'
GROUP BY city
HAVING user_count > 100  -- 筛选用户数>100的城市
ORDER BY user_count DESC;

-- 理由:
-- 1. DISTINCT无法配合聚合函数
-- 2. 需要HAVING过滤分组结果
-- 3. GROUP BY是唯一选择

场景3:订单状态分布(多列去重)

sql 复制代码
-- 需求:查询订单状态和支付方式的所有组合
-- 特点:多列去重

-- ✅ 方案1:DISTINCT(推荐)
SELECT DISTINCT order_status, payment_method
FROM orders;

-- ✅ 方案2:GROUP BY(功能等价)
SELECT order_status, payment_method
FROM orders
GROUP BY order_status, payment_method;

-- 选择建议:
-- • 如果只是去重:用DISTINCT(更直观)
-- • 如果后续可能需要统计:用GROUP BY(便于扩展)

场景4:用户行为漏斗分析(复杂统计)

sql 复制代码
-- 需求:分析用户从注册到购买的转化率
-- 特点:多层级统计,复杂聚合

-- ✅ 必须使用:GROUP BY + 子查询
SELECT 
  DATE(created_at) AS date,
  COUNT(DISTINCT user_id) AS registered_users,
  COUNT(DISTINCT CASE WHEN first_login_at IS NOT NULL THEN user_id END) AS activated_users,
  COUNT(DISTINCT CASE WHEN first_order_at IS NOT NULL THEN user_id END) AS purchased_users,
  ROUND(COUNT(DISTINCT CASE WHEN first_order_at IS NOT NULL THEN user_id END) * 100.0 / COUNT(DISTINCT user_id), 2) AS conversion_rate
FROM users
WHERE created_at >= '2024-01-01'
GROUP BY DATE(created_at)
ORDER BY date DESC;

-- 理由:
-- 1. 涉及多个聚合指标
-- 2. 需要计算转化率
-- 3. 按日期分组
-- 4. DISTINCT无法满足需求

我的经验总结 :实际业务中,80%的"去重"需求最终都会演变为"去重+统计"。所以我的建议是:如果是核心业务表的查询,即使当前只需要去重,也优先考虑GROUP BY,为将来的需求变更留有余地


性能优化:索引与执行计划调优

索引优化策略

去重操作的性能优化,核心在于索引设计:

执行计划分析与调优

工具1:EXPLAIN分析

sql 复制代码
-- 查看执行计划
EXPLAIN SELECT DISTINCT city FROM orders WHERE status = 'completed';

-- 关键字段解读:
-- type: 访问类型(ALL/index/range/ref等)
--   • ALL: 全表扫描(最慢)
--   • index: 索引扫描
--   • range: 索引范围扫描
--   • ref: 索引等值查询(最快)
--
-- key: 实际使用的索引
-- rows: 预估扫描的行数(越少越好)
-- Extra: 额外信息
--   • Using index: 索引覆盖(最优)
--   • Using index for group-by: 索引用于分组
--   • Using temporary: 使用临时表
--   • Using filesort: 文件排序(需优化)

工具2:EXPLAIN FORMAT=JSON(更详细)

sql 复制代码
-- JSON格式执行计划(MySQL 5.7+)
EXPLAIN FORMAT=JSON 
SELECT DISTINCT city 
FROM orders 
WHERE status = 'completed';

-- 输出示例:
{
  "query_block": {
    "duplicates_removal": {
      "using_temporary_table": false,  -- 是否使用临时表
      "using_filesort": false,         -- 是否文件排序
      "table": {
        "access_type": "ref",          -- 访问类型
        "key": "idx_status_city",      -- 使用的索引
        "used_key_parts": ["status"],  -- 索引使用部分
        "rows_examined_per_scan": 500  -- 每次扫描行数
      }
    }
  }
}

实战调优案例

问题SQL(慢查询日志发现):

sql 复制代码
SELECT DISTINCT product_category 
FROM orders 
WHERE order_date >= '2024-01-01';

-- 执行时间:5.2秒
-- 扫描行数:200万行

执行计划分析

sql 复制代码
EXPLAIN SELECT DISTINCT product_category 
FROM orders 
WHERE order_date >= '2024-01-01';

-- type: ALL          <- 全表扫描!
-- key: NULL          <- 没有使用索引!
-- rows: 2000000      <- 扫描200万行
-- Extra: Using where; Using temporary  <- 使用临时表

优化方案1:添加单列索引

sql 复制代码
ALTER TABLE orders ADD INDEX idx_date(order_date);

-- 优化后执行计划:
-- type: range       <- 改为范围扫描
-- key: idx_date     <- 使用了索引
-- rows: 500000      <- 只扫描50万行(2024年的订单)
-- Extra: Using where; Using temporary

-- 执行时间:1.8秒
-- 性能提升:3倍

优化方案2:添加覆盖索引(更优)

sql 复制代码
ALTER TABLE orders ADD INDEX idx_date_category(order_date, product_category);

-- 优化后执行计划:
-- type: range       <- 范围扫描
-- key: idx_date_category  <- 使用覆盖索引
-- rows: 500000
-- Extra: Using where; Using index for group-by  <- 索引覆盖!

-- 执行时间:0.3秒
-- 性能提升:17倍!

我的调优经验

  1. 先看type:如果是ALL(全表扫描),必须优化
  2. 再看key:如果是NULL(无索引),考虑添加索引
  3. 关注Extra
    • 看到Using filesort:考虑添加排序索引
    • 看到Using temporary:检查是否可以用索引避免
    • 看到Using index:最优状态,无需优化

踩坑经验与最佳实践

常见的坑与避坑指南

坑1:误用DISTINCT导致性能问题

sql 复制代码
-- ❌ 错误示例:对大表的多列使用DISTINCT
SELECT DISTINCT user_id, ip_address, user_agent, timestamp
FROM access_log
WHERE log_date = '2024-01-01';

-- 问题:
-- • 组合列的基数极大(几乎每行都不重复)
-- • DISTINCT失去意义,但仍产生去重开销
-- • 可能创建巨大的临时表

-- ✅ 正确做法:评估是否真的需要DISTINCT
-- 如果基数接近总行数,去掉DISTINCT反而更快
SELECT user_id, ip_address, user_agent, timestamp
FROM access_log
WHERE log_date = '2024-01-01'
LIMIT 10000;  -- 如果只是取样,直接LIMIT

坑2:GROUP BY后忘记显式排序

sql 复制代码
-- ❌ 错误示例:期望结果有序,但没有ORDER BY
SELECT city, COUNT(*) AS cnt
FROM orders
GROUP BY city;

-- 问题(MySQL 8.0):
-- • 结果顺序不确定(不再隐式排序)
-- • 每次执行顺序可能不同
-- • 分页查询可能出现重复或遗漏

-- ✅ 正确做法:显式添加ORDER BY
SELECT city, COUNT(*) AS cnt
FROM orders
GROUP BY city
ORDER BY cnt DESC;  -- 明确排序规则

坑3:在SELECT列表中使用非分组列

sql 复制代码
-- ❌ 错误示例(MySQL 5.7 ONLY_FULL_GROUP_BY模式下报错)
SELECT city, user_name, COUNT(*) AS cnt
FROM orders
GROUP BY city;

-- 错误信息:
-- Expression #2 of SELECT list is not in GROUP BY clause 
-- and contains nonaggregated column 'user_name'

-- 问题:
-- • user_name不在GROUP BY中
-- • 也不是聚合函数
-- • MySQL 8.0默认启用ONLY_FULL_GROUP_BY,会报错

-- ✅ 方案1:将列加入GROUP BY
SELECT city, user_name, COUNT(*) AS cnt
FROM orders
GROUP BY city, user_name;

-- ✅ 方案2:使用聚合函数
SELECT city, MAX(user_name) AS sample_user, COUNT(*) AS cnt
FROM orders
GROUP BY city;

-- ✅ 方案3:使用ANY_VALUE(MySQL 5.7+)
SELECT city, ANY_VALUE(user_name) AS sample_user, COUNT(*) AS cnt
FROM orders
GROUP BY city;

坑4:HAVING中使用WHERE条件

sql 复制代码
-- ❌ 错误示例:在HAVING中过滤行
SELECT city, COUNT(*) AS cnt
FROM orders
GROUP BY city
HAVING status = 'completed';  -- 错误!status不是聚合结果

-- 问题:
-- • HAVING用于过滤分组后的结果
-- • 不能用于过滤原始行
-- • 而且性能差(先分组再过滤)

-- ✅ 正确做法:WHERE过滤行,HAVING过滤组
SELECT city, COUNT(*) AS cnt
FROM orders
WHERE status = 'completed'  -- 先过滤行
GROUP BY city
HAVING cnt > 100;           -- 再过滤组

最佳实践总结

性能优化Checklist

SQL编写阶段

  • 评估是否真的需要去重(基数检查)
  • 选择DISTINCT还是GROUP BY(根据是否需要聚合)
  • 先WHERE过滤再GROUP BY(减少分组数据量)
  • 显式添加ORDER BY(如果需要排序)

索引设计阶段

  • 在去重列上建立索引
  • 对于多列去重,考虑联合索引
  • 对于WHERE + DISTINCT,索引包含WHERE条件列
  • 检查索引覆盖(避免回表)

执行优化阶段

  • 使用EXPLAIN分析执行计划
  • 检查type是否为ALL(全表扫描)
  • 检查Extra是否有Using filesort
  • 监控临时表大小(tmp_table_size)

MySQL版本相关

  • MySQL 5.7:GROUP BY添加ORDER BY NULL(避免隐式排序)
  • MySQL 8.0:GROUP BY需显式ORDER BY(已取消隐式排序)
  • 启用ONLY_FULL_GROUP_BY模式(避免SQL歧义)

实战总结

经过深度分析和实战验证,我总结了以下核心要点:

核心观点

  1. DISTINCT和GROUP BY不是对立的,而是功能互补的

    • DISTINCT:专注于去重,语义清晰
    • GROUP BY:提供分组聚合能力,功能强大
    • 选择的关键在于是否需要聚合计算
  2. 性能差异主要来自MySQL版本和索引

    • MySQL 8.0后,两者性能接近
    • 索引是性能的关键,有索引都快,无索引都慢
    • 不要盲目追求"哪个更快",而要针对性优化
  3. 代码可维护性同样重要

    • 简单场景优先DISTINCT(更直观)
    • 复杂场景使用GROUP BY(更灵活)
    • 考虑未来需求变更,留有扩展余地

选型速查表

需求描述 推荐方案 示例SQL
查询唯一值列表 SELECT DISTINCT SELECT DISTINCT city FROM users
统计每组数量 GROUP BY + COUNT SELECT city, COUNT(*) FROM users GROUP BY city
计算每组总和/平均值 GROUP BY + SUM/AVG SELECT city, AVG(amount) FROM orders GROUP BY city
过滤满足条件的组 GROUP BY + HAVING SELECT city, COUNT(*) FROM users GROUP BY city HAVING COUNT(*) > 100
多层级汇总统计 GROUP BY + WITH ROLLUP SELECT city, SUM(amount) FROM orders GROUP BY city WITH ROLLUP

写在最后

作为一名数据库优化工程师,我最深的体会是:SQL优化没有银弹,只有针对具体场景的最优解。DISTINCT和GROUP BY各有优势,关键是理解它们的本质差异,根据业务需求做出正确选择。

我的建议是

  • 如果你是初学者,先掌握DISTINCT(简单直观),再学习GROUP BY(功能强大)
  • 如果你在优化慢查询,先用EXPLAIN分析执行计划,再针对性添加索引
  • 如果你在设计新系统,考虑在常用的去重/分组列上提前建立索引
  • 如果你的生产环境还是MySQL 5.7,尽快规划升级到8.0(性能提升显著)

最后,记住一句话:好的SQL不是写出来的,是优化出来的。持续监控慢查询日志,不断优化索引设计,才能保证系统的长期稳定运行。


📝 关于作者

默语佬,CSDN技术博主,专注于数据库性能优化、MySQL架构设计、SQL调优等领域。十年数据库实战经验,服务过多个千万级数据量的系统。

技术专长

  • MySQL性能调优
  • SQL优化与索引设计
  • 数据库架构设计
  • 慢查询分析与优化

联系方式

  • CSDN:默语佬
  • 欢迎技术交流与合作

原创不易,如果这篇文章对你有帮助,请给个三连支持:点赞👍、收藏⭐、关注🔔!

有任何疑问欢迎评论区讨论,看到必回!如果你在SQL优化中遇到问题,也可以贴出执行计划,我会帮助分析~ 😊


版权声明:本文为默语佬原创文章,遵循CC 4.0 BY-SA版权协议,转载请附上原文出处链接和本声明。

相关推荐
爱隐身的官人3 小时前
Windows配置解压版MySQL5(免安装)
windows·mysql
重生之我是Java开发战士3 小时前
【Java EE】Spring Web MVC入门:综合实践与架构设计
1024程序员节
Echoo华地3 小时前
GitLab社区版日志rotate失败的问题
1024程序员节
哲Zheᗜe༘4 小时前
了解学习Redis主从复制
数据库·redis·学习
oDeviloo4 小时前
新版IntelliJ IDEA个性化设置兼容老版习惯
java·ide·intellij-idea
一只小透明啊啊啊啊4 小时前
Java Web 开发的核心组件:Servlet, JSP,Filter,Listener
java·前端·servlet
asfdsfgas4 小时前
华硕 Armoury Crate 安装卡 50% 不动:清理安装缓存文件的解决步骤
1024程序员节
一条懒鱼6665 小时前
Redis Sentinel哨兵集群
数据库·redis·sentinel
Yeats_Liao5 小时前
Go Web 编程快速入门 10 - 数据库集成与ORM:连接池、查询优化与事务管理
前端·数据库·后端·golang