SQL & Database — Tư tưởng & Nguyên lý/DISTINCT vs GROUP BY — cùng plan, khác intent
10/51
Bài 10 / 51~14 phútTruy vấn cơ bảnMiễ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

flowchart TD
  INPUT["Input rows\n(co the co duplicate)"]
  INPUT --> D["DISTINCT"]
  INPUT --> G["GROUP BY"]
  D --> D1["Hash / sort tat ca rows"]
  D1 --> D2["Giu 1 row dai dien\ncho moi gia tri unique"]
  D2 --> D3["Tra danh sach khong trung\n(khong co aggregate)"]
  G --> G1["Hash / sort tat ca rows"]
  G1 --> G2["Gom nhom rows\ncung gia tri GROUP BY"]
  G2 --> G3["Ap dung aggregate\n(COUNT, SUM, AVG...)"]
  G3 --> G4["Tra nhom + ket qua aggregate"]
  style D3 fill:#dbeafe,color:#1e3a8a
  style G4 fill:#ede9fe,color:#4c1d95

3. Cùng plan, khác intent

-- 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:

-- 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:

-- 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

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.

-- 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):

-- 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:

-- 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.

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.

-- 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ì:

-- 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
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?

Về mặt logic, 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. Hệ quản trị nhận ra sự tương đương này và biên dịch DISTINCT col thành cùng một node gom nhóm (ví dụ HashAggregate trong PostgreSQL) với GROUP BY col. Implication: không có lý do performance để chọn cái này hơn cái kia khi chỉ cần dedupe một column. Chọn theo semantic intent — DISTINCT nói rõ "tôi chỉ muốn dedupe", GROUP BY nói "tôi đang gom nhóm để tính gì đó".

Q2
Phân biệt khi nào dùng DISTINCT vs GROUP BY. Cho 2 ví dụ TaskFlow cho mỗi loại.

Dùng DISTINCT khi chỉ cần dedupe:

  • Danh sách user đã tạo ít nhất một task: SELECT DISTINCT creator_id FROM tasks — chỉ cần id không trùng, không cần đếm.
  • Danh sách project có comment: SELECT DISTINCT t.project_id FROM tasks t JOIN comments c ON c.task_id = t.id — list project không trùng.

Dùng GROUP BY khi cần aggregate hoặc HAVING:

  • Số task mỗi user: SELECT assignee_id, COUNT(*) FROM tasks GROUP BY assignee_id — cần COUNT, không thể dùng DISTINCT.
  • User có hơn 10 comment: SELECT user_id, COUNT(*) FROM comments GROUP BY user_id HAVING COUNT(*) > 10 — cần HAVING filter sau aggregate.
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?

Không — query này không trả về duplicate vì DISTINCT u.* dedupe toàn bộ row. Nhưng vấn đề là nó dùng sai cách: implicit cross join users, tasks tạo ra N row cho mỗi user (một row cho mỗi task của user đó), sau đó DISTINCT loại bỏ duplicate. Chi phí gấp N lần không cần thiết — database phải tạo ra Cartesian product rồi mới dedupe.

Fix đúng ngữ nghĩa: dùng EXISTS để tránh sinh ra nhiều row ngay từ đầu:

SELECT u.*
FROM users u
WHERE EXISTS (
SELECT 1 FROM tasks t WHERE t.assignee_id = u.id
);

Hoặc dùng DISTINCT với explicit JOIN nếu cần join thêm columns khác. Nguyên tắc: khi thấy mình cần DISTINCT để "fix" kết quả, hãy hỏi tại sao có duplicate — thường là JOIN sai.

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?

DISTINCT ON (assignee_id) hoạt động bằng cách: nhóm các row có cùng assignee_id, rồi giữ lại row đầu tiên của mỗi nhóm theo thứ tự ORDER BY quyết định. Để xác định "đầu tiên" trong mỗi nhóm, database cần sort các row trong nhóm trước — tức là sort theo assignee_id trước để gom nhóm, rồi sort theo created_at DESC để chọn row đầu.

Nếu ORDER BY created_at DESC (không có assignee_id đầu tiên), database không thể gom nhóm theo assignee_id một cách hiệu quả vì rows đã bị sắp xếp theo thứ tự khác — hệ quản trị sẽ báo lỗi (PostgreSQL: "SELECT DISTINCT ON expressions must match initial ORDER BY expressions"). Rule leftmost prefix đảm bảo planner có thể kết hợp grouping và row selection trong một lượt scan.

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?

MySQL trước 5.7 (và chế độ non-strict sau 5.7) thực hiện partial GROUP BY: cho phép column không nằm trong GROUP BY xuất hiện trong SELECT list mà không cần aggregate. Khi user_id được group, MySQL tự chọn một giá trị title bất kỳ trong nhóm đó — không cam kết giá trị nào. Đây là silent bug: query chạy, trả kết quả, nhưng title có thể là của bất kỳ task nào trong nhóm.

Engine tuân thủ SQL chuẩn (PostgreSQL, SQL Server, SQLite strict): mọi column trong SELECT phải nằm trong GROUP BY hoặc được bọc trong aggregate function. Rule này đúng đắn hơn vì buộc developer phải khai báo rõ ý định — "tôi muốn title nào: một title cụ thể, hay gộp tất cả lại?". Fix:

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

-- Fix 2: dung aggregate
SELECT user_id, MIN(title) AS first_title FROM tasks GROUP BY user_id;

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

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