前言
在日常的數據庫操作中,數據去重是一個非常常見的需求。無論是查詢結果去重、數據清洗,還是統計分析,我們都需要掌握MySQL中的各種去重技術。本文將詳細介紹MySQL中常用的去重關鍵字和操作方法,結合實際業務場景,幫助大家更好地理解和應用這些技術。
MySQL提供了多種去重方式,主要包括DISTINCT關鍵字、GROUP BY子句、以及一些高級的去重技巧。每種方法都有其適用場景和性能特點,選擇合適的去重方式對于提高查詢效率至關重要。
一、DISTINCT關鍵字詳解
(一)基本語法和用法
DISTINCT是MySQL中最常用的去重關鍵字,它可以去除查詢結果中的重復行。DISTINCT關鍵字必須放在SELECT語句的最前面,它會對整個查詢結果進行去重。
-- 基本語法
SELECT DISTINCT column1, column2, ... FROM table_name;-- 單列去重:查詢所有不重復的城市
SELECT DISTINCT city FROM customers;-- 多列組合去重:查詢不重復的城市和省份組合
SELECT DISTINCT city, province FROM customers;-- 結合WHERE條件的去重查詢
SELECT DISTINCT department_id
FROM employees
WHERE salary > 5000;
(二)DISTINCT的工作原理
DISTINCT的工作原理是對查詢結果進行排序和比較,去除完全相同的行。需要注意的是:
-- 示例數據表:products
-- id | name | category | price
-- 1 | iPhone | 手機 | 6999
-- 2 | iPad | 平板 | 3999
-- 3 | MacBook | 電腦 | 9999
-- 4 | iPhone | 手機 | 6999
-- 5 | Samsung | 手機 | 5999-- 單列去重:只返回不重復的分類
SELECT DISTINCT category FROM products;
-- 結果:手機、平板、電腦-- 多列去重:name和category的組合必須完全相同才會被去重
SELECT DISTINCT name, category FROM products;
-- 結果:iPhone-手機、iPad-平板、MacBook-電腦、Samsung-手機
-- 注意:即使有兩個iPhone,但它們的完整記錄相同,所以只返回一條
(三)DISTINCT的性能考慮
DISTINCT操作需要對結果集進行排序和比較,在大數據量情況下可能影響性能:
-- 性能優化建議:-- 1. 在有索引的列上使用DISTINCT效果更好
SELECT DISTINCT customer_id FROM orders; -- customer_id有索引-- 2. 盡量減少DISTINCT的列數
SELECT DISTINCT city FROM customers; -- 好于下面的寫法
-- SELECT DISTINCT city, address FROM customers; -- 如果不必要,避免多列-- 3. 結合LIMIT使用,避免處理過多數據
SELECT DISTINCT category FROM products LIMIT 10;-- 4. 使用EXISTS替代DISTINCT IN子查詢(某些情況下性能更好)
-- 不推薦:
SELECT * FROM customers WHERE city IN (SELECT DISTINCT city FROM stores);
-- 推薦:
SELECT * FROM customers c WHERE EXISTS (SELECT 1 FROM stores s WHERE s.city = c.city);
二、GROUP BY去重操作
(一)GROUP BY基本去重
GROUP BY不僅可以用于分組統計,也是一種強大的去重工具。相比DISTINCT,GROUP BY提供了更多的靈活性:
-- 使用GROUP BY實現去重
SELECT city FROM customers GROUP BY city;-- 等價于DISTINCT的寫法
SELECT DISTINCT city FROM customers;-- GROUP BY的優勢:可以同時進行統計
SELECT city, COUNT(*) as customer_count
FROM customers
GROUP BY city;-- 多列分組去重
SELECT department_id, job_title, COUNT(*) as employee_count
FROM employees
GROUP BY department_id, job_title;
(二)GROUP BY與聚合函數結合
GROUP BY的真正威力在于與聚合函數的結合使用:
-- 統計每個分類的產品數量和平均價格
SELECT category,COUNT(*) as product_count, -- 統計每個分類的產品數量AVG(price) as avg_price, -- 計算平均價格MIN(price) as min_price, -- 最低價格MAX(price) as max_price -- 最高價格
FROM products
GROUP BY category;-- 查找每個部門薪資最高的員工信息
SELECT department_id,MAX(salary) as max_salary,COUNT(*) as employee_count
FROM employees
GROUP BY department_id;-- 統計每個客戶的訂單數量和總金額
SELECT customer_id,COUNT(order_id) as order_count, -- 訂單數量SUM(total_amount) as total_spent, -- 總消費金額AVG(total_amount) as avg_order -- 平均訂單金額
FROM orders
GROUP BY customer_id
HAVING total_spent > 10000; -- 只顯示消費超過1萬的客戶
(三)HAVING子句過濾分組結果
HAVING子句用于過濾GROUP BY的結果,類似于WHERE,但作用于分組后的數據:
-- 查找訂單數量超過5個的客戶
SELECT customer_id,COUNT(*) as order_count
FROM orders
GROUP BY customer_id
HAVING COUNT(*) > 5;-- 查找平均薪資超過8000的部門
SELECT department_id,AVG(salary) as avg_salary,COUNT(*) as employee_count
FROM employees
GROUP BY department_id
HAVING AVG(salary) > 8000;-- 復雜的HAVING條件:多個聚合函數條件
SELECT category,COUNT(*) as product_count,AVG(price) as avg_price
FROM products
GROUP BY category
HAVING COUNT(*) >= 3 AND AVG(price) > 1000;
三、高級去重技巧
(一)什么是窗口函數
在介紹ROW_NUMBER()之前,我們先了解一下什么是窗口函數。
窗口函數就像是透過一個"窗口"來觀察和處理數據。 想象一下你站在樓里透過窗戶看外面的風景,這個"窗戶"就是你觀察的范圍,你可以看到窗戶范圍內的所有景物并對它們進行分析。
在數據庫中:
- 窗口 = 數據的觀察范圍(可以是整個表,也可以是按某個字段分組的數據)
- 窗口函數 = 在這個范圍內進行計算的函數
- 關鍵特點 = 不會減少行數,每一行都會保留,只是在每行上添加計算結果
窗口函數與聚合函數的區別
-- 普通聚合函數:會合并數據,減少行數
SELECT department_id, COUNT(*) as emp_count
FROM employees
GROUP BY department_id;
-- 結果:如果有3個部門,只返回3行數據-- 窗口函數:保持原有行數,在每行上添加計算結果
SELECTdepartment_id,employee_name,salary,COUNT(*) OVER (PARTITION BY department_id) as dept_emp_count
FROM employees;
-- 結果:如果有100個員工,仍然返回100行,但每行都知道自己部門有多少人
生活中的比喻:班級排名
- 傳統GROUP BY方式:把學生按班級分組,只告訴你每個班有多少人
- 窗口函數方式:每個學生都知道自己在班級中的排名,同時保留所有學生的完整信息
(二)使用ROW_NUMBER()進行去重
ROW_NUMBER()是MySQL的窗口函數,可以為每個分組內的行分配一個唯一的序號,常用于復雜的去重場景。
窗口函數語法詳解
-- 窗口函數的基本語法結構
ROW_NUMBER() OVER (PARTITION BY column1, column2, ... -- 分組字段(可選)ORDER BY column3, column4, ... -- 排序字段(必需)
)
語法說明:
- OVER:窗口函數的關鍵字,定義窗口的范圍和規則
- PARTITION BY:類似于GROUP BY,將數據分成不同的組,在每個組內獨立計算
- ORDER BY:在每個分組內按指定字段排序,ROW_NUMBER()根據這個順序分配序號
OVER子句詳細說明
OVER子句是窗口函數的核心,它定義了函數的計算范圍:
-- 1. 只有ORDER BY,沒有PARTITION BY:對整個結果集排序編號
SELECTname,salary,ROW_NUMBER() OVER (ORDER BY salary DESC) as salary_rank
FROM employees;
-- 結果:所有員工按薪資從高到低編號 1,2,3,4,5...-- 2. 有PARTITION BY和ORDER BY:分組內排序編號
SELECTdepartment_id,name,salary,ROW_NUMBER() OVER (PARTITION BY department_id ORDER BY salary DESC) as dept_rank
FROM employees;
-- 結果:每個部門內的員工按薪資排序,每個部門都從1開始編號
PARTITION BY與GROUP BY的區別:
GROUP BY: 會合并行,減少結果集的行數
SELECT department_id, COUNT(*) FROM employees GROUP BY department_id;
PARTITION BY: 不會合并行,保持原有行數,只是在每個分組內計算
SELECTdepartment_id,name,ROW_NUMBER() OVER (PARTITION BY department_id ORDER BY hire_date) as hire_order
FROM employees;
常用窗口函數對比
示例數據:員工薪資表
dept_id | name | salary
1 | 張三 | 8000
1 | 李四 | 9000
1 | 王五 | 9000
2 | 趙六 | 7000
ROW_NUMBER(): 連續唯一編號,相同值也會分配不同序號
SELECTdept_id, name, salary,ROW_NUMBER() OVER (PARTITION BY dept_id ORDER BY salary DESC) as row_num
FROM employees;
執行結果:李四=1, 王五=2, 張三=3(即使李四和王五薪資相同)
RANK(): 相同值分配相同排名,但會跳過后續排名
SELECTdept_id, name, salary,RANK() OVER (PARTITION BY dept_id ORDER BY salary DESC) as rank_num
FROM employees;
執行結果:李四=1, 王五=1, 張三=3(跳過了排名2)
DENSE_RANK(): 相同值分配相同排名,不跳過后續排名
SELECTdept_id, name, salary,DENSE_RANK() OVER (PARTITION BY dept_id ORDER BY salary DESC) as dense_rank
FROM employees;
執行結果:李四=1, 王五=1, 張三=2(不跳過排名)
實際應用示例
ROW_NUMBER()窗口函數可以為每個分組內的行分配一個唯一的序號,常用于復雜的去重場景:
-- 刪除重復數據,保留ID最小的記錄
-- 假設customers表中有重復的email記錄-- 1. 先查看重復數據
SELECTemail,COUNT(*) as duplicate_count
FROM customers
GROUP BY email
HAVING COUNT(*) > 1;-- 2. 使用ROW_NUMBER()標記重復數據
SELECTid,name,email,-- PARTITION BY email:按郵箱分組,相同郵箱的記錄在同一組-- ORDER BY id:在每個郵箱組內按ID升序排列-- 結果:每個郵箱組內的記錄被分配序號1,2,3...ROW_NUMBER() OVER (PARTITION BY email ORDER BY id) as row_num
FROM customers;
執行結果示例:
id | name | email | row_num
1 | 張三 | zhang@qq.com | 1
3 | 李四 | zhang@qq.com | 2 -- 相同郵箱的第2條記錄
2 | 王五 | wang@qq.com | 1
4 | 趙六 | zhao@qq.com | 1-- 3. 刪除重復數據(保留row_num=1的記錄)
DELETE c1 FROM customers c1
INNER JOIN (SELECTid,ROW_NUMBER() OVER (PARTITION BY email ORDER BY id) as row_numFROM customers
) c2 ON c1.id = c2.id
WHERE c2.row_num > 1;
(三)獲取每組最新記錄的去重
這是業務中非常常見的去重場景:獲取每個用戶、每個分類等的最新一條記錄。這種去重不是簡單的刪除重復,而是從每個分組中選擇符合條件的特定記錄:
-- 場景:查詢每個用戶的最新購買記錄
-- 使用ROW_NUMBER()窗口函數(推薦方法)
SELECTuser_id,user_name,order_id,product_name,order_date,amount
FROM (SELECTu.user_id,u.user_name,o.order_id,o.product_name,o.order_date,o.amount,-- PARTITION BY u.user_id:按用戶ID分組,每個用戶的記錄獨立編號-- ORDER BY o.order_date DESC:在每個用戶組內按訂單日期降序排列(最新的在前)-- 結果:每個用戶的最新訂單獲得序號1,次新的獲得序號2,以此類推ROW_NUMBER() OVER (PARTITION BY u.user_id ORDER BY o.order_date DESC) as rnFROM users uINNER JOIN orders o ON u.user_id = o.user_id
) ranked_orders
WHERE rn = 1; -- 只取每個用戶的第一條記錄(最新的)-- 處理同一時間多條記錄的情況
SELECTuser_id,user_name,order_id,product_name,order_date,amount
FROM (SELECTu.user_id,u.user_name,o.order_id,o.product_name,o.order_date,o.amount,-- PARTITION BY u.user_id:按用戶分組-- ORDER BY多個字段:先按日期降序,再按訂單ID降序-- 這樣確保即使同一天有多個訂單,也能選出唯一的"最新"記錄ROW_NUMBER() OVER (PARTITION BY u.user_idORDER BY o.order_date DESC, o.order_id DESC) as rnFROM users uINNER JOIN orders o ON u.user_id = o.user_id
) ranked_orders
WHERE rn = 1;-- 其他常見的最新記錄查詢場景:-- 1. 查詢每個商品的最新價格記錄
SELECTproduct_id,product_name,price,effective_date
FROM (SELECTproduct_id,product_name,price,effective_date,ROW_NUMBER() OVER (PARTITION BY product_id ORDER BY effective_date DESC) as rnFROM product_price_history
) latest_prices
WHERE rn = 1;-- 2. 查詢每個部門最新入職的員工
SELECTdepartment_id,employee_name,hire_date,salary
FROM (SELECTdepartment_id,employee_name,hire_date,salary,ROW_NUMBER() OVER (PARTITION BY department_id ORDER BY hire_date DESC) as rnFROM employees
) latest_hires
WHERE rn = 1;
替代方法:使用相關子查詢
-- 使用相關子查詢實現相同功能(性能可能較差)
SELECTu.user_id,u.user_name,o.order_id,o.product_name,o.order_date,o.amount
FROM users u
INNER JOIN orders o ON u.user_id = o.user_id
WHERE o.order_date = (-- 子查詢:找到該用戶的最新訂單日期SELECT MAX(order_date)FROM orders o2WHERE o2.user_id = u.user_id
);-- 注意:如果同一天有多條記錄,上述查詢可能返回多條結果
-- 需要進一步處理:
SELECTu.user_id,u.user_name,o.order_id,o.product_name,o.order_date,o.amount
FROM users u
INNER JOIN orders o ON u.user_id = o.user_id
WHERE (o.order_date, o.order_id) = (-- 使用復合條件確保唯一性SELECT order_date, MAX(order_id)FROM orders o2WHERE o2.user_id = u.user_idAND o2.order_date = (SELECT MAX(order_date) FROM orders o3 WHERE o3.user_id = u.user_id)
);
(四)使用UNION去重
UNION操作符會自動去除重復行,而UNION ALL則保留所有行:
-- UNION自動去重
SELECT city FROM customers_north
UNION
SELECT city FROM customers_south;-- UNION ALL保留重復
SELECT city FROM customers_north
UNION ALL
SELECT city FROM customers_south;-- 復雜的UNION去重查詢
SELECT 'VIP客戶' as customer_type, name, email FROM vip_customers
UNION
SELECT '普通客戶' as customer_type, name, email FROM regular_customers
ORDER BY customer_type, name;
(五)臨時表去重方法
對于大量數據的去重操作,有時使用臨時表會更高效:
-- 創建臨時表存儲去重結果
CREATE TEMPORARY TABLE temp_unique_customers AS
SELECT DISTINCT customer_id, name, email
FROM customers;-- 清空原表
TRUNCATE TABLE customers;-- 將去重數據插入回原表
INSERT INTO customers (customer_id, name, email)
SELECT customer_id, name, email FROM temp_unique_customers;-- 刪除臨時表
DROP TEMPORARY TABLE temp_unique_customers;
四、實際應用場景
(一)電商系統中的去重應用
在電商系統中,去重操作非常常見,以下是一些典型場景:
-- 場景1:統計每個商品的銷售情況(去重訂單項)
SELECT product_id,product_name,COUNT(DISTINCT order_id) as order_count, -- 有多少個不同訂單購買了此商品SUM(quantity) as total_sold, -- 總銷售數量SUM(quantity * price) as total_revenue -- 總銷售額
FROM order_items oi
JOIN products p ON oi.product_id = p.id
GROUP BY product_id, product_name
ORDER BY total_revenue DESC;-- 場景2:查找活躍用戶(去重登錄記錄)
SELECT user_id,COUNT(DISTINCT DATE(login_time)) as active_days, -- 活躍天數MIN(login_time) as first_login, -- 首次登錄MAX(login_time) as last_login -- 最后登錄
FROM user_login_logs
WHERE login_time >= DATE_SUB(NOW(), INTERVAL 30 DAY)
GROUP BY user_id
HAVING active_days >= 10; -- 30天內至少活躍10天的用戶
(二)數據分析中的去重統計
-- 場景3:網站流量分析(去重訪問統計)
SELECT DATE(visit_time) as visit_date,COUNT(*) as total_visits, -- 總訪問次數(包含重復)COUNT(DISTINCT user_id) as unique_visitors, -- 獨立訪客數COUNT(DISTINCT session_id) as unique_sessions -- 獨立會話數
FROM website_visits
WHERE visit_time >= DATE_SUB(NOW(), INTERVAL 7 DAY)
GROUP BY DATE(visit_time)
ORDER BY visit_date;-- 場景4:用戶行為分析(去重行為統計)
SELECT user_id,COUNT(DISTINCT action_type) as action_types, -- 用戶執行了多少種不同行為COUNT(DISTINCT product_id) as viewed_products, -- 查看了多少不同商品COUNT(*) as total_actions -- 總行為次數
FROM user_actions
WHERE action_date >= DATE_SUB(NOW(), INTERVAL 30 DAY)
GROUP BY user_id
HAVING action_types >= 3; -- 至少執行了3種不同行為的用戶
五、性能優化建議
(一)索引優化
合理的索引設計對去重操作的性能至關重要:
-- 為經常用于去重的列創建索引
CREATE INDEX idx_customer_email ON customers(email);
CREATE INDEX idx_product_category ON products(category);-- 復合索引用于多列去重
CREATE INDEX idx_order_customer_date ON orders(customer_id, order_date);-- 查看索引使用情況
EXPLAIN SELECT DISTINCT category FROM products;
(二)查詢優化技巧
-- 1. 使用EXISTS替代DISTINCT子查詢
-- 不推薦:
SELECT * FROM products WHERE category IN (SELECT DISTINCT category FROM featured_products
);-- 推薦:
SELECT * FROM products p WHERE EXISTS (SELECT 1 FROM featured_products fp WHERE fp.category = p.category
);-- 2. 合理使用LIMIT
SELECT DISTINCT category FROM products LIMIT 20;-- 3. 避免在大表上進行全表DISTINCT
-- 如果可能,先用WHERE條件過濾數據
SELECT DISTINCT customer_id
FROM orders
WHERE order_date >= '2024-01-01';
六、常見問題與解決方案
(一)NULL值處理
在去重操作中,NULL值的處理需要特別注意:
-- DISTINCT會將NULL視為相同值
SELECT DISTINCT phone FROM customers; -- 多個NULL只會返回一個NULL-- 如果要排除NULL值
SELECT DISTINCT phone FROM customers WHERE phone IS NOT NULL;-- GROUP BY也會將NULL歸為一組
SELECT phone, COUNT(*) FROM customers GROUP BY phone;
(二)性能問題排查
-- 使用EXPLAIN分析查詢性能
EXPLAIN SELECT DISTINCT category FROM products;-- 查看查詢執行時間
SET profiling = 1;
SELECT DISTINCT customer_id FROM orders;
SHOW PROFILES;-- 對于大數據量,考慮分批處理
SELECT DISTINCT customer_id FROM orders
WHERE order_date BETWEEN '2024-01-01' AND '2024-01-31';
七、總結
MySQL中的去重操作是數據處理的基礎技能,掌握不同去重方法的特點和適用場景非常重要:
- DISTINCT:適用于簡單的結果去重,語法簡潔,但功能相對單一
- GROUP BY:功能強大,可以結合聚合函數進行統計分析,是數據分析的利器
- 窗口函數ROW_NUMBER():適用于復雜的去重場景,如刪除重復數據、獲取每組最新記錄等
- UNION:適用于合并多個查詢結果并去重
- 獲取每組最新記錄:這是業務中最常見的去重需求,推薦使用ROW_NUMBER()窗口函數實現
在實際應用中,應該根據具體的業務需求和數據特點選擇合適的去重方法,同時注意性能優化,合理使用索引,避免在大數據量上進行低效的去重操作。