DHDataHọc
Lý thuyếtBài 20/307 phút đọc+10 XP

Gom nhóm với GROUP BY

Bạn sẽ học được

  • Gom dòng thành nhóm bằng GROUP BY và tính tổng hợp cho từng nhóm.
  • Viết đúng quy tắc: mọi cột không tổng hợp phải có trong GROUP BY.
  • Gom theo nhiều cột để tạo nhóm kết hợp.
  • Hiểu rằng NULL tạo thành một nhóm riêng.
  • Phân biệt WHERE (lọc dòng trước khi gom) và HAVING (lọc nhóm sau khi gom).

Khái niệm cốt lõi

Ba bài trước tính tổng hợp cho toàn bảng. GROUP BY chia bảng thành các nhóm rồi tính tổng hợp cho từng nhóm:

code
SELECT city, COUNT(*) AS so_khach
FROM customers
GROUP BY city
ORDER BY so_khach DESC;

Kết quả năm dòng:

cityso_khach
TP. Hồ Chí Minh4
Hà Nội3
Đà Nẵng2
Cần Thơ2
Hải Phòng1

Cách đọc câu lệnh: "gom các khách có cùng city thành một nhóm; với mỗi nhóm, đếm số dòng". PostgreSQL duyệt 12 khách, đặt mỗi người vào nhóm theo thành phố, rồi đếm từng nhóm. Tổng các nhóm luôn bằng 12 — một phép kiểm tra nên làm mỗi khi gom nhóm.

Quy tắc quan trọng nhất của GROUP BY: mỗi cột trong SELECT phải hoặc nằm trong GROUP BY, hoặc nằm trong một hàm tổng hợp. Câu này sai:

code
SELECT city, name, COUNT(*)
FROM customers
GROUP BY city;

Lỗi: column "customers.name" must appear in the GROUP BY clause or be used in an aggregate function. Lý do hợp lý: trong nhóm "Hà Nội" có ba khách, name không có một giá trị duy nhất để trả về. PostgreSQL từ chối thay vì chọn đại một cái.

Gom theo nhiều cột tạo nhóm kết hợp:

code
SELECT city, segment, COUNT(*) AS so_khach
FROM customers
GROUP BY city, segment
ORDER BY city, segment;

Mỗi cặp (city, segment) là một nhóm. Hà Nội có ba khách nhưng thuộc ba segment khác nhau, nên tạo ba nhóm — mỗi nhóm một dòng. Tổng số dòng kết quả có thể nhiều hơn số thành phố; đó là điều bình thường.

NULL tạo nhóm riêng. Nếu gom nhóm theo email, mọi khách không có email nằm chung một nhóm NULL:

code
SELECT email, COUNT(*) AS so_khach
FROM customers
GROUP BY email
ORDER BY email;

Kết quả có 11 dòng: mười email khác nhau, mỗi email một khách, cộng một dòng NULL với so_khach = 2. Hai khách Dũng Phạm và Inh Đỗ không có email, và họ bị gom chung vì GROUP BY coi mọi NULL là "bằng nhau" khi phân nhóm — dù NULL = NULL trong WHERE lại cho NULL. Đây là một trong ít chỗ SQL đối xử với NULL khác thường.

Bạn có thể gom theo biểu thức, không chỉ tên cột:

code
SELECT EXTRACT(MONTH FROM order_date) AS thang,
       COUNT(*) AS so_don
FROM orders
GROUP BY EXTRACT(MONTH FROM order_date)
ORDER BY thang;

Kết quả cho biết tháng nào có nhiều đơn nhất. PostgreSQL cho phép viết GROUP BY 1 (theo vị trí cột trong SELECT), nhưng tên cột hoặc biểu thức dễ đọc hơn khi quay lại sửa truy vấn sau vài tháng.

WHERE và HAVING khác nhau ở thời điểm chạy:

Mệnh đềLọc gìThời điểm
WHEREDòngTrước khi gom nhóm
HAVINGNhómSau khi gom và tính tổng hợp

Ví dụ lọc thành phố có từ ba khách trở lên:

code
SELECT city, COUNT(*) AS so_khach
FROM customers
GROUP BY city
HAVING COUNT(*) >= 3
ORDER BY so_khach DESC;

Phải dùng HAVING vì điều kiện dựa trên kết quả COUNT, thứ chưa tồn tại khi WHERE chạy. Còn lọc khách thuộc segment retail trước rồi mới đếm theo thành phố thì dùng WHERE:

code
SELECT city, COUNT(*) AS so_khach
FROM customers
WHERE segment = 'retail'
GROUP BY city
ORDER BY so_khach DESC;

Thứ tự thực thi đầy đủ: FROM → WHERE → GROUP BY → HAVING → SELECT → ORDER BY → LIMIT. Hiểu thứ tự này giải thích vì sao bí danh dùng được trong ORDER BY nhưng không dùng được trong WHERE. PostgreSQL có một ngoại lệ tiện lợi: bí danh của SELECT dùng được ngay trong GROUP BY (chuẩn SQL không cho phép) — nhưng HAVING thì vẫn phải viết biểu thức đầy đủ.

Ví dụ chạy được

Đếm khách theo segment:

code
SELECT segment, COUNT(*) AS so_khach
FROM customers
GROUP BY segment
ORDER BY so_khach DESC;

Ba dòng: retail 7, vip 3, wholesale 2. Tổng 12. Đây là mẫu "phân bố theo danh mục" — câu hỏi đầu tiên của gần như mọi báo cáo.

Doanh thu theo trạng thái đơn:

code
SELECT o.status,
       COUNT(*) AS so_don,
       SUM(oi.quantity * oi.unit_price) AS doanh_thu
FROM orders o
JOIN order_items oi ON oi.order_id = o.id
GROUP BY o.status
ORDER BY doanh_thu DESC;

Ba dòng: completed 13 đơn / 29.050.000; pending 2 đơn / 1.220.000; cancelled 1 đơn / 960.000. Lưu ý COUNT(*) đếm dòng trong kết quả nối — mỗi đơn có nhiều món nên số dòng lớn hơn số đơn. Muốn đếm đơn, dùng COUNT(DISTINCT o.id).

Sửa lại cho đúng:

code
SELECT o.status,
       COUNT(DISTINCT o.id) AS so_don,
       SUM(oi.quantity * oi.unit_price) AS doanh_thu
FROM orders o
JOIN order_items oi ON oi.order_id = o.id
GROUP BY o.status
ORDER BY doanh_thu DESC;

Kết quả: 13 / 2 / 1. Sự khác biệt giữa hai truy vấn là bài học đáng giá nhất của chương: khi đã nối bảng, COUNT(*) đếm dòng nối, không đếm thực thể gốc.

Số đơn theo khách, kể cả khách chưa mua:

code
SELECT c.name, COUNT(o.id) AS so_don
FROM customers c
LEFT JOIN orders o ON o.customer_id = c.id
GROUP BY c.name
ORDER BY so_don DESC, c.name;

Mười hai dòng. Bình Trần 3 đơn; An, Chi, Giang mỗi người 2 đơn; bảy khách còn lại 1 đơn; Hà Bùi 0 đơn. COUNT(o.id) là chìa khóa — COUNT(*) sẽ cho Hà Bùi 1 đơn dù cô chưa mua lần nào.

Giá và số sản phẩm theo nhóm:

code
SELECT category,
       COUNT(*) AS so_san_pham,
       MIN(price) AS re_nhat,
       MAX(price) AS dat_nhat,
       AVG(price) AS trung_binh
FROM products
GROUP BY category
ORDER BY so_san_pham DESC;

Gia dụng 4 sản phẩm, Điện tử 3, Sách 1, Thời trang 1. Tổng 9. Nhóm một sản phẩm có MIN = MAX = AVG — dấu hiệu dữ liệu quá mỏng để so sánh.

Gom theo hai cột:

code
SELECT city, segment, COUNT(*) AS so_khach
FROM customers
GROUP BY city, segment
ORDER BY city, segment;

Kết quả có 11 dòng (ít hơn 12 vì một cặp trùng: TP. Hồ Chí Minh + retail có hai khách). Đây là cách kiểm tra chéo phân bố — nếu một ô có số bất thường, bạn thấy ngay.

Thực hành

  1. Đếm số đơn theo từng status. Kết quả: completed 13, pending 2, cancelled 1.
  2. Tính doanh thu trung bình mỗi đơn theo status (tổng doanh thu chia số đơn). Completed ≈ 2.234.615,38; pending 610.000; cancelled 960.000.
  3. Gom khách theo city và segment, chỉ giữ nhóm có ít nhất 2 khách bằng HAVING. Bốn nhóm còn lại: TP. Hồ Chí Minh/retail (3), Hà Nội/wholesale (2), Đà Nẵng/retail (2), Cần Thơ/vip (2).
  4. Viết truy vấn đếm số khách có email theo thành phố, dùng COUNT(email) thay COUNT(*). So sánh với COUNT(*) để thấy thành phố nào thiếu email.

Lỗi thường gặp

Triệu chứngNguyên nhânCách sửa
must appear in the GROUP BY clauseChọn cột không tổng hợpThêm cột vào GROUP BY hoặc bỏ khỏi SELECT
aggregate functions are not allowed in WHEREDùng COUNT(...) trong WHEREChuyển điều kiện sang HAVING
Số đơn lớn hơn số đơn thậtCOUNT(*) sau JOIN một-nhiềuCOUNT(DISTINCT o.id)
Nhóm NULL gây bất ngờDữ liệu thiếu tạo nhóm riêngLọc WHERE cot IS NOT NULL nếu muốn bỏ
Tổng các nhóm không bằng tổng bảngWHERE lọc mất dòng hoặc HAVING loại nhómKiểm tra lại phép kiểm chéo

Lỗi thứ hai xuất hiện rất sớm khi mới học: viết WHERE COUNT(*) > 3 và nhận thông báo cú pháp. Hãy nhớ thứ tự — muốn lọc theo kết quả tổng hợp, mệnh đề phải chạy sau khi tổng hợp, và đó là HAVING.

Tóm tắt

  • GROUP BY cot chia dòng thành nhóm; hàm tổng hợp tính trên từng nhóm.
  • Mọi cột không tổng hợp trong SELECT phải có trong GROUP BY.
  • Gom nhiều cột tạo nhóm theo tổ hợp; NULL là một nhóm riêng.
  • WHERE lọc dòng trước khi gom, HAVING lọc nhóm sau khi gom.
  • Tổng các nhóm phải bằng tổng toàn bảng — dùng làm phép kiểm chéo.

Dùng ← → để chuyển bài