SQL & Database — Tư tưởng & Nguyên lý
10/52
Bài 10 / 52~14 phútTruy vấn cơ bảnLộ trình · chặng 10/21Miễn phí lượt xem

DISTINCT vs GROUP BY — cùng plan, khác intent

Cùng plan, khác intent: DISTINCT để dedupe, GROUP BY để aggregate. DISTINCT ON (PostgreSQL) giải 'top N per group' gọn; window function là alternative portable.

Hai query trả về cùng kết quả: SELECT DISTINCT user_id FROM tasksSELECT user_id FROM tasks GROUP BY user_id. Chạy EXPLAIN cho cả hai — plan giống hệt: HashAggregate. Vậy khác gì nhau?

Bài này chỉ ra sự khác biệt là semantic intent chứ không phải hiệu năng, giải thích khi nào nên chọn cái nào, và giới thiệu DISTINCT ON — cú pháp PostgreSQL-specific giải bài "top N per group" trong một dòng mà không cần window function (kèm alternative portable cho các engine khác).

1. Analogy — "lấy danh sách" vs "gom nhóm để đếm"

Hình dung bảng chấm công: mỗi nhân viên có nhiều dòng ứng với nhiều ngày làm việc. Bạn cần hai thứ khác nhau:

Yêu cầu A: "Cho tôi danh sách nhân viên đã từng chấm công." Intent: loại bỏ trùng lặp.

Yêu cầu B: "Cho tôi số ngày làm việc của mỗi nhân viên." Intent: gom nhóm để tính tổng hợp.

Bảng chấm côngSQL
Danh sách nhân viên không trùngSELECT DISTINCT user_id
Số ngày làm mỗi nhân viênSELECT user_id, COUNT(*)
Intent: dedupeDISTINCT
Intent: aggregateGROUP BY
Cùng kết quả khi chỉ lấy column groupPlan giống nhau: HashAggregate
Khác nhau khi cần tổng hợpDISTINCT không làm được COUNT/SUM/AVG
💡 Cách nhớ

DISTINCT = "tôi chỉ muốn dedupe". GROUP BY = "tôi muốn gom nhóm để tính gì đó". Khi chỉ cần danh sách không trùng — DISTINCT nói lên intent rõ hơn. Khi cần số liệu tổng hợp — GROUP BY là lựa chọn duy nhất.

2. Cơ chế — DISTINCT vs GROUP BY

Hai câu query đi hai đường ở tầng cú pháp, nhưng ở tầng thực thi chúng gặp nhau tại đúng một node — và chỉ rẽ đôi ở bước cuối:

Hai query SELECT DISTINCT user_id và SELECT user_id GROUP BY user_id cùng đổ vào một node HashAggregate băm user_id giữ một nhóm cho mỗi giá trị, rồi từ node đó rẽ đôi: một nhánh trả danh sách không trùng và không có chỗ gắn aggregate, nhánh kia trả nhóm kèm COUNT SUM AVG

3. Cùng plan, khác intent

SQL
-- Ca hai query tra ve ket qua giong het nhau
SELECT DISTINCT user_id FROM tasks;
SELECT user_id FROM tasks GROUP BY user_id;

Khi xem execution plan của cả hai query (dùng EXPLAIN hoặc công cụ tương đương trên engine của bạn), plan thường giống hệt nhau — hệ quản trị biến DISTINCT col thành cùng một phép toán gom nhóm với GROUP BY col. Ví dụ trên PostgreSQL:

SQL
-- Ca hai deu cho plan: HashAggregate (Group Key: user_id) -> Seq Scan
EXPLAIN SELECT DISTINCT user_id FROM tasks;
EXPLAIN SELECT user_id FROM tasks GROUP BY user_id;
-- Cost giong nhau -- chi khac semantic intent khi doc code

Hệ quản trị nhận ra rằng dedupe (loại trùng) và group-then-return-key là cùng một phép toán — cả hai đều cần gom các row có cùng giá trị lại và giữ một đại diện. Cost giống nhau, execution plan giống nhau.

Sự khác biệt nằm ở readabilityextensibility:

  • Dùng DISTINCT khi chỉ cần dedupe một hoặc vài column — intent rõ ràng cho người đọc code.
  • Dùng GROUP BY khi cần aggregate (COUNT, SUM, AVG) hoặc filter sau aggregate (HAVING).

Demo bắt buộc dùng GROUP BY khi thêm aggregate:

SQL
-- DISTINCT khong the lam duoc dieu nay
SELECT user_id, COUNT(*) AS task_count FROM tasks GROUP BY user_id;

-- Loi neu co gang them aggregate vao DISTINCT
-- ERROR: aggregate functions are not allowed in DISTINCT ON expressions

Thử ngẫmmột PR review thấy đồng nghiệp đổi GROUP BY user_id sang DISTINCT user_id mà vẫn giữ nguyên COUNT(*) trong SELECT. Code đó chạy được không, và nếu không thì lỗi rơi vào lúc nào?

4. DISTINCT ON — cú pháp PostgreSQL cho "top N per group"

Bài toán: "lấy task gần nhất của mỗi user". Giải pháp portable là window function (Module 3 — JOIN, aggregation & window sẽ đi sâu). Với PostgreSQL, có cách ngắn hơn: DISTINCT ON.

SQL
-- Lay task moi nhat cho moi user
SELECT DISTINCT ON (assignee_id) *
FROM tasks
WHERE assignee_id IS NOT NULL
ORDER BY assignee_id, created_at DESC;

Cú pháp: DISTINCT ON (col1, col2) * giữ lại row đầu tiên trong mỗi nhóm unique theo col1, col2 — theo thứ tự ORDER BY quyết định row nào là "đầu tiên".

Quan trọng: column trong DISTINCT ON (...) phải là leftmost prefix của ORDER BY. Nếu DISTINCT ON (assignee_id) thì ORDER BY phải bắt đầu bằng assignee_id.

Tương đương với window function (verbose hơn nhiều):

SQL
-- Window function equivalent -- dai hon nhung portable sang MySQL/SQLite
SELECT * FROM (
  SELECT *,
    ROW_NUMBER() OVER (PARTITION BY assignee_id ORDER BY created_at DESC) AS rn
  FROM tasks
  WHERE assignee_id IS NOT NULL
) sub
WHERE rn = 1;

DISTINCT ON ngắn hơn và planner thường tối ưu tốt hơn cho case đơn giản vì có thể tận dụng composite index trên (assignee_id, created_at DESC).

Hạn chế: DISTINCT ON chỉ có trong PostgreSQL. MySQL, SQLite, SQL Server không hỗ trợ — phải dùng window function khi cần portable.

5. Pitfall — DISTINCT * trên large table

Pitfall — DISTINCT * thường là dấu hiệu JOIN sai

SELECT DISTINCT * FROM tasks phải sort hoặc hash toàn bộ bảng để tìm duplicate — chi phí O(N log N). Trên bảng 5 triệu row, query này mất nhiều giây. Quan trọng hơn: khi bạn thấy mình cần DISTINCT *, đó thường là dấu hiệu JOIN sai tạo ra Cartesian product thay vì vấn đề thực sự cần dedupe.

Ví dụ anti-pattern kinh điển:

SQL
-- ANTI-PATTERN: dung DISTINCT de hide JOIN bug
-- Implicit cross join: moi user ghep voi moi task -> duplicate
SELECT DISTINCT u.*
FROM users u, tasks t
WHERE u.id = t.assignee_id;

-- FIX: explicit JOIN, khong can DISTINCT
SELECT u.*
FROM users u
INNER JOIN tasks t ON u.id = t.assignee_id;
-- Van co the co duplicate neu user co nhieu task -- can DISTINCT hoac GROUP BY dung nghia
-- Fix hoan chinh:
SELECT DISTINCT u.*
FROM users u
INNER JOIN tasks t ON u.id = t.assignee_id;
-- Hoac:
SELECT u.* FROM users u WHERE EXISTS (
  SELECT 1 FROM tasks t WHERE t.assignee_id = u.id
);

Nguyên tắc: nếu bạn đang thêm DISTINCT để "fix" duplicate trong kết quả, hãy hỏi tại sao có duplicate trước — thường là JOIN condition thiếu hoặc sai.

Thử ngẫmTaskFlow deploy production đang có SELECT DISTINCT u.* sau users, tasks implicit join, và nó "chạy đúng" bao lâu nay. Điều gì khiến bạn vẫn nên sửa thay vì để yên?

6. GROUP BY — strict mode theo SQL chuẩn

SQL chuẩn (và hầu hết RDBMS hiện đại) yêu cầu mọi column trong SELECT list phải nằm trong GROUP BY clause hoặc được bọc trong aggregate function. MySQL trước 5.7 không enforce rule này — một nguồn gây bug khi migrate.

SQL
-- ERROR theo SQL chuan (PostgreSQL, SQL Server, SQLite strict mode...)
SELECT user_id, title FROM tasks GROUP BY user_id;
-- ERROR: column "tasks.title" must appear in the GROUP BY clause
--        or be used in an aggregate function

-- Fix 1: them title vao GROUP BY
SELECT user_id, title FROM tasks GROUP BY user_id, title;

-- Fix 2: dung aggregate function
-- STRING_AGG (PostgreSQL, SQL Server 2017+) hoac GROUP_CONCAT (MySQL/SQLite)
SELECT user_id, STRING_AGG(title, ', ') AS all_titles FROM tasks GROUP BY user_id;

MySQL trước 5.7 chạy thành công câu query lỗi trên nhưng trả về giá trị title ngẫu nhiên (không xác định row nào được chọn) — silent bug khó phát hiện. Hệ quản trị tuân thủ strict mode sẽ fail ngay tại parse time, đây là hành vi đúng đắn hơn.

Module 3 — JOIN, aggregation & window của khoá này sẽ đi sâu vào GROUP BY kết hợp HAVING và các visibility rule của aggregate.

7. Applied — TaskFlow scenarios

Ba tình huống thực tế cho thấy khi nào chọn gì:

SQL
-- Scenario 1: list user da tung comment trong project X
-- DISTINCT phu hop: chi can dedupe, khong can count
SELECT DISTINCT u.id, u.name
FROM users u
JOIN comments c ON c.user_id = u.id
JOIN tasks t ON t.id = c.task_id
WHERE t.project_id = 5;

-- Scenario 2: count so comment moi user trong project X
-- GROUP BY bat buoc: can aggregate COUNT
SELECT u.name, COUNT(c.id) AS comment_count
FROM users u
JOIN comments c ON c.user_id = u.id
JOIN tasks t ON t.id = c.task_id
WHERE t.project_id = 5
GROUP BY u.id, u.name
ORDER BY comment_count DESC;

-- Scenario 3: lay comment moi nhat cua moi user trong project X
-- DISTINCT ON elegant: top 1 per group
SELECT DISTINCT ON (user_id) user_id, body, created_at
FROM comments
WHERE task_id IN (SELECT id FROM tasks WHERE project_id = 5)
ORDER BY user_id, created_at DESC;

8. Deep Dive — DISTINCT semantics

📚 Deep Dive — DISTINCT semantics

Ghi chú: Use The Index Luke cho agnostic intuition về DISTINCT. Modern SQL cho cross-vendor pattern so sánh. PG docs khi cần cú pháp DISTINCT ON chính xác.

9. Liên hệ các bài khác

10. Tóm tắt

  • DISTINCTGROUP BY có cùng plan (HashAggregate) khi chỉ dedupe một column — khác biệt là semantic intent, không phải performance.
  • Dùng DISTINCT khi chỉ cần danh sách không trùng, không cần tổng hợp số liệu — intent rõ ràng hơn cho người đọc code.
  • Dùng GROUP BY khi cần aggregate (COUNT, SUM, AVG) hoặc filter qua HAVING.
  • DISTINCT ON (col) là PG-specific — giữ row đầu tiên mỗi nhóm theo ORDER BY, giải bài "top N per group" ngắn gọn mà không cần window function. Column trong DISTINCT ON phải là leftmost prefix của ORDER BY.
  • DISTINCT * thường là dấu hiệu JOIN sai — debug nguyên nhân duplicate thay vì patch bằng DISTINCT.
  • SQL strict mode: mọi column trong SELECT phải nằm trong GROUP BY hoặc aggregate (SQL chuẩn, PostgreSQL, SQL Server, SQLite strict) — MySQL trước 5.7 lax, port sang engine strict sẽ fail.
  • Forward link: Module 3 — JOIN, aggregation & window sẽ đi sâu vào GROUP BY + HAVING + aggregate visibility rules, và window function thay thế DISTINCT ON khi cần portable.

11. Tự kiểm tra

Tự kiểm tra
0/5 câu đã trả lời
  1. Q1
    Vì sao query planner biến `SELECT DISTINCT col FROM table` thành cùng một phép toán gom nhóm như `GROUP BY col`? Implication gì về performance khi chọn giữa hai cách?
  2. Q2
    Phân biệt khi nào dùng DISTINCT vs GROUP BY. Cho 2 ví dụ TaskFlow cho mỗi loại.
  3. Q3
    Query SELECT DISTINCT u.* FROM users u, tasks t WHERE u.id = t.assignee_id có thể trả về duplicate dù có DISTINCT. Vì sao? Fix thế nào?
  4. Q4
    Query SELECT DISTINCT ON (assignee_id) * FROM tasks ORDER BY assignee_id, created_at DESC — tại sao column trong DISTINCT ON phải xuất hiện ở vị trí leftmost trong ORDER BY?
  5. Q5
    Code MySQL `SELECT user_id, title FROM tasks GROUP BY user_id` chạy OK. Port sang engine strict (PostgreSQL, SQL Server) → error. Giải thích tại sao MySQL cho phép, engine strict không?

Bài tiếp theo: Pattern matching — vì sao LIKE '%x' kill index

Bài này đáng gửi cho bạn học cùng?

Copy link đã gắn nguồn — dán group, chat, hoặc LinkedIn.

Bài này có giúp bạn hiểu bản chất không?

Hỏi đáp về bài này

Chưa có câu hỏi

Đặt câu hỏi

Có gì chưa rõ trong bài? Đặt câu hỏi đầu tiên — câu trả lời từ cộng đồng giúp bạn (và người sau).

Đặt câu hỏi đầu tiên

Bài tiếp theo

Pattern matching — vì sao LIKE '%x' không dùng được index