Bài 3 ghép nhiều bảng lại thành 1 tập kết quả lớn. Bài này làm ngược lại: gộp nhiều hàng thành 1 con số duy nhất — tổng doanh thu, số lượng khách hàng, giá trị đơn trung bình. Đây là nhóm câu hỏi kinh doanh phổ biến nhất trong thực tế ("doanh thu tháng này bao nhiêu?", "danh mục nào bán chạy nhất?"), và GROUP BY + hàm tổng hợp (aggregate function) là công cụ duy nhất trong SQL trả lời được. Bài này cũng đi sâu vào 1 cạm bẫy SQLite gây khó chịu nhất cho người quen PostgreSQL/MySQL: SQLite không ép buộc quy tắc "mọi cột SELECT phải nằm trong GROUP BY hoặc trong hàm tổng hợp" mà nhiều engine khác ép buộc nghiêm ngặt.

1. Hàm Tổng Hợp: Gộp Nhiều Hàng Thành 1 Giá Trị

SQLite có 5 hàm tổng hợp cốt lõi: COUNT, SUM, AVG, MIN, MAX. Không có GROUP BY, chúng gộp toàn bộ bảng thành đúng 1 hàng kết quả.

Tình huống 1 — Đếm hàng: 3 cách viết COUNT hoàn toàn khác nghĩa:

SELECT
  COUNT(*) AS total_orders,          -- đếm MỌI hàng, kể cả cột NULL
  COUNT(customer_id) AS with_customer, -- đếm hàng có customer_id KHÔNG NULL
  COUNT(DISTINCT customer_id) AS unique_customers -- đếm số khách hàng KHÁC NHAU
FROM orders;

Chạy thử: COUNT(*) trả về 28 (tổng số đơn), nhưng COUNT(customer_id) chỉ trả về 24 — vì 4 đơn khách vãng lai có customer_id IS NULL (đúng như đã xác nhận ở Bài 3) và COUNT(column) luôn bỏ qua giá trị NULL, chỉ COUNT(*) mới đếm mọi hàng vô điều kiện. Đây là điểm khác biệt bị hiểu nhầm nhiều nhất giữa COUNT(*)COUNT(1 cột cụ thể).

Tình huống 2 — Doanh thu & giá trị đơn trung bình:

SELECT
  SUM(total_amount) AS total_revenue,
  AVG(total_amount) AS avg_order_value,
  MIN(total_amount) AS smallest_order,
  MAX(total_amount) AS largest_order
FROM orders;

AVG(total_amount) ở đây ra khoảng 128.27 — tính trên toàn bộ 28 đơn, bất kể trạng thái (kể cả đơn cancelled/refunded). Với báo cáo thực tế, thường cần lọc trước bằng WHERE:

SELECT AVG(total_amount) AS avg_delivered_value
FROM orders
WHERE status = 'delivered';

Kết quả tăng lên ~140.01 — cao hơn mức trung bình toàn bộ, vì loại bỏ các đơn cancelled/refunded vốn có xu hướng giá trị thấp hơn trong dataset này. Đây là ví dụ thực tế cho thấy WHERE lọc trước khi hàm tổng hợp tính toán — thứ tự này sẽ quan trọng hơn nữa khi so sánh với HAVING ở mục 5.

Tình huống 3 — Đếm khách hàng đang hoạt động (đếm theo điều kiện):

SELECT
  COUNT(*) AS total_customers,
  SUM(is_active) AS active_customers
FROM customers;

Kết quả: 12 khách hàng tổng, 10 đang hoạt động. Mẹo hay: SUM(is_active) hoạt động được vì is_active lưu 0/1 — cộng dồn 1 cột toàn giá trị 0/1 chính là đếm số hàng có giá trị 1. Cách viết tổng quát hơn (dùng được cho mọi điều kiện, không chỉ cột 0/1 sẵn có) là SUM(CASE WHEN is_active = 1 THEN 1 ELSE 0 END), hoặc gọn hơn trong SQLite: COUNT(*) FILTER (WHERE is_active = 1).

🔬 Đào sâu: hàm tổng hợp bỏ qua NULL hoàn toàn — kể cả AVG
SUM, AVG, MIN, MAX, và COUNT(column) đều bỏ qua hàng có giá trị NULL ở cột đang tổng hợp — chúng không coi NULL là 0. Điều này quan trọng nhất với AVG: nếu 1 cột có 10 hàng, trong đó 4 hàng NULL và 6 hàng có giá trị, AVG(cột) chia tổng cho 6 (số hàng không NULL), không phải 10. Nếu ý định thực sự là "coi thiếu dữ liệu là 0" (ví dụ: sản phẩm chưa từng được đánh giá tính là 0 sao thay vì bỏ qua khỏi trung bình), phải chủ động dùng AVG(COALESCE(cột, 0))AVG mặc định sẽ không làm vậy thay bạn.

2. GROUP BY: Gộp Nhóm Theo 1 Hoặc Nhiều Cột

GROUP BY cột chia bảng thành các nhóm có cùng giá trị ở cột, rồi hàm tổng hợp tính riêng cho từng nhóm thay vì toàn bảng.

Tình huống 1 — Doanh thu theo danh mục sản phẩm (đã thấy ở Bài 3, giờ mở rộng):

SELECT p.category,
       SUM(oi.quantity * oi.unit_price) AS revenue,
       COUNT(DISTINCT oi.order_id) AS num_orders
FROM order_items oi
INNER JOIN products p ON oi.product_id = p.product_id
GROUP BY p.category
ORDER BY revenue DESC;

Kết quả 4 hàng, mỗi hàng 1 danh mục: Electronics 1633.87 (9 đơn), Office 1117.94 (5 đơn), Accessories 556.83 (9 đơn), Home Appliances 282.97 (6 đơn). Lưu ý COUNT(DISTINCT oi.order_id) chứ không phải COUNT(*) — vì 1 đơn hàng có thể có nhiều dòng order_items cùng danh mục, COUNT(*) sẽ đếm trùng.

Tình huống 2 — Số đơn & doanh thu theo trạng thái:

SELECT status, COUNT(*) AS num_orders, SUM(total_amount) AS total_revenue
FROM orders
GROUP BY status
ORDER BY num_orders DESC;

Kết quả: delivered chiếm áp đảo với 19/28 đơn (2660.21), các trạng thái còn lại (shipped, refunded, pending, cancelled) chỉ 2 đơn mỗi loại, processing đúng 1 đơn. Đây là kiểu báo cáo "phân bố trạng thái" cực kỳ phổ biến trong dashboard vận hành.

Tình huống 3 — Nhóm theo nhiều cột cùng lúc (compound grouping): phân tích trạng thái đơn hàng theo từng quốc gia.

SELECT c.country, o.status, COUNT(*) AS cnt
FROM orders o
INNER JOIN customers c ON o.customer_id = c.customer_id
GROUP BY c.country, o.status
ORDER BY c.country, o.status;

GROUP BY c.country, o.status tạo ra 1 nhóm cho mỗi tổ hợp duy nhất của cả 2 cột — không phải nhóm theo country rồi lại nhóm theo status một cách độc lập. Chạy thử sẽ thấy Việt Nam xuất hiện ở nhiều hàng (mỗi trạng thái 1 hàng riêng), trong khi các quốc gia chỉ có 1-2 đơn thường chỉ có 1-2 hàng.

ℹ️ Thứ tự thực thi thật của SQL — vì sao GROUP BY chạy sau WHERE
Dù viết theo thứ tự SELECT → FROM → WHERE → GROUP BY → HAVING → ORDER BY, engine không thực thi theo đúng thứ tự đọc. Thứ tự thực thi thật là: FROM/JOIN (tạo tập hàng nguồn) → WHERE (lọc hàng thô) → GROUP BY (gộp nhóm) → hàm tổng hợp tính trên từng nhóm → HAVING (lọc nhóm) → SELECT (chọn cột hiển thị) → ORDER BYLIMIT. Hiểu đúng thứ tự này giải thích vì sao WHERE không được dùng tên alias đặt trong SELECT (vì WHERE chạy trước khi SELECT tồn tại), trong khi ORDER BY dùng được (vì nó chạy sau SELECT).

3. Cạm Bẫy: Cột Không GROUP BY, Không Tổng Hợp

Theo chuẩn SQL, mọi cột xuất hiện trong SELECT của 1 câu lệnh có GROUP BY phải là: (a) cột nằm trong GROUP BY, hoặc (b) cột nằm trong 1 hàm tổng hợp. PostgreSQL/MySQL (từ 5.7.5+) ép buộc nghiêm ngặt quy tắc này — vi phạm sẽ bị từ chối với lỗi cú pháp. SQLite thì không.

-- Hợp lệ trong SQLite, nhưng PostgreSQL sẽ TỪ CHỐI với lỗi:
-- "column "products.product_name" must appear in the GROUP BY clause..."
SELECT category, product_name, COUNT(*) AS num_products
FROM products
GROUP BY category;

Chạy thử trong sân chơi bên dưới: SQLite trả về đủ 4 hàng, mỗi hàng có 1 product_name "trông có vẻ hợp lý" — nhưng đó chỉ là hàng SQLite tình cờ quét thấy trước tiên trong mỗi nhóm (thường là hàng có product_id nhỏ nhất do thứ tự lưu trữ vật lý, nhưng đây không phải hành vi được đảm bảo theo chuẩn — không nên dựa vào nó). Không có mối liên hệ logic nào giữa product_name hiển thị và COUNT(*) đứng cạnh nó.

🕳️ Cạm bẫy thường gặp: code chạy đúng trên SQLite, vỡ khi deploy lên PostgreSQL
Vì SQLite dễ dãi, lập trình viên viết query kiểu trên trong lúc phát triển (chạy được, kết quả "nhìn có vẻ đúng") rồi bất ngờ gặp lỗi khi ứng dụng chuyển sang PostgreSQL cho môi trường sản xuất — đúng như bạn đã setup ở lab Docker Bài 2. Muốn viết query hoạt động đúng trên cả 2 engine, luôn đưa mọi cột không tổng hợp vào GROUP BY, hoặc nếu cần hiển thị 1 giá trị đại diện có chủ đích (không phải ngẫu nhiên), dùng rõ ràng MIN()/MAX(). Ngoại lệ đáng chú ý: SQLite có 1 bảo đảm đặc biệt — nếu câu lệnh có đúng 1 hàm MIN() hoặc MAX(), các cột bare khác sẽ lấy giá trị từ đúng hàng tạo ra min/max đó (không phải ngẫu nhiên nữa). Ví dụ SELECT category, product_name, MAX(unit_price) FROM products GROUP BY category luôn trả về đúng tên sản phẩm đắt nhất mỗi danh mục — nhưng bảo đảm này chỉ áp dụng khi có chính xác 1 MIN/MAX trong câu lệnh, không áp dụng cho COUNT/SUM/AVG.

4. HAVING vs WHERE: Lọc Trước vs Sau Khi Gộp Nhóm

WHERE lọc từng hàng thô trước khi gộp nhóm — không dùng được với kết quả hàm tổng hợp (vì lúc WHERE chạy, hàm tổng hợp chưa tính xong, xem lại callout mục 2). HAVING lọc từng nhóm sau khi đã tổng hợp — dùng được điều kiện trên kết quả SUM/COUNT/AVG.

Tình huống 1 — Chỉ giữ danh mục có doanh thu > 300:

SELECT p.category, SUM(oi.quantity * oi.unit_price) AS revenue
FROM order_items oi
INNER JOIN products p ON oi.product_id = p.product_id
GROUP BY p.category
HAVING revenue > 300
ORDER BY revenue DESC;

Kết quả chỉ còn 3/4 danh mục — Home Appliances (doanh thu 282.97, thấp hơn ngưỡng 300) bị loại khỏi báo cáo. Thử đổi HAVING thành WHERE revenue > 300 (giữ nguyên vị trí, tức là đặt trước GROUP BY) sẽ báo lỗi ngay — revenue là alias của kết quả SUM(), chưa tồn tại ở thời điểm WHERE chạy.

Tình huống 2 — Chỉ giữ danh mục thu hút từ 5 khách hàng khác nhau trở lên (đo độ phổ biến, không phải doanh thu):

SELECT p.category, COUNT(DISTINCT o.customer_id) AS num_customers
FROM order_items oi
INNER JOIN products p ON oi.product_id = p.product_id
INNER JOIN orders o ON oi.order_id = o.order_id
GROUP BY p.category
HAVING num_customers >= 5
ORDER BY num_customers DESC;

Kết quả: Electronics (7 khách), Accessories (6 khách), Office (5 khách) — Home Appliances (chỉ 3 khách khác nhau) bị loại, dù doanh thu của nó không phải tiêu chí ở truy vấn này.

Tình huống 3 — Kết hợp cả 2: lọc hàng thô trước, rồi lọc nhóm sau: chỉ tính đơn delivered, rồi chỉ giữ khách có tổng chi tiêu > 200.

SELECT customer_id, SUM(total_amount) AS total_spent
FROM orders
WHERE status = 'delivered'
GROUP BY customer_id
HAVING total_spent > 200
ORDER BY total_spent DESC;

WHERE status = 'delivered' loại bỏ đơn không phải delivered trước khi gộp nhóm theo khách — nếu 1 khách có 1 đơn 500$ đã bị hủy và 1 đơn 50$ đã giao, WHERE đảm bảo chỉ 50$ được tính vào tổng, không phải 550$. HAVING total_spent > 200 sau đó lọc tiếp theo tổng đã tính đúng.

Bảng So Sánh WHERE vs HAVING

Tiêu chí WHERE HAVING
Lọc trên đối tượng nào? Từng hàng thô (trước khi gộp nhóm) Từng nhóm (sau khi gộp nhóm và tính hàm tổng hợp)
Dùng được hàm tổng hợp (SUM/COUNT...)? Không — hàm tổng hợp chưa tồn tại lúc này Có — đây chính là lý do HAVING tồn tại
Bắt buộc phải có GROUP BY? Không Về lý thuyết không bắt buộc, nhưng gần như luôn đi kèm GROUP BY trong thực tế
Ảnh hưởng hiệu năng Lọc sớm — giảm số hàng cần gộp nhóm, thường nhanh hơn Lọc muộn — vẫn phải gộp nhóm toàn bộ trước khi loại bớt

Sân chơi tương tác: SQL Workbench Với Biểu Đồ Cột Tự Động

Chạy bất kỳ query nào trả về đúng 2 cột (1 cột nhãn + 1 cột số) — workbench sẽ tự động vẽ thêm biểu đồ cột ngay bên dưới bảng kết quả, không cần bấm nút riêng. Thử ngay ví dụ đầu tiên (doanh thu theo danh mục) để thấy biểu đồ xuất hiện.

🗄️ Sân chơi tương tác: SQL Workbench + Biểu Đồ Cột (TechMart)

customers

Cột Kiểu
customer_id INTEGER PRIMARY KEY
full_name TEXT NOT NULL
email TEXT
country TEXT
signup_date TEXT
is_active INTEGER

products

Cột Kiểu
product_id INTEGER PRIMARY KEY
product_name TEXT NOT NULL
category TEXT
unit_price REAL
stock_quantity INTEGER

orders

Cột Kiểu
order_id INTEGER PRIMARY KEY
customer_id INTEGER (NULL = khách vãng lai)
order_date TEXT
status TEXT
total_amount REAL

order_items

Cột Kiểu
order_item_id INTEGER PRIMARY KEY
order_id INTEGER
product_id INTEGER
quantity INTEGER
unit_price REAL
Kết quả sẽ hiện ở đây sau khi chạy query...
Đang tải SQLite-WASM engine...
sql-techmart-seed.sql
Đang tải...
sql-aggregate-group-by.js

⬇ Tải file schema + dữ liệu mẫu TechMart (.sql) — chạy được trực tiếp bằng sqlite3 CLI hoặc DB Browser for SQLite trên máy bạn.

Trắc nghiệm ôn tập

Câu 1: Với bảng orders có 28 hàng, trong đó 4 hàng có customer_id IS NULL, COUNT(customer_id) trả về bao nhiêu?

Trắc nghiệm ôn tập

Câu 2: Một cột rating có 10 hàng: 6 hàng có giá trị số, 4 hàng NULL (chưa được đánh giá). AVG(rating) sẽ chia tổng cho bao nhiêu?

Trắc nghiệm ôn tập

Câu 3: Truy vấn SELECT category, product_name, COUNT(*) FROM products GROUP BY category; chạy được trên SQLite nhưng bị PostgreSQL từ chối. Vì sao?

Trắc nghiệm ôn tập

Câu 4: Vì sao WHERE revenue > 300 (với revenue là alias của SUM(...)) báo lỗi, trong khi HAVING revenue > 300 chạy bình thường?

Trắc nghiệm ôn tập

Câu 5: 1 khách có 2 đơn: 1 đơn 500$ đã cancelled, 1 đơn 50$ đã delivered. Với truy vấn SELECT customer_id, SUM(total_amount) FROM orders WHERE status = 'delivered' GROUP BY customer_id;, tổng của khách này sẽ là bao nhiêu?

📖 Tài liệu tham khảo / References

Bài viết liên quan trong series

Bài 5: Subquery & CTE Bài 3: JOIN Toàn Tập Quay lại Lộ trình Series SQL