Skip to content

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:

  1. GROUP BY — gom các đơn hàng theo khu vực
  2. SUM() — cộng dồn doanh thu từng nhóm
  3. 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ápHành viVí dụ thực tế
COUNT(*)Đếm mọi dòng, kể cả NULLTổ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ị uniqueSố 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ần COALESCE trướ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:

  1. Split: Database chia dữ liệu thành các nhóm theo giá trị của cột
  2. Apply: Tính aggregate function trên từng nhóm
  3. 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ả:

regionorder_counttotal_revenue
Hà Nội1,2478,340,000,000
HCM2,10314,200,000,000
Đà Nẵng6894,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ả:

regionmonthrevenueorder_count
Hà Nội12.1B412
Hà Nội21.8B356
HCM13.5B687
HCM23.1B601

🎯 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

WHEREHAVING
Thời điểmTRƯỚC GROUP BYSAU 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ý:

  1. FROM orders — lấy toàn bộ bảng orders
  2. WHERE status = 'paid' — bỏ hết đơn chưa thanh toán (giảm từ 10M → 7M dòng)
  3. GROUP BY region — gom 7M dòng thành ~63 nhóm (theo tỉnh/thành)
  4. HAVING SUM(amount) > 1000000000 — giữ lại nhóm nào > 1 tỷ
  5. 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:

ColumnTypeMô tả
idINTPrimary key
user_idINTMã khách hàng
categoryVARCHARDanh mục chi tiêu
amountDECIMALSố tiền (VND)
created_atTIMESTAMPThờ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:

  1. GROUP BY category — gom tất cả giao dịch theo danh mục
  2. COUNT(*) > 100 trong HAVING — chỉ giữ danh mục có trên 100 giao dịch
  3. ORDER BY total_amount DESC — sắp theo tổng chi tiêu giảm dần
  4. LIMIT 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 function

Tạ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 đúngan 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 BY

Lọ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ượcRows xử lýThời gian ước tính
GROUP BY 10M rows → HAVING lọc10,000,000~3.2 giây
WHERE lọc → GROUP BY 1M rows1,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 Scan

Khi 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 error

Vấn đề:

  1. Sai về logic: HAVING chỉ nên lọc kết quả aggregate
  2. Sai về hiệu năng: Database phải GROUP BY toàn bộ data trước khi lọc
  3. Có thể lỗi: PostgreSQL sẽ báo error vì status khô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) > 30000000 thà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.