Giao diện
GROUP BY và Tổng Hợp Dữ Liệu 📊
Mở đầu — Một câu hỏi, ba kỹ thuật
Sáng thứ Hai, CEO gửi bạn một tin nhắn:
"Tháng này mỗi khu vực bán được bao nhiêu đơn? Khu vực nào doanh thu trên 1 tỷ?"
Nghe đơn giản, nhưng câu hỏi này thực chất yêu cầu ba kỹ thuật cùng lúc:
- GROUP BY — gom các đơn hàng theo khu vực
- SUM() — cộng dồn doanh thu từng nhóm
- HAVING — lọc ra nhóm nào vượt 1 tỷ
Đây chính là thế giới aggregation — nơi bạn biến hàng triệu dòng dữ liệu thô thành những con số có ý nghĩa kinh doanh. Mọi dashboard, mọi báo cáo KPI, mọi biểu đồ doanh thu — đều bắt đầu từ đây.
Aggregate Functions — Năm vũ khí cơ bản
Aggregate function nhận nhiều dòng làm đầu vào, trả về một giá trị duy nhất. Hãy nghĩ nó như phép "tóm tắt" dữ liệu.
COUNT — Đếm
sql
-- 1. COUNT(*) — đếm TẤT CẢ các dòng (kể cả NULL)
SELECT COUNT(*) AS total_orders FROM orders;
-- 2. COUNT(column) — đếm dòng mà column KHÔNG NULL
SELECT COUNT(phone) AS customers_with_phone FROM customers;
-- 3. COUNT(DISTINCT column) — đếm giá trị unique
SELECT COUNT(DISTINCT customer_id) AS unique_buyers FROM orders;Phân biệt ba dạng COUNT
| Cú pháp | Hành vi | Ví dụ thực tế |
|---|---|---|
COUNT(*) | Đếm mọi dòng, kể cả NULL | Tổng số đơn hàng |
COUNT(column) | Bỏ qua dòng NULL ở column đó | Khách có số điện thoại |
COUNT(DISTINCT col) | Chỉ đếm giá trị unique | Số khách hàng riêng biệt |
SUM — Tổng cộng
sql
-- Tổng doanh thu tháng 1/2024
SELECT SUM(amount) AS total_revenue
FROM orders
WHERE order_date >= '2024-01-01' AND order_date < '2024-02-01';💡
SUM()bỏ qua NULL — bạn không cầnCOALESCEtrước khi tính tổng.
AVG — Trung bình
sql
-- Giá trị trung bình mỗi đơn hàng
SELECT AVG(amount) AS avg_order_value FROM orders;AVG và NULL
AVG() tính trung bình trên các dòng không NULL. Nếu có 100 dòng mà 20 dòng NULL, nó tính trung bình trên 80 dòng. Đôi khi đây không phải thứ bạn muốn — hãy cân nhắc SUM(amount) / COUNT(*) nếu muốn tính cả NULL như 0.
MAX và MIN — Cực trị
sql
-- Đơn hàng lớn nhất và nhỏ nhất
SELECT
MAX(amount) AS largest_order,
MIN(amount) AS smallest_order
FROM orders
WHERE status = 'paid';🎯 Ví dụ thực tế: Dashboard Metrics
Đây là query mà team Data thường dùng cho dashboard hàng ngày:
sql
-- Dashboard tổng quan ngày
SELECT
COUNT(*) AS total_orders,
SUM(amount) AS total_revenue,
AVG(amount) AS avg_order_value,
MAX(amount) AS largest_order,
MIN(amount) AS smallest_order,
COUNT(DISTINCT customer_id) AS unique_customers
FROM orders
WHERE order_date = CURRENT_DATE AND status = 'paid';Một query duy nhất, sáu metrics — đó là sức mạnh của aggregate functions.
GROUP BY — Split-Apply-Combine
GROUP BY hoạt động theo mô hình Split-Apply-Combine — chia nhỏ, tính toán, gộp kết quả:
BEFORE GROUP BY: AFTER GROUP BY:
┌──────────┬─────────┐ ┌──────────┬───────┬─────────┐
│ region │ amount │ │ region │ count │ total │
├──────────┼─────────┤ ├──────────┼───────┼─────────┤
│ Hà Nội │ 500K │ ──→ │ Hà Nội │ 2 │ 1.2M │
│ HCM │ 300K │ │ HCM │ 2 │ 800K │
│ Hà Nội │ 700K │ │ Đà Nẵng │ 1 │ 400K │
│ HCM │ 500K │ └──────────┴───────┴─────────┘
│ Đà Nẵng │ 400K │
└──────────┴─────────┘
SPLIT APPLY COMBINE
(chia theo nhóm) (tính aggregate) (gộp kết quả)Ba bước ngầm xảy ra khi bạn viết GROUP BY:
- Split: Database chia dữ liệu thành các nhóm theo giá trị của cột
- Apply: Tính aggregate function trên từng nhóm
- Combine: Gộp kết quả mỗi nhóm thành một dòng output
GROUP BY một cột
sql
-- Doanh thu theo từng khu vực
SELECT
region,
COUNT(*) AS order_count,
SUM(amount) AS total_revenue
FROM orders
GROUP BY region;Kết quả:
| region | order_count | total_revenue |
|---|---|---|
| Hà Nội | 1,247 | 8,340,000,000 |
| HCM | 2,103 | 14,200,000,000 |
| Đà Nẵng | 689 | 4,120,000,000 |
GROUP BY nhiều cột
Khi GROUP BY nhiều cột, mỗi tổ hợp duy nhất trở thành một nhóm:
sql
-- Doanh thu theo khu vực VÀ tháng
SELECT
region,
EXTRACT(MONTH FROM order_date) AS month,
SUM(amount) AS revenue,
COUNT(*) AS order_count
FROM orders
WHERE EXTRACT(YEAR FROM order_date) = 2024
GROUP BY region, EXTRACT(MONTH FROM order_date)
ORDER BY region, month;Kết quả:
| region | month | revenue | order_count |
|---|---|---|---|
| Hà Nội | 1 | 2.1B | 412 |
| Hà Nội | 2 | 1.8B | 356 |
| HCM | 1 | 3.5B | 687 |
| HCM | 2 | 3.1B | 601 |
🎯 Business Example: Doanh thu hàng tháng theo danh mục sản phẩm
sql
-- Báo cáo cho team Product — category nào đang tăng trưởng?
SELECT
category,
EXTRACT(MONTH FROM order_date) AS month,
SUM(amount) AS revenue,
COUNT(*) AS order_count,
ROUND(AVG(amount), 0) AS avg_order_value
FROM orders
WHERE EXTRACT(YEAR FROM order_date) = 2024
GROUP BY category, EXTRACT(MONTH FROM order_date)
ORDER BY category, month;WHERE vs HAVING — Câu hỏi phỏng vấn kinh điển
Đây là câu hỏi xuất hiện trong 90% buổi phỏng vấn SQL. Nếu bạn chỉ nhớ một thứ từ bài này, hãy nhớ bảng so sánh dưới đây.
Bảng so sánh
| WHERE | HAVING | |
|---|---|---|
| Thời điểm | TRƯỚC GROUP BY | SAU GROUP BY |
| Lọc gì | Từng dòng (row) | Kết quả nhóm (group) |
| Dùng aggregate? | ❌ Không được | ✅ Bắt buộc phải có |
| Ví dụ | WHERE status = 'paid' | HAVING SUM(amount) > 1000000 |
Thứ tự thực thi logic của SQL
FROM → Lấy dữ liệu từ bảng
WHERE → Lọc từng dòng ← Lọc TRƯỚC aggregation
GROUP BY → Gom nhóm
HAVING → Lọc kết quả nhóm ← Lọc SAU aggregation
SELECT → Chọn cột hiển thị
ORDER BY → Sắp xếp
LIMIT → Giới hạn kết quảVí dụ: Dùng cả WHERE và HAVING
Quay lại câu hỏi CEO: "Khu vực nào doanh thu trên 1 tỷ, chỉ tính đơn đã thanh toán?"
sql
-- Tìm khu vực có doanh thu trên 1 tỷ, chỉ tính đơn đã thanh toán
SELECT
region,
SUM(amount) AS total_revenue,
COUNT(*) AS paid_order_count
FROM orders
WHERE status = 'paid' -- Lọc TRƯỚC: chỉ lấy đơn đã thanh toán
GROUP BY region
HAVING SUM(amount) > 1000000000; -- Lọc SAU: khu vực doanh thu > 1 tỷHãy đọc lại query trên theo flow xử lý:
FROM orders— lấy toàn bộ bảng ordersWHERE status = 'paid'— bỏ hết đơn chưa thanh toán (giảm từ 10M → 7M dòng)GROUP BY region— gom 7M dòng thành ~63 nhóm (theo tỉnh/thành)HAVING SUM(amount) > 1000000000— giữ lại nhóm nào > 1 tỷSELECT— hiển thị region và total_revenue
🧠 Cách nhớ nhanh
WHERE = "Anh lọc hàng trước khi xếp vào nhóm" HAVING = "Anh lọc nhóm sau khi đã xếp xong"
Tưởng tượng bạn chia học sinh vào các lớp:
- WHERE: "Chỉ lấy học sinh đã đóng học phí" → lọc trước khi chia lớp
- HAVING: "Chỉ giữ lớp nào có trên 30 học sinh" → lọc sau khi chia xong
Multi-level Aggregation
GROUP BY với Date Functions — Phân tích theo thời gian
Phân tích time-series là use case phổ biến nhất của GROUP BY trong thực tế:
sql
-- Doanh thu theo tháng, theo danh mục
SELECT
DATE_TRUNC('month', order_date) AS month,
category,
SUM(amount) AS revenue,
COUNT(*) AS order_count,
AVG(amount) AS avg_order_value
FROM orders
GROUP BY DATE_TRUNC('month', order_date), category
ORDER BY month DESC, revenue DESC;DATE_TRUNC trong các database
- PostgreSQL:
DATE_TRUNC('month', order_date) - MySQL:
DATE_FORMAT(order_date, '%Y-%m-01') - SQL Server:
DATEFROMPARTS(YEAR(order_date), MONTH(order_date), 1)
Cú pháp khác nhau, ý tưởng giống nhau — truncate ngày về đầu tháng để group.
Aggregation nhiều tầng — Nested grouping
sql
-- Revenue theo năm > quý > khu vực
SELECT
EXTRACT(YEAR FROM order_date) AS year,
EXTRACT(QUARTER FROM order_date) AS quarter,
region,
SUM(amount) AS revenue,
COUNT(*) AS order_count,
COUNT(DISTINCT customer_id) AS unique_customers,
ROUND(SUM(amount) * 1.0 / COUNT(DISTINCT customer_id), 0) AS revenue_per_customer
FROM orders
WHERE status = 'paid'
GROUP BY
EXTRACT(YEAR FROM order_date),
EXTRACT(QUARTER FROM order_date),
region
ORDER BY year DESC, quarter DESC, revenue DESC;Mỗi dòng kết quả đại diện cho tổ hợp (năm, quý, khu vực) — cực kỳ hữu ích cho báo cáo quý gửi ban lãnh đạo.
DISTINCT trong Aggregate
DISTINCT bên trong aggregate function giúp bạn đếm/tổng giá trị unique thay vì tất cả:
sql
-- Đếm số khách hàng unique đã mua hàng (không phải số đơn)
SELECT COUNT(DISTINCT customer_id) AS unique_customers
FROM orders
WHERE order_date >= '2024-01-01';So sánh COUNT vs COUNT(DISTINCT)
sql
-- Cùng một bảng orders, kết quả rất khác:
SELECT
COUNT(*) AS total_orders, -- 15,000 (đơn)
COUNT(customer_id) AS non_null_customers, -- 14,980 (bỏ 20 NULL)
COUNT(DISTINCT customer_id) AS unique_customers -- 3,200 (khách riêng biệt)
FROM orders;15,000 đơn nhưng chỉ 3,200 khách → trung bình mỗi khách mua ~4.7 lần. Đó là repeat purchase rate — metric mà team Marketing rất quan tâm.
DISTINCT trong SUM
sql
-- Giả sử bảng payment có duplicate do lỗi system
-- Tính tổng amount theo unique transaction_id
SELECT
order_id,
SUM(DISTINCT amount) AS unique_total,
SUM(amount) AS raw_total -- có thể bị double-count
FROM payments
GROUP BY order_id;Cẩn thận với SUM(DISTINCT)
SUM(DISTINCT amount) loại bỏ các giá trị trùng nhau, không phải dòng trùng. Nếu hai giao dịch khác nhau nhưng cùng amount = 500K, chỉ tính một lần 500K. Dùng cẩn thận — thường bạn muốn fix duplicate ở bước trước đó.
🏋️ Bài tập nhanh
Bài toán: Cho bảng transactions:
| Column | Type | Mô tả |
|---|---|---|
| id | INT | Primary key |
| user_id | INT | Mã khách hàng |
| category | VARCHAR | Danh mục chi tiêu |
| amount | DECIMAL | Số tiền (VND) |
| created_at | TIMESTAMP | Thời điểm giao dịch |
Yêu cầu: Tìm top 5 danh mục có tổng chi tiêu cao nhất, nhưng chỉ tính danh mục có trên 100 giao dịch. Hiển thị: tên danh mục, số giao dịch, tổng số tiền, trung bình mỗi giao dịch.
Hãy tự viết query trước khi xem đáp án! 🧠
📝 Xem đáp án
sql
SELECT
category,
COUNT(*) AS transaction_count,
SUM(amount) AS total_amount,
ROUND(AVG(amount), 0) AS avg_amount
FROM transactions
GROUP BY category
HAVING COUNT(*) > 100
ORDER BY total_amount DESC
LIMIT 5;Giải thích từng bước:
GROUP BY category— gom tất cả giao dịch theo danh mụcCOUNT(*) > 100trong HAVING — chỉ giữ danh mục có trên 100 giao dịchORDER BY total_amount DESC— sắp theo tổng chi tiêu giảm dầnLIMIT 5— chỉ lấy top 5
Tại sao dùng HAVING chứ không WHERE? Vì điều kiện "trên 100 giao dịch" là điều kiện trên kết quả aggregate (COUNT(*) > 100), không phải trên từng dòng.
⚠️ Gotcha: Non-aggregated Column trong SELECT
Đây là lỗi mà hầu hết người mới đều mắc ít nhất một lần:
sql
-- ❌ SAI: "name" không có trong GROUP BY cũng không phải aggregate
SELECT department, name, COUNT(*)
FROM employees
GROUP BY department;
-- ERROR: column "name" must appear in GROUP BY clause
-- or be used in an aggregate functionTại sao lỗi? Khi GROUP BY department, mỗi department có nhiều employees. Database không biết hiển thị name của ai — Nguyễn Văn A hay Trần Thị B?
sql
-- ✅ ĐÚNG cách 1: Thêm name vào GROUP BY
SELECT department, name, COUNT(*)
FROM employees
GROUP BY department, name;
-- → Mỗi tổ hợp (department, name) là một nhóm riêng
-- ✅ ĐÚNG cách 2: Dùng aggregate cho name
SELECT department, COUNT(*), MAX(name) AS sample_name
FROM employees
GROUP BY department;
-- → Lấy tên "lớn nhất" theo alphabet làm đại diện
-- ✅ ĐÚNG cách 3: Dùng STRING_AGG (PostgreSQL) để gộp tất cả tên
SELECT department, COUNT(*), STRING_AGG(name, ', ') AS all_names
FROM employees
GROUP BY department;MySQL vs PostgreSQL — Hành vi khác nhau
MySQL mặc định "cho qua" lỗi này
MySQL trước version 5.7 (và khi tắt ONLY_FULL_GROUP_BY) sẽ không báo lỗi — nó âm thầm chọn một giá trị ngẫu nhiên cho cột non-aggregated. Kết quả hoàn toàn không đáng tin cậy.
sql
-- MySQL (ONLY_FULL_GROUP_BY = OFF): chạy được nhưng kết quả bậy
SELECT department, name, COUNT(*) FROM employees GROUP BY department;
-- "name" là giá trị ngẫu nhiên — có thể khác nhau mỗi lần chạy!PostgreSQL luôn enforce nghiêm ngặt — phải có trong GROUP BY hoặc aggregate. Đây là hành vi đúng và an toàn hơn.
Best practice: Luôn bật ONLY_FULL_GROUP_BY trong MySQL:
sql
SET sql_mode = 'ONLY_FULL_GROUP_BY';⚡ Ghi chú hiệu năng
Nguyên tắc vàng: WHERE trước, GROUP BY sau
❌ Chậm: FROM (10M rows) → GROUP BY → HAVING status = 'paid'
✅ Nhanh: FROM (10M rows) → WHERE status = 'paid' (→ 7M rows) → GROUP BYLọc bằng WHERE trước GROUP BY giúp giảm số dòng cần xử lý — database chỉ cần gom nhóm trên dataset đã thu nhỏ.
So sánh hiệu năng thực tế
| Chiến lược | Rows xử lý | Thời gian ước tính |
|---|---|---|
| GROUP BY 10M rows → HAVING lọc | 10,000,000 | ~3.2 giây |
| WHERE lọc → GROUP BY 1M rows | 1,000,000 | ~0.3 giây |
→ 10x nhanh hơn chỉ bằng cách đặt filter đúng chỗ.
Index cho GROUP BY
sql
-- Nếu query thường xuyên GROUP BY region:
CREATE INDEX idx_orders_region ON orders(region);
-- Composite index cho GROUP BY + WHERE:
CREATE INDEX idx_orders_status_region ON orders(status, region);
-- → WHERE status = 'paid' GROUP BY region sẽ dùng Index ScanKhi có index phù hợp, database có thể dùng Index Scan thay vì Sort + Group — hiệu năng tốt hơn đáng kể trên bảng lớn.
Tip: EXPLAIN để kiểm tra
sql
-- Xem query plan trước khi chạy trên production
EXPLAIN ANALYZE
SELECT region, SUM(amount)
FROM orders
WHERE status = 'paid'
GROUP BY region;Nếu thấy Sort trong plan → thiếu index. Nếu thấy HashAggregate hoặc GroupAggregate → database đang group hiệu quả.
🚫 Anti-pattern: Dùng HAVING thay WHERE
Đây là anti-pattern phổ biến — dùng HAVING để lọc điều kiện của từng dòng:
sql
-- 🚫 ANTI-PATTERN: Lọc status bằng HAVING
SELECT region, SUM(amount) AS total_revenue
FROM orders
GROUP BY region
HAVING status = 'paid'; -- ❌ Dùng HAVING để lọc từng dòng!
-- Ngoài lỗi logic, trên một số database đây còn là syntax errorVấn đề:
- Sai về logic: HAVING chỉ nên lọc kết quả aggregate
- Sai về hiệu năng: Database phải GROUP BY toàn bộ data trước khi lọc
- Có thể lỗi: PostgreSQL sẽ báo error vì
statuskhông nằm trong GROUP BY
sql
-- ✅ FIX: Lọc bằng WHERE trước khi GROUP BY
SELECT region, SUM(amount) AS total_revenue
FROM orders
WHERE status = 'paid' -- ✅ WHERE lọc từng dòng — đúng vị trí
GROUP BY region;Quy tắc đơn giản
- Điều kiện trên cột gốc → dùng
WHERE - Điều kiện trên kết quả aggregate (COUNT, SUM, AVG...) → dùng
HAVING
🎮 Playground
Chạy thử GROUP BY + HAVING với dữ liệu kinh doanh Việt Nam:
sql
-- Tạo bảng orders mẫu
CREATE TABLE orders (
id SERIAL PRIMARY KEY,
region VARCHAR(50),
category VARCHAR(50),
amount BIGINT,
status VARCHAR(20),
customer_id INT,
order_date DATE
);
-- Insert dữ liệu mẫu
INSERT INTO orders (region, category, amount, status, customer_id, order_date) VALUES
('Hà Nội', 'Điện tử', 8500000, 'paid', 101, '2024-01-15'),
('Hà Nội', 'Thời trang', 2300000, 'paid', 102, '2024-01-16'),
('Hà Nội', 'Điện tử', 12000000, 'paid', 103, '2024-01-17'),
('Hà Nội', 'Gia dụng', 5600000, 'paid', 101, '2024-01-20'),
('Hà Nội', 'Thời trang', 1800000, 'pending', 104, '2024-01-22'),
('HCM', 'Điện tử', 15000000, 'paid', 201, '2024-01-10'),
('HCM', 'Điện tử', 9200000, 'paid', 202, '2024-01-12'),
('HCM', 'Thời trang', 3400000, 'paid', 203, '2024-01-14'),
('HCM', 'Gia dụng', 7800000, 'paid', 201, '2024-01-18'),
('HCM', 'Thời trang', 4100000, 'paid', 204, '2024-01-25'),
('HCM', 'Điện tử', 11500000, 'pending', 205, '2024-01-28'),
('Đà Nẵng', 'Điện tử', 6700000, 'paid', 301, '2024-01-11'),
('Đà Nẵng', 'Thời trang', 1900000, 'paid', 302, '2024-01-19'),
('Đà Nẵng', 'Gia dụng', 3200000, 'paid', 301, '2024-01-23'),
('Đà Nẵng', 'Điện tử', 8900000, 'paid', 303, '2024-02-02'),
('Hà Nội', 'Điện tử', 7400000, 'paid', 105, '2024-02-05'),
('HCM', 'Gia dụng', 6100000, 'paid', 206, '2024-02-08'),
('HCM', 'Điện tử', 13200000, 'paid', 202, '2024-02-10');
-- ============================================
-- 🧪 THỬ CÁC QUERY DƯỚI ĐÂY
-- ============================================
-- 1. Doanh thu theo khu vực (chỉ đơn đã thanh toán)
SELECT
region,
COUNT(*) AS paid_orders,
SUM(amount) AS total_revenue,
ROUND(AVG(amount), 0) AS avg_order_value
FROM orders
WHERE status = 'paid'
GROUP BY region
ORDER BY total_revenue DESC;
-- 2. Khu vực nào có doanh thu trên 30 triệu?
SELECT
region,
SUM(amount) AS total_revenue
FROM orders
WHERE status = 'paid'
GROUP BY region
HAVING SUM(amount) > 30000000
ORDER BY total_revenue DESC;
-- 3. Doanh thu theo khu vực + danh mục
SELECT
region,
category,
COUNT(*) AS order_count,
SUM(amount) AS revenue
FROM orders
WHERE status = 'paid'
GROUP BY region, category
ORDER BY region, revenue DESC;
-- 4. Khách hàng unique mỗi khu vực
SELECT
region,
COUNT(DISTINCT customer_id) AS unique_customers,
COUNT(*) AS total_orders,
ROUND(COUNT(*) * 1.0 / COUNT(DISTINCT customer_id), 1) AS orders_per_customer
FROM orders
WHERE status = 'paid'
GROUP BY region;Thử thay đổi
- Bỏ
WHERE status = 'paid'để xem kết quả thay đổi thế nào - Thay
HAVING SUM(amount) > 30000000thành con số khác - Thêm
EXTRACT(MONTH FROM order_date)vào GROUP BY để xem theo tháng
📍 Trang tiếp theo
Bạn đã nắm được cách tổng hợp dữ liệu trong một bảng. Nhưng thực tế, dữ liệu luôn nằm rải rác nhiều bảng — orders, customers, products, payments...
Làm sao kết hợp chúng lại? Đó là công việc của JOIN — kỹ năng SQL quan trọng nhất mà bạn sẽ học tiếp theo.