Tuần 3

Aggregation, GROUP BY, HAVING

SQL cho Interview — Lộ trình 10 tuần

Nhấn phím → để bắt đầu · T để xem mục lục

1. Tổng quan tuần 3

1.1

1.1 Mục tiêu tuần 3

Hai tuần trước bạn đã làm chủ cấp độ "một dòng vào, một dòng ra" — lọc và biến đổi. Tuần này nâng cấp lên cấp độ "nhiều dòng vào, một dòng ra" — gốc rễ của phân tích dữ liệu trong SQL.

Sau tuần 3 bạn cần làm được

Insight quan trọng
Một thống kê không chính thức: ~40% bài LeetCode SQL Medium chỉ cần kỹ thuật ở tuần 3 này. Làm chủ aggregation là thắng nửa cuộc đua.
1.2

1.2 Mental model: Phân hoạch và Reduce

GROUP BY là phân hoạch tập dữ liệu thành các nhóm theo khóa, sau đó reduce mỗi nhóm thành một dòng bằng aggregator.

orders (đầu vào) customer total A100 B50 A200 C300 B150 A400 C100 phân hoạch theo customer 3 nhóm trung gian A: 100, 200, 400 B: 50, 150 C: 300, 100 SUM cust sum A700 B200 C400

SQL: SELECT customer, SUM(total) FROM orders GROUP BY customer;

2. Aggregator cơ bản

2.1

2.1 COUNT — ba phiên bản, ba semantics khác nhau

Đây là chủ đề bị nhầm nhiều nhất. Cho bảng customers(id, country) với 5 dòng:

idcountry
1Vietnam
2Vietnam
3NULL
4Australia
5NULL
Biểu thứcKết quảÝ nghĩa
COUNT(*)5Đếm số dòng (kể cả dòng toàn NULL)
COUNT(country)3Đếm dòng có country không NULL
COUNT(DISTINCT country)2Đếm số quốc gia khác nhau (bỏ NULL)
COUNT(1)5Tương đương COUNT(*) — không phụ thuộc cột
Bẫy interview kinh điển
"Đếm số khách hàng có country" và "đếm số khách hàng" là hai câu hỏi khác nhau. Đọc đề kỹ — yêu cầu thường là một trong ba phiên bản trên, không phải COUNT(*) mặc định.
2.2

2.2 SUM, AVG, MIN, MAX — và NULL

Tất cả aggregator (trừ COUNT(*)) bỏ qua NULL trước khi tính:

Cột salaryAggregatorKết quả
{100, 200, NULL, 300}SUM(salary)600
{100, 200, NULL, 300}AVG(salary)200 (= 600/3, không phải 600/4)
{100, 200, NULL, 300}MIN(salary)100
{100, 200, NULL, 300}MAX(salary)300
{NULL, NULL}SUM(salary)NULL (không phải 0)
{} (rỗng)SUM(salary)NULL (không phải 0)
Bẫy AVG

"Trung bình lương" với NULL: AVG bỏ NULL ⇒ chia cho số dòng không-NULL. Nếu bạn muốn coi NULL là 0:

AVG(COALESCE(salary, 0))   -- coi NULL như 0
SUM(salary) / COUNT(*)     -- chia cho tổng số dòng
Hai biểu thức trên cho kết quả khác AVG mặc định. Trong interview, luôn xác nhận với người ra đề: "NULL có tham gia trung bình không?"
2.3

2.3 Numeric edge cases — integer division

SQL kế thừa quy tắc số học từ kiểu dữ liệu — và kiểu INT chia INT có thể không như bạn mong đợi.

Bẫy ratio / percentage
-- 3 đơn paid trên 7 đơn tổng
SELECT
    SUM(CASE WHEN status = 'paid' THEN 1 ELSE 0 END) / COUNT(*) AS rate
FROM orders;
-- MySQL/PostgreSQL: 0.4286 (decimal — OK)
-- SQL Server: 0 (integer division!)

SQL Server (và một số phương ngữ khác) thực hiện integer division khi cả hai vế đều INT. 3 / 7 = 0.

Cách an toàn

-- Cast một vế sang DECIMAL/FLOAT
SUM(CASE WHEN status = 'paid' THEN 1.0 ELSE 0 END) / COUNT(*)

-- Hoặc nhân với 1.0
1.0 * paid_count / total_count

-- AVG bỏ trắng việc đếm — tự nó đã là float trong nhiều dialect
AVG(CASE WHEN status = 'paid' THEN 1.0 ELSE 0 END)
Khuyên
Trong mọi ratio query, viết 1.0 trong CASE thay vì 1. Phòng integer division — chi phí 0, lợi ích bug-free.

3. GROUP BY

3.1

3.1 Cú pháp cơ bản và ràng buộc cột

SELECT customer_id, COUNT(*) AS n_orders, SUM(total) AS revenue
FROM orders
GROUP BY customer_id;
Quy tắc cốt lõi
Mọi cột trong SELECT phải hoặc nằm trong GROUP BY, hoặc được bọc trong aggregator. Không có lựa chọn thứ ba.
Lý do

Sau khi GROUP BY, mỗi dòng kết quả đại diện cho một nhóm — không phải một dòng gốc. Nếu bạn yêu cầu cột không thuộc khóa nhóm và không aggregate, SQL không biết chọn dòng nào trong nhóm.

-- SAI (chuẩn ANSI và PostgreSQL/SQL Server reject)
SELECT customer_id, name, COUNT(*)
FROM orders GROUP BY customer_id;
-- name không trong GROUP BY và không aggregate → ambiguous
Phương ngữ — bẫy MySQL
MySQL trước version 5.7 mặc định cho phép query trên (silent bug — chọn dòng tùy ý). Từ 5.7+ có chế độ ONLY_FULL_GROUP_BY bật mặc định, reject. Trong interview, code phải pass cả ANSI mode.
3.2

3.2 GROUP BY nhiều cột

Khi GROUP BY nhiều cột, nhóm được định nghĩa bởi tổ hợp các giá trị.

-- Đếm số đơn theo cặp (customer, status)
SELECT customer_id, status, COUNT(*) AS n
FROM orders
GROUP BY customer_id, status;

Một customer có thể xuất hiện nhiều dòng nếu có đơn ở nhiều status khác nhau. Ví dụ:

customer_idstatusn
1paid3
1cancelled1
2paid5
Pattern interview — daily metrics
-- Số đơn và doanh thu theo ngày + theo status
SELECT order_date, status, COUNT(*) AS n_orders, SUM(total) AS revenue
FROM orders
GROUP BY order_date, status
ORDER BY order_date, status;
3.3

3.3 GROUP BY và NULL

Khác với hầu hết hành vi của NULL trong SQL, GROUP BY coi tất cả NULL là cùng một nhóm.

Ví dụ
Bảng customers có 4 khách: 2 ở Vietnam, 1 ở Australia, 1 NULL country.
SELECT country, COUNT(*) FROM customers GROUP BY country;
Trả về 3 dòng:
countrycount
Vietnam2
Australia1
NULL1
Quan trọng
Trong GROUP BY, NULL hoạt động "giống một giá trị bình thường" — đây là ngoại lệ so với quy tắc 3VL chung. Lý do: nếu không nhóm NULL lại, mỗi NULL sẽ là một nhóm riêng (vì NULL ≠ NULL) → bùng nổ kết quả.
Bài toán thực
Câu hỏi: "đếm số đơn theo từng quốc gia, kể cả khách chưa khai báo quốc gia" — query trên đã làm đúng. Câu hỏi: "đếm số đơn theo từng quốc gia ĐÃ XÁC ĐỊNH" — phải thêm WHERE country IS NOT NULL.
3.4

3.4 Quy tắc sạch khi viết GROUP BY

  1. Nhất quán column list. Cột không-aggregate trong SELECT phải xuất hiện đầy đủ trong GROUP BY. Đừng dùng MySQL old-mode để "ăn gian".
  2. Đặt tên alias rõ. COUNT(*) nên là n_orders, không phải cnt.
  3. Group theo cột định danh, không theo expression nếu có thể. GROUP BY customer_id tốt hơn GROUP BY UPPER(customer_email) nếu hai cột tương đương về semantic.
  4. Chỉ aggregate cột cần thiết. Đừng SELECT customer_id, SUM(total), MAX(total), MIN(total), AVG(total) ... nếu chỉ cần SUM. Performance.

Có thể GROUP BY theo alias không?

-- PostgreSQL/MySQL cho phép
SELECT YEAR(order_date) AS y, COUNT(*) FROM orders GROUP BY y;

-- Standard ANSI yêu cầu lặp lại expression — chạy mọi nơi
SELECT YEAR(order_date) AS y, COUNT(*) FROM orders GROUP BY YEAR(order_date);

Trong portable code, lặp expression. Trong code dialect-specific, dùng alias cho ngắn.

4. WHERE vs HAVING

4.1

4.1 Thứ tự thực thi logic — neo cho cả tuần

Đây là sơ đồ phải thuộc. Cú pháp viết theo thứ tự nào không quan trọng — engine luôn thực thi theo logic này:

FROM lấy bảng WHERE lọc dòng GROUP BY phân hoạch HAVING lọc nhóm SELECT chiếu cột ORDER + LIMIT WHERE chạy TRƯỚC GROUP BY · HAVING chạy SAU GROUP BY
Hệ quả
4.2

4.2 WHERE vs HAVING — luật phân chia

Tiêu chíWHEREHAVING
Lọc cái gìDòng gốcNhóm sau khi GROUP BY
Khi nào chạyTrước GROUP BYSau GROUP BY
Dùng được aggregator?Không (WHERE COUNT(*) > 5 sai) (HAVING COUNT(*) > 5 đúng)
Dùng được cột bất kỳ?Có (mọi cột bảng)Chỉ cột trong GROUP BY hoặc aggregator
So sánh trực tiếp
-- 1. "Khách hàng có hơn 5 đơn paid" — KHÔNG phải hơn 5 đơn tổng
SELECT customer_id, COUNT(*) AS n_paid
FROM orders
WHERE  status = 'paid'      -- lọc dòng trước khi đếm
GROUP BY customer_id
HAVING COUNT(*) > 5;        -- lọc nhóm theo số đếm

Hai bộ lọc làm hai việc khác nhau và không thay thế cho nhau được.

4.3

4.3 Pattern: chọn WHERE hay HAVING?

Quy tắc đơn giản: nếu predicate dùng aggregator, đặt vào HAVING. Ngược lại, đặt vào WHERE.

Tình huống "có thể đặt cả hai"
Yêu cầu: "khách Vietnam có hơn 3 đơn".
-- Cách 1: filter dòng trước
SELECT customer_id FROM orders o
JOIN customers c ON c.customer_id = o.customer_id
WHERE c.country = 'Vietnam'
GROUP BY o.customer_id
HAVING COUNT(*) > 3;

-- Cách 2: HAVING làm tất (không khuyên — chậm hơn)
SELECT o.customer_id FROM orders o
JOIN customers c ON c.customer_id = o.customer_id
GROUP BY o.customer_id, c.country
HAVING c.country = 'Vietnam' AND COUNT(*) > 3;
Khuyên
Đẩy filter xuống WHERE bất cứ khi nào có thể. WHERE chạy trước GROUP BY → ít dòng hơn vào aggregation → nhanh hơn. Đây là tối ưu cơ bản optimizer thường tự làm, nhưng viết rõ vẫn tốt cho người đọc.

5. Conditional Aggregation

5.1

5.1 SUM(CASE WHEN ...) — vũ khí mạnh nhất ở level này

Đây là pattern giải được khoảng 30% bài LeetCode SQL Medium. Ý tưởng: aggregate có điều kiện bằng cách bọc CASE WHEN bên trong aggregator.

Ví dụ — đếm theo nhiều điều kiện trong một query
SELECT
    COUNT(*)                                              AS total_orders,
    SUM(CASE WHEN status = 'paid'      THEN 1 ELSE 0 END) AS n_paid,
    SUM(CASE WHEN status = 'cancelled' THEN 1 ELSE 0 END) AS n_cancelled,
    SUM(CASE WHEN total > 1000         THEN 1 ELSE 0 END) AS n_big
FROM orders;

Kết quả: một dòng, 4 cột — tất cả tính từ một lần quét bảng duy nhất. Hiệu quả gấp nhiều lần chạy 4 query riêng.

Tư duy
Coi mỗi CASE WHEN ... THEN 1 ELSE 0 END như một indicator function \(\mathbf{1}[\text{condition}]\). Tổng các indicator = số dòng thỏa điều kiện.
5.2

5.2 COUNT(CASE WHEN) — viết ngắn hơn

Trick: COUNT bỏ NULL. Nếu CASE không có ELSE thì giá trị mặc định là NULL — ta tận dụng để viết ngắn:

-- Hai biểu thức tương đương:
SUM(CASE WHEN status = 'paid' THEN 1 ELSE 0 END)
COUNT(CASE WHEN status = 'paid' THEN 1 END)
-- (Không có ELSE → giá trị NULL → COUNT bỏ qua)
Pattern cleanup
-- Trước
SELECT
    SUM(CASE WHEN status='paid'      THEN 1 ELSE 0 END) AS n_paid,
    SUM(CASE WHEN status='cancelled' THEN 1 ELSE 0 END) AS n_cancelled
FROM orders;

-- Sau (gọn hơn)
SELECT
    COUNT(CASE WHEN status='paid'      THEN 1 END) AS n_paid,
    COUNT(CASE WHEN status='cancelled' THEN 1 END) AS n_cancelled
FROM orders;

Cả hai đều đúng, độ rõ ràng tương đương — chọn pattern bạn dùng nhất quán. Trong code review, tránh trộn cả hai trong cùng một query.

5.3

5.3 Pivot pattern — biến dòng thành cột

Đây là một trong những use case đẹp nhất của conditional aggregation: chuyển dữ liệu "long format" thành "wide format".

Bài toán

Bảng sales(customer_id, status, amount) ở dạng long. Yêu cầu: với mỗi khách, hiển thị cùng dòng các cột paid, pending, cancelled.

SELECT
    customer_id,
    SUM(CASE WHEN status = 'paid'      THEN amount ELSE 0 END) AS paid,
    SUM(CASE WHEN status = 'pending'   THEN amount ELSE 0 END) AS pending,
    SUM(CASE WHEN status = 'cancelled' THEN amount ELSE 0 END) AS cancelled
FROM sales
GROUP BY customer_id;
Khi nào dùng
Khi cần báo cáo dạng matrix (ví dụ doanh thu theo tháng × theo region). Trong các phương ngữ có PIVOT (SQL Server, Oracle), có cú pháp riêng — nhưng conditional aggregation chạy ở mọi nơi. Trong interview, đây là approach an toàn.
5.4

5.4 Ratio và percentage queries

Pattern phổ biến: tính tỷ lệ dòng thỏa điều kiện X trên tổng số dòng (hoặc tổng có điều kiện Y).

Ví dụ — tỷ lệ confirmation theo customer
SELECT
    customer_id,
    COUNT(*)                                              AS n_total,
    SUM(CASE WHEN status = 'paid' THEN 1 ELSE 0 END)      AS n_paid,
    -- 1.0 phòng integer division
    SUM(CASE WHEN status = 'paid' THEN 1.0 ELSE 0 END) / COUNT(*) AS paid_rate
FROM orders
GROUP BY customer_id;
Ví dụ — chia trên một subset
"Trong các đơn paid, bao nhiêu phần trăm là big (>1000)?" — mẫu số là số đơn paid, không phải tổng.
SELECT
    SUM(CASE WHEN status='paid' AND total > 1000 THEN 1.0 ELSE 0 END)
    / NULLIF(SUM(CASE WHEN status='paid' THEN 1 ELSE 0 END), 0) AS big_paid_rate
FROM orders;

Chú ý NULLIF(..., 0) phòng chia cho 0 khi không có đơn paid nào.

5.5

5.5 FILTER clause — chuẩn ANSI gọn hơn

Một số phương ngữ hỗ trợ FILTER (WHERE ...) — cú pháp ANSI cho conditional aggregation, đọc tự nhiên hơn CASE WHEN.

-- PostgreSQL, SQLite 3.30+
SELECT
    customer_id,
    COUNT(*)                              AS n_total,
    COUNT(*) FILTER (WHERE status='paid') AS n_paid,
    SUM(total) FILTER (WHERE status='paid') AS revenue_paid
FROM orders
GROUP BY customer_id;
Phương ngữHỗ trợ FILTER?
PostgreSQLCó (từ 9.4)
SQLiteCó (từ 3.30)
MySQLKhông — phải CASE WHEN
SQL ServerKhông — phải CASE WHEN
Khuyên cho interview
LeetCode mặc định MySQL ⇒ dùng CASE WHEN. StrataScratch / DataLemur có PostgreSQL mode ⇒ FILTER cho code đẹp. Biết cả hai để dùng đúng platform.

6. Worked Examples

6.1

6.1 LeetCode 596 — Classes With at Least 5 Students

Schema

Courses(student VARCHAR, class VARCHAR) -- (student, class) là composite key — không có dòng trùng

Yêu cầu

Trả về tên các lớp có ít nhất 5 học sinh.

Phân rã

  1. Phân hoạch theo lớp → GROUP BY class
  2. Đếm học sinh mỗi lớp → COUNT(*) hoặc COUNT(DISTINCT student)
  3. Lọc nhóm có count ≥ 5 → HAVING

Lời giải

SELECT class
FROM Courses
GROUP BY class
HAVING COUNT(DISTINCT student) >= 5;
Vì sao DISTINCT?

Đề bài nói (student, class) là composite key — không có trùng. Nhưng các bài tương tự trong production thường KHÔNG có ràng buộc này. COUNT(DISTINCT student) an toàn hơn COUNT(*) trong general case.

Trong interview, nói rõ: "Tôi dùng DISTINCT để phòng dữ liệu có dòng trùng. Nếu được đảm bảo bảng không có duplicate, COUNT(*) đủ."

6.2

6.2 LeetCode 1741 — Find Total Time Spent

Schema

Employees(emp_id INT, event_day DATE, in_time INT, out_time INT) -- (emp_id, event_day, in_time) là composite key -- in_time, out_time là phút trong ngày (0-1439)

Yêu cầu

Với mỗi (emp_id, event_day), tính tổng số phút làm việc. Một nhân viên có thể vào/ra nhiều lần trong ngày.

Phân rã

  1. Mỗi dòng = một session, thời gian = (out_time − in_time)
  2. Phân hoạch theo (emp_id, event_day) → GROUP BY 2 cột
  3. Tổng thời gian mỗi nhóm → SUM

Lời giải

SELECT
    event_day AS day,
    emp_id,
    SUM(out_time - in_time) AS total_time
FROM Employees
GROUP BY event_day, emp_id;
Nguyên tắc
Nhận ra "tổng tích lũy theo nhóm 2 chiều" là instinct cần xây. Đề bài có thể được phrase rất khác ("tổng doanh thu theo ngày × theo region", "tổng giờ học theo môn × theo học sinh") — pattern là một.
6.3

6.3 LeetCode 1393 — Capital Gain/Loss (Medium)

Schema

Stocks(stock_name VARCHAR, operation VARCHAR, operation_day INT, price INT) -- operation: 'Buy' hoặc 'Sell'

Yêu cầu

Với mỗi stock, tính tổng lãi/lỗ = (tổng tiền Sell) − (tổng tiền Buy).

Phân rã

Với mỗi stock: cần một số dương từ Sell và một số âm từ Buy, sau đó tổng. Dùng conditional aggregation:

Lời giải

-- Cách 1: SUM(CASE WHEN)
SELECT
    stock_name,
    SUM(CASE WHEN operation = 'Sell' THEN  price
             WHEN operation = 'Buy'  THEN -price
        END) AS capital_gain_loss
FROM Stocks
GROUP BY stock_name;

-- Cách 2: tách hai SUM rồi trừ
SELECT
    stock_name,
    SUM(CASE WHEN operation = 'Sell' THEN price ELSE 0 END)
  - SUM(CASE WHEN operation = 'Buy'  THEN price ELSE 0 END) AS capital_gain_loss
FROM Stocks
GROUP BY stock_name;
Take-away
Khi yêu cầu là "A trừ B" và A, B đến từ cùng bảng phân biệt bởi một cột status: dùng conditional aggregation. Đừng nghĩ đến JOIN — overkill.

7. Knowledge Check

7.Q

Knowledge Check — Tuần 3

Q1: Bảng payments(amount) có 4 dòng: 100, 200, NULL, 300. AVG(amount) trả gì?
AVG bỏ NULL trước khi tính. Tổng 600 chia cho số dòng không-NULL = 3 → 200. Nếu muốn coi NULL là 0, dùng AVG(COALESCE(amount, 0)) = 150.
Q2: Yêu cầu "khách hàng paid hơn 5 đơn". Query nào ĐÚNG?
A sai vì WHERE không dùng được aggregator. C sai vì status không trong GROUP BY (dù MySQL old-mode chấp nhận). D sai vì WHERE phải đứng trước GROUP BY. B đúng: WHERE lọc dòng trước, HAVING lọc nhóm sau.
Q3: Bảng orders(status) có 7 dòng: 3 'paid', 2 'pending', 2 NULL. Query SELECT status, COUNT(*) FROM orders GROUP BY status trả bao nhiêu dòng?
GROUP BY là ngoại lệ: tất cả NULL được nhóm vào một group duy nhất. Kết quả 3 dòng: paid (3), pending (2), NULL (2). Đây là khác biệt quan trọng so với so sánh = NULL trong WHERE (luôn UNKNOWN).

8. Bài tập về nhà

8.1

8.1 Bài tập LeetCode (5 problem)

  1. 596. Classes With at Least 5 Students (Easy) — đã giải tại lớp.
  2. 1741. Find Total Time Spent by Each Employee (Easy) — đã giải tại lớp.
  3. 1393. Capital Gain/Loss (Medium) — conditional aggregation, đã giải tại lớp.
  4. 1729. Find Followers Count (Easy) — GROUP BY + COUNT, sắp xếp theo user_id.
  5. 1907. Count Salary Categories (Medium) — bài này có một bẫy: category không có nhân viên nào vẫn phải xuất hiện trong kết quả với count = 0. Gợi ý: GROUP BY không giúp được; nghĩ đến UNION 3 query, mỗi query một category.

Bonus — phân tích bug

Cho query sau, dự đoán kết quả trên dữ liệu có NULL và giải thích:

SELECT customer_id,
       COUNT(*)        AS a,
       COUNT(country)  AS b,
       COUNT(DISTINCT country) AS c,
       SUM(CASE WHEN country IS NULL THEN 1 ELSE 0 END) AS d
FROM customers
GROUP BY customer_id;

Quan hệ giữa a, b, c, d? Cái nào có thể bằng 0? Cái nào có thể NULL?

8.2

8.2 Đọc thêm và tự kiểm tra

Đọc

Tự kiểm tra (không nhìn slide)

Tuần sau — JOIN sâu
Tuần 4: INNER, LEFT/RIGHT/FULL OUTER, CROSS, self-join, anti-join. Đây là nửa thứ hai của bộ kỹ năng nền tảng. Sau đó (tuần 5) là subquery — và bạn sẽ giải được hầu hết bài Easy LeetCode trong dưới 5 phút.

Mục lục

Nhấn T hoặc Escape để đóng