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(*) và
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).
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.
GROUP BY chạy sau WHERE
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 BY → LIMIT. 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ó.
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.
customers
| Cột | Kiểu |
|---|---|
| customer_id | INTEGER PRIMARY KEY |
| full_name | TEXT NOT NULL |
| 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 |
Đang tải...
⬇ 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?