SQL & Database — Thực chiến PostgreSQL/DISTINCT vs GROUP BY — cùng plan, khác intent
~14 phútTruy vấn cơ bản lượt xem

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

PG planner biến DISTINCT thành Group/HashAggregate. Khi nào dùng cái nào theo intent. DISTINCT ON PG-specific cho 'top N per group' không cần window.

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 PG-specific giải bài "top N per group" trong một dòng mà không cần window function.

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

Chạy EXPLAIN cho cả hai:

EXPLAIN SELECT DISTINCT user_id FROM tasks;
-- HashAggregate  (cost=1820.00..1870.00 rows=5000 ...)
--   Group Key: user_id
--   ->  Seq Scan on tasks ...

EXPLAIN SELECT user_id FROM tasks GROUP BY user_id;
-- HashAggregate  (cost=1820.00..1870.00 rows=5000 ...)
--   Group Key: user_id
--   ->  Seq Scan on tasks ...

PostgreSQL planner biến DISTINCT col thành Group/HashAggregate node — về bản chất giống GROUP BY col. 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

3. DISTINCT ON — PG-specific superpower

Bài toán: "lấy task gần nhất của mỗi user". Giải pháp thông thường là window function (Module 3 của khoá này 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.

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

5. GROUP BY — strict mode của PostgreSQL

PostgreSQL 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 trong PostgreSQL
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
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. Port sang PostgreSQL sẽ fail ngay tại compile time, đây là hành vi đúng đắn hơn.

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

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

7. Deep Dive — DISTINCT semantics

📚 Deep Dive — DISTINCT semantics
  • PostgreSQL Documentation 7.3.3 "DISTINCT" — cú pháp chính thức của DISTINCTDISTINCT ON, bao gồm rule về ORDER BY prefix. Đọc khi cần nắm chắc cú pháp và edge case.
  • PostgreSQL Wiki — Loose Index Scan — pattern DISTINCTGROUP BY với index, so sánh DISTINCT ON vs window function vs lateral join về performance. Đọc khi cần tối ưu query "top N per group" trên table lớn.

Ghi chú: PG docs cho cú pháp chính thức; Wiki cho perf comparison thực tế giữa các pattern. Đọc theo thứ tự đó.

8. 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.
  • PostgreSQL strict mode: mọi column trong SELECT phải nằm trong GROUP BY hoặc aggregate — MySQL trước 5.7 lax, port sang PG sẽ fail.
  • Forward link: Module 3 của khoá này sẽ đi sâu vào GROUP BY + HAVING + aggregate visibility rules, và window function thay thế DISTINCT ON khi cần portable.

9. Tự kiểm tra

Tự kiểm tra
Q1
Vì sao PostgreSQL planner biến `SELECT DISTINCT col FROM table` thành HashAggregate giống `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. PostgreSQL planner nhận ra sự tương đương này và biên dịch DISTINCT col thành cùng một HashAggregate node 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 — PostgreSQL sẽ báo lỗi "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 PostgreSQL → error. Giải thích tại sao MySQL cho phép, PostgreSQL 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.

PostgreSQL theo chuẩn SQL 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?