SQL cho phân tích dữ liệu · Hướng dẫn · 30 tháng 10, 2026 · 14 phút đọc
Tìm và xóa dữ liệu trùng lặp trong SQL, kèm ví dụ

Để tìm dữ liệu trùng lặp trong SQL, hãy nhóm theo các cột lẽ ra phải duy nhất và giữ lại những nhóm xuất hiện nhiều hơn một lần: GROUP BY col1, col2 HAVING COUNT(*) > 1. Để xem đầy đủ các dòng trùng, hoặc để xóa mọi bản sao trừ một, hãy đánh số các dòng trong mỗi nhóm bằng ROW_NUMBER() OVER (PARTITION BY col1, col2 ORDER BY ...): dòng số 1 là bản bạn giữ, các dòng từ số 2 trở đi là bản trùng. Phần khó không nằm ở cú pháp, mà ở việc quyết định những cột nào định nghĩa “cùng một dòng”, và bản sao nào được giữ lại.
Mọi ví dụ đều chạy trên cơ sở dữ liệu hãng hàng không của khóa SQL trên Nomto. Truy vấn dùng cú pháp PostgreSQL và chạy trên DuckDB, động cơ của phòng học SQL trên Nomto; mọi bảng kết quả đều là kết quả chạy thật. Phòng học SQL chỉ cho phép đọc, nên các câu DELETE ở cuối bài được trình bày dưới dạng cú pháp; còn các câu SELECT xem trước những gì chúng sẽ xóa thì đã được chạy thật. Phần xóa có cách viết riêng cho SQL Server và MySQL, hai hệ quản trị phổ biến nhất ở các doanh nghiệp Việt Nam.
Bắt đầu bằng việc định nghĩa thế nào là trùng
Bảng app_events chứa 1.494.723 sự kiện từ ứng dụng di động và website: mở ứng dụng, tìm kiếm, xem ưu đãi, mua vé. Mỗi dòng có event_id riêng, nên phép kiểm tra bản sao giống hệt nhau không tìm thấy gì:
SELECT COUNT(*) AS all_rows,
(SELECT COUNT(*) FROM (SELECT DISTINCT * FROM app_events) d) AS distinct_rows
FROM app_events;
| all_rows | distinct_rows |
|---|---|
| 1494723 | 1494723 |
Điều đó không có nghĩa là bảng sạch. Khi điện thoại mất kết nối, ứng dụng gửi lại sự kiện, và máy chủ lưu nó thêm một lần với event_id mới và received_at muộn hơn. Bản thân sự kiện, tức một client làm một việc tại một thời điểm, vẫn là một. Vì vậy khóa nghiệp vụ ở đây là (client_id, event_time, event_name), và các cột kỹ thuật event_id và received_at phải bị loại khỏi phép so sánh.
Đây là quyết định đầu tiên trong mọi cuộc săn dữ liệu trùng: liệt kê các cột mà theo ý nghĩa của dữ liệu phải là duy nhất. Đưa cột ID vào phép so sánh sẽ che giấu mọi bản trùng.
Tìm giá trị trùng trong một cột
Trường hợp đơn giản nhất là một cột. Những tên hành khách nào xuất hiện trên nhiều hơn một vé?
SELECT passenger_name, COUNT(*) AS tickets
FROM tickets
GROUP BY passenger_name
HAVING COUNT(*) > 1
ORDER BY tickets DESC
LIMIT 3;
| passenger_name | tickets |
|---|---|
| ALEKSANDR IVANOV | 842 |
| ALEKSANDR KUZNECOV | 755 |
| SERGEY IVANOV | 634 |
Có 15.389 tên bị lặp lại. Không cái nào là lỗi dữ liệu: 366.733 vé chỉ mang 22.220 tên khác nhau, và tên phổ biến thì nhiều người dùng chung, giống như ở Việt Nam có vô số người tên Nguyễn Văn Hùng. GROUP BY gộp các dòng có cùng giá trị, HAVING lọc các nhóm sau khi đếm (xem bài GROUP BY và HAVING trong SQL để hiểu vì sao WHERE COUNT(*) > 1 không chạy được).
Tìm dòng trùng trên nhiều cột
Đưa mọi cột của khóa vào GROUP BY. Các sự kiện bị gửi lại trong app_events:
SELECT client_id, event_time, event_name, COUNT(*) AS copies
FROM app_events
GROUP BY client_id, event_time, event_name
HAVING COUNT(*) > 1
ORDER BY event_time
LIMIT 5;
| client_id | event_time | event_name | copies |
|---|---|---|---|
| 0586c43b2bb9 | 2026-07-01 00:09:01 | checkout_start | 2 |
| 0d823672b3be | 2026-07-01 00:11:20 | app_open | 2 |
| 00a725eaf49b | 2026-07-01 00:14:34 | add_passenger | 2 |
| 05416737ab4d | 2026-07-01 00:20:59 | select_fare | 2 |
| 184daf99054d | 2026-07-01 00:34:39 | add_passenger | 2 |
Tổng cộng có bao nhiêu dòng trùng?
Bọc chính truy vấn đó lại và cộng tổng. Số nhóm trùng và số dòng thừa là hai con số khác nhau, và báo cáo thường nhầm lẫn chúng:
SELECT COUNT(*) AS duplicate_groups,
SUM(copies) AS rows_in_groups,
SUM(copies - 1) AS extra_rows
FROM (
SELECT client_id, event_time, event_name, COUNT(*) AS copies
FROM app_events
GROUP BY client_id, event_time, event_name
HAVING COUNT(*) > 1
) d;
| duplicate_groups | rows_in_groups | extra_rows |
|---|---|---|
| 10689 | 21378 | 10689 |
Mỗi sự kiện bị trùng ở đây có đúng hai bản, nên 21.378 dòng có liên quan nhưng chỉ 10.689 dòng cần xóa. Một phép kiểm tra một dòng cho cùng đáp án mà không cần liệt kê các nhóm:
SELECT COUNT(*) AS rows,
COUNT(DISTINCT (client_id, event_time, event_name)) AS unique_events,
COUNT(*) - COUNT(DISTINCT (client_id, event_time, event_name)) AS extra
FROM app_events;
| rows | unique_events | extra |
|---|---|---|
| 1494723 | 1484034 | 10689 |
COUNT(DISTINCT (a, b, c)) với một bộ giá trị trong ngoặc chạy được trong PostgreSQL và DuckDB; MySQL viết là COUNT(DISTINCT a, b, c). SQL Server chỉ cho phép một biểu thức trong COUNT(DISTINCT ...), nên ở đó hãy dùng phiên bản truy vấn con.
Không phải cứ lặp lại là trùng
Định nghĩa khóa cẩu thả thì bạn sẽ “tìm thấy” những bản trùng thực ra là dữ liệu thật. Cùng một tên trên cùng một chuyến bay trông có vẻ đáng ngờ:
SELECT tf.flight_id, t.passenger_name,
COUNT(*) AS tickets,
COUNT(DISTINCT t.passenger_id) AS passengers
FROM ticket_flights tf
JOIN tickets t ON t.ticket_no = tf.ticket_no
GROUP BY tf.flight_id, t.passenger_name
HAVING COUNT(*) > 1
ORDER BY tickets DESC, tf.flight_id
LIMIT 3;
| flight_id | passenger_name | tickets | passengers |
|---|---|---|---|
| 298 | ALEKSANDR IVANOV | 5 | 5 |
| 30577 | ALEKSANDR IVANOV | 5 | 5 |
| 250 | SERGEY KUZNECOV | 4 | 4 |
Có 12.169 cặp chuyến bay và tên như vậy, nhưng mỗi vé có một passenger_id khác nhau: năm người khác nhau cùng tên Aleksandr Ivanov trên chuyến bay 298. Đặt COUNT(DISTINCT id) cạnh COUNT(*) là cách nhanh để phân biệt bản trùng thật với người trùng tên.
Xem đầy đủ các dòng trùng
GROUP BY trả về một dòng cho mỗi nhóm, và bạn mất các cột khác. Để thấy từng bản sao với đầy đủ cột, hãy đếm bằng hàm cửa sổ, vì nó giữ nguyên các dòng (xem thêm bài hàm cửa sổ trong SQL):
SELECT event_id, client_id, event_time, received_at, event_name,
COUNT(*) OVER (PARTITION BY client_id, event_time, event_name) AS copies
FROM app_events
QUALIFY copies > 1
ORDER BY event_time, client_id, received_at
LIMIT 6;
| event_id | client_id | event_time | received_at | event_name | copies |
|---|---|---|---|---|---|
| 139 | 0586c43b2bb9 | 2026-07-01 00:09:01 | 2026-07-01 00:09:03 | checkout_start | 2 |
| 140 | 0586c43b2bb9 | 2026-07-01 00:09:01 | 2026-07-01 00:09:04 | checkout_start | 2 |
| 170 | 0d823672b3be | 2026-07-01 00:11:20 | 2026-07-01 00:11:21 | app_open | 2 |
| 172 | 0d823672b3be | 2026-07-01 00:11:20 | 2026-07-01 00:11:23 | app_open | 2 |
| 207 | 00a725eaf49b | 2026-07-01 00:14:34 | 2026-07-01 00:14:34 | add_passenger | 2 |
| 299 | 00a725eaf49b | 2026-07-01 00:14:34 | 2026-07-01 00:23:54 | add_passenger | 2 |
Giờ thì quy luật đã lộ ra: cùng thời điểm, cùng hành động, nhưng bản thứ hai đến muộn hơn. Trên cả 10.689 cặp, bản gửi lại đến sau bản gốc từ 1 giây đến 10 phút, trung vị là 24 giây.
QUALIFY lọc theo hàm cửa sổ giống như HAVING lọc theo hàm tổng hợp. DuckDB, Snowflake, BigQuery và Databricks hỗ trợ nó; PostgreSQL, MySQL và SQL Server thì không, nên ở đó hãy đặt hàm cửa sổ trong một truy vấn con và lọc ở ngoài: SELECT * FROM (SELECT ..., COUNT(*) OVER (...) AS copies FROM app_events) x WHERE copies > 1.
Một cách cũ hơn là JOIN bảng với kết quả GROUP BY ... HAVING trên tất cả các cột khóa. Cách này chạy ở mọi nơi nhưng phải đọc bảng hai lần.
Giữ dòng đầu tiên bằng ROW_NUMBER
Để khử trùng, hãy đánh số các bản sao trong mỗi nhóm và giữ số 1. ORDER BY trong OVER quyết định bản nào thắng; ở đây là bản đến đầu tiên, với event_id làm cột phá hòa để kết quả lặp lại được:
WITH ranked AS (
SELECT event_id, client_id, event_time, received_at, event_name,
ROW_NUMBER() OVER (
PARTITION BY client_id, event_time, event_name
ORDER BY received_at, event_id
) AS rn
FROM app_events
)
SELECT rn, COUNT(*) AS rows
FROM ranked
GROUP BY rn
ORDER BY rn;
| rn | rows |
|---|---|
| 1 | 1484034 |
| 2 | 10689 |
WHERE rn = 1 cho bảng sạch gồm 1.484.034 sự kiện; WHERE rn > 1 cho đúng 10.689 dòng cần xóa. Không có ORDER BY xác định, mỗi lần chạy cơ sở dữ liệu có thể giữ một bản sao khác nhau. Vì sao ROW_NUMBER chứ không phải RANK được giải thích trong bài ROW_NUMBER, RANK và DENSE_RANK.
Giữ bản nào tùy thuộc vào dữ liệu. Với sự kiện bị gửi lại, bản sớm nhất là bản gốc. Với bảng khách hàng được nạp nhiều lần, bạn thường muốn phiên bản mới nhất: ORDER BY updated_at DESC. Với các dòng khác nhau về độ đầy đủ, bạn có thể sắp xếp theo “có email” trước tiên.
Vì sao điều này quan trọng: chỉ số bị thổi phồng
Dòng trùng hiếm khi làm truy vấn báo lỗi; chúng âm thầm làm tăng số đếm. Số lượt mua theo nền tảng, trước và sau khi khử trùng:
WITH ranked AS (
SELECT platform,
ROW_NUMBER() OVER (
PARTITION BY client_id, event_time, event_name
ORDER BY received_at, event_id
) AS rn
FROM app_events
WHERE event_name = 'purchase'
)
SELECT platform,
COUNT(*) AS raw_purchases,
COUNT(*) FILTER (WHERE rn = 1) AS real_purchases,
COUNT(*) - COUNT(*) FILTER (WHERE rn = 1) AS duplicates
FROM ranked
GROUP BY platform
ORDER BY platform;
| platform | raw_purchases | real_purchases | duplicates |
|---|---|---|---|
| android | 21052 | 20844 | 208 |
| ios | 14262 | 14161 | 101 |
| web | 16538 | 16473 | 65 |
Android có nhiều lần gửi lại nhất: số lượt mua thô cao hơn thực tế khoảng 1%, gấp hơn hai lần mức đếm dư 0,4% của web. Không lớn, nhưng đủ để làm lệch tỷ lệ chuyển đổi hoặc một phép so sánh A/B test giữa các nền tảng.
DISTINCT ON: một dòng cho mỗi khóa trong PostgreSQL
PostgreSQL và DuckDB có lối tắt cho “dòng đầu tiên mỗi nhóm”: DISTINCT ON (key) giữ dòng đầu tiên của mỗi khóa theo ORDER BY. ORDER BY phải bắt đầu bằng đúng các cột trong DISTINCT ON:
SELECT DISTINCT ON (client_id, event_time, event_name)
event_id, client_id, event_time, event_name, received_at
FROM app_events
WHERE event_name = 'purchase'
ORDER BY client_id, event_time, event_name, received_at, event_id
LIMIT 3;
| event_id | client_id | event_time | event_name | received_at |
|---|---|---|---|---|
| 143160 | 000034d98b23 | 2026-07-05 10:26:38 | purchase | 2026-07-05 10:26:39 |
| 1108749 | 0001232bce1c | 2026-08-02 03:08:12 | purchase | 2026-08-02 03:08:13 |
| 815068 | 00013b82c7af | 2026-07-24 22:56:23 | purchase | 2026-07-24 22:56:23 |
Chạy trên toàn bảng, nó trả về đúng 1.484.034 dòng như rn = 1. DISTINCT ON ngắn hơn, nhưng MySQL, SQL Server và Oracle không có nó, và nó chỉ làm được việc “giữ một”; ROW_NUMBER còn cho phép giữ hai, hoặc đánh dấu bản trùng mà không xóa.
SELECT DISTINCT thông thường chỉ loại bỏ dòng trùng khi mọi cột được chọn đều khớp, nên nó giúp được với bản sao giống hệt nhau và vô dụng ở đây, nơi event_id và received_at khác nhau.
Xóa dòng trùng
Trước khi xóa bất cứ thứ gì, hãy chạy câu SELECT liệt kê những dòng sẽ bị xóa và kiểm tra số lượng. Với các sự kiện gửi lại, những dòng cần xóa là những dòng có rn > 1:
SELECT COUNT(*) AS rows_to_delete
FROM app_events
WHERE event_id IN (
SELECT event_id
FROM (
SELECT event_id,
ROW_NUMBER() OVER (
PARTITION BY client_id, event_time, event_name
ORDER BY received_at, event_id
) AS rn
FROM app_events
) r
WHERE rn > 1
);
| rows_to_delete |
|---|
| 10689 |
Con số khớp với extra_rows từ phép kiểm tra bằng GROUP BY, nên lệnh xóa sẽ bỏ đúng một bản trong mỗi cặp. Biến nó thành DELETE thế nào thì tùy hệ quản trị.
SQL Server
SQL Server cho phép xóa thông qua một CTE đọc từ một bảng duy nhất, nên đây là phiên bản ngắn nhất:
WITH ranked AS (
SELECT ROW_NUMBER() OVER (
PARTITION BY client_id, event_time, event_name
ORDER BY received_at, event_id
) AS rn
FROM app_events
)
DELETE FROM ranked
WHERE rn > 1;
MySQL
MySQL từ chối câu DELETE có truy vấn con đọc trực tiếp từ chính bảng đó (lỗi 1093, “You can’t specify target table for update in FROM clause”). Thay vào đó, hãy JOIN với một bảng dẫn xuất. Với MySQL 8.0 trở lên, có hàm cửa sổ:
DELETE e
FROM app_events e
JOIN (
SELECT event_id,
ROW_NUMBER() OVER (
PARTITION BY client_id, event_time, event_name
ORDER BY received_at, event_id
) AS rn
FROM app_events
) r ON r.event_id = e.event_id
WHERE r.rn > 1;
Với MySQL 5.7, bản không có hàm cửa sổ, hãy dùng self join: xóa một dòng khi có một dòng khác cùng khóa đến sớm hơn.
DELETE e1
FROM app_events e1
JOIN app_events e2
ON e2.client_id = e1.client_id
AND e2.event_time = e1.event_time
AND e2.event_name = e1.event_name
AND (e2.received_at < e1.received_at
OR (e2.received_at = e1.received_at AND e2.event_id < e1.event_id));
PostgreSQL và DuckDB
Khi bảng có ID duy nhất, hãy xóa theo ID với cùng truy vấn con:
DELETE FROM app_events
WHERE event_id IN (
SELECT event_id
FROM (
SELECT event_id,
ROW_NUMBER() OVER (
PARTITION BY client_id, event_time, event_name
ORDER BY received_at, event_id
) AS rn
FROM app_events
) r
WHERE rn > 1
);
Nếu bảng PostgreSQL hoàn toàn không có cột duy nhất (bản sao giống hệt nhau), hãy dùng địa chỉ vật lý ẩn của dòng là ctid để phân biệt các bản sao:
DELETE FROM t a
USING t b
WHERE a.key_col = b.key_col
AND a.ctid > b.ctid; -- keeps the copy with the smallest ctid
Trong DuckDB, dựng lại bảng thường đơn giản hơn là xóa:
CREATE OR REPLACE TABLE app_events AS
SELECT * FROM app_events
QUALIFY ROW_NUMBER() OVER (
PARTITION BY client_id, event_time, event_name
ORDER BY received_at, event_id
) = 1;
Dù dùng hệ quản trị nào, hãy chạy lệnh xóa trong một transaction, so số dòng bị ảnh hưởng với kết quả SELECT của bạn, và chỉ khi đó mới commit. Với bảng lớn, xóa theo từng lô hoặc dựng lại bảng giúp tránh khóa bảng lâu.
Ngăn dữ liệu trùng quay lại
Xóa thì sửa được quá khứ; ràng buộc mới sửa được tương lai. Một unique index trên khóa nghiệp vụ khiến cơ sở dữ liệu từ chối bản sao thứ hai:
CREATE UNIQUE INDEX app_events_dedup
ON app_events (client_id, event_time, event_name);
Sau đó, hãy cho quy trình nạp dữ liệu chấp nhận dòng lặp lại thay vì báo lỗi: INSERT ... ON CONFLICT DO NOTHING trong PostgreSQL và DuckDB, INSERT IGNORE hoặc INSERT ... ON DUPLICATE KEY UPDATE trong MySQL, MERGE trong SQL Server. Việc tạo index sẽ thất bại khi vẫn còn dòng trùng, nên hãy dọn dẹp trước. Nếu bạn không thể sửa bảng nguồn, hãy khử trùng trong một view hoặc một bảng staging bằng mẫu ROW_NUMBER, và cho mọi báo cáo đọc từ đó.
Thêm một cái bẫy: GROUP BY gom mọi NULL vào một nhóm, nên những dòng có NULL ở cột khóa bị tính là trùng với nhau. Self join với a.col = b.col không bao giờ khớp NULL và bỏ sót chúng. Unique index trong PostgreSQL mặc định coi các NULL là khác nhau (PostgreSQL 15 thêm NULLS NOT DISTINCT để đổi điều đó). Hãy quyết định bạn muốn hành vi nào trước khi tin vào con số. Xem thêm bài NULL trong SQL.
Câu hỏi thường gặp
Làm sao tìm bản ghi trùng lặp trong SQL?
Nhóm theo các cột lẽ ra phải duy nhất và lọc bằng HAVING COUNT(*) > 1: SELECT col1, col2, COUNT(*) FROM t GROUP BY col1, col2 HAVING COUNT(*) > 1. Để xem đầy đủ các dòng, hãy dùng COUNT(*) OVER (PARTITION BY col1, col2) và lọc những dòng có giá trị lớn hơn 1.
Làm sao xóa dòng trùng nhưng giữ lại một dòng?
Đánh số các dòng trong mỗi nhóm bằng ROW_NUMBER() OVER (PARTITION BY key ORDER BY ...) và xóa những dòng có số lớn hơn 1. ORDER BY quyết định bản nào được giữ, nên hãy làm cho nó xác định, ví dụ timestamp sớm nhất cộng ID làm cột phá hòa.
Làm sao loại bỏ dòng trùng mà không xóa dữ liệu?
Chỉ chọn bản đầu tiên: WHERE rn = 1 trên một truy vấn con dùng ROW_NUMBER, DISTINCT ON trong PostgreSQL và DuckDB, hoặc SELECT DISTINCT nếu các dòng giống hệt nhau ở mọi cột. Bọc nó trong một view để báo cáo đọc phiên bản sạch, còn bảng gốc vẫn nguyên vẹn.
Vì sao SELECT DISTINCT không loại bỏ được dòng trùng của tôi?
DISTINCT so sánh tất cả các cột được chọn. Nếu có một cột khác nhau, chẳng hạn ID hay thời điểm nạp dữ liệu, các dòng không giống hệt nhau và cả hai đều được giữ lại. Hãy chỉ chọn các cột khóa, hoặc dùng ROW_NUMBER để chọn một dòng đầy đủ cho mỗi khóa.
Cách nhanh nhất để xóa dữ liệu trùng trong bảng lớn là gì?
Xóa một phần nhỏ các dòng theo ID thường không có vấn đề gì. Khi phần lớn dữ liệu bị trùng, thường nhanh hơn nếu tạo một bảng mới chỉ gồm các dòng rn = 1, hoán đổi nó vào chỗ bảng cũ, và thêm unique index để vấn đề không quay lại.
Ngôn ngữ khác: English, Español, Português (Brasil), 简体中文