Mở đầu — vì sao bài này quan trọng
Nếu bạn từng được sếp hỏi "tháng này doanh thu bao nhiêu?", "trung bình mỗi đơn hàng giá trị bao nhiêu?", hay "có bao nhiêu khách hàng mới?" — thì bạn đang chạm đúng vào trái tim của công việc Business Analyst. Và để trả lời những câu đó bằng dữ liệu thật, thay vì kéo Excel thủ công, bạn cần đến aggregate functions (hàm tổng hợp) và GROUP BY.
Ở hai bài trước, bạn đã học cách lấy ra từng dòng dữ liệu thô với SELECT, lọc bằng WHERE, sắp xếp bằng ORDER BY. Nhưng dữ liệu thô gần như không bao giờ là thứ sếp muốn. Không ai muốn nhìn 50.000 dòng giao dịch. Người ta muốn nhìn con số đã được tóm gọn: tổng, trung bình, đếm, lớn nhất, nhỏ nhất — và quan trọng hơn, được chia nhóm theo thời gian, theo khu vực, theo sản phẩm.
Đây chính là khoảnh khắc SQL biến từ "công cụ tra cứu" thành "công cụ phân tích". Một BA giỏi không chỉ lấy được dữ liệu, mà còn biến hàng nghìn dòng giao dịch thành một bảng báo cáo gọn gàng mà ban lãnh đạo có thể đọc trong 30 giây. Bài này dạy bạn chính xác kỹ năng đó: cách tính các con số tổng hợp, và cách dùng GROUP BY để "bẻ" con số tổng hợp đó theo từng nhóm bạn cần.
Khái niệm cốt lõi
Aggregate function là gì?
Một aggregate function nhận nhiều dòng dữ liệu làm đầu vào và trả về một giá trị duy nhất. Thay vì "mỗi dòng một kết quả" như các phép tính thông thường, nó "gom tất cả dòng lại thành một con số".
Năm hàm bạn sẽ dùng đến 90% thời gian:
COUNT(*) -- Đếm số dòng
SUM(amount) -- Cộng tổng một cột số
AVG(score) -- Trung bình cộng
MIN(price) -- Giá trị nhỏ nhất
MAX(price) -- Giá trị lớn nhất
Ví dụ đơn giản nhất — đếm tổng số đơn hàng trong bảng orders:
SELECT COUNT(*) AS tong_so_don
FROM orders;
Kết quả không phải hàng nghìn dòng, mà chỉ một con số duy nhất. Đó là bản chất của aggregate.
Phân biệt COUNT(*), COUNT(cột) và COUNT(DISTINCT cột)
Đây là chỗ nhiều BA mới mắc lỗi, nên hãy nắm thật chắc:
COUNT(*)— đếm tất cả các dòng, kể cả dòng có giá trị NULL.COUNT(customer_id)— chỉ đếm những dòng màcustomer_idkhông NULL.COUNT(DISTINCT customer_id)— đếm số giá trị khác nhau củacustomer_id, tức là số khách hàng duy nhất.
COUNT(*) trên bảng orders, bạn sẽ đếm số đơn hàng, không phải số khách. Một khách mua 10 lần sẽ bị tính thành 10. Đáp án đúng phải là COUNT(DISTINCT customer_id).NULL và aggregate function
Một quy tắc vàng: các hàm SUM, AVG, MIN, MAX đều bỏ qua giá trị NULL. Chúng không coi NULL là 0. Điều này nghe nhỏ nhưng ảnh hưởng lớn đến kết quả AVG. Ví dụ cột rating có 3 giá trị [5, 5, NULL]: AVG(rating) sẽ là (5+5)/2 = 5, chứ không phải (5+5+0)/3 = 3.33. Mẫu số chỉ tính những giá trị thực sự tồn tại.
GROUP BY — chia con số tổng hợp theo nhóm
Aggregate function dùng một mình chỉ cho ra một con số cho toàn bộ bảng. Nhưng thực tế bạn hiếm khi muốn vậy. Bạn muốn "doanh thu theo từng tháng", "số khách theo từng thành phố". Đó là lúc GROUP BY vào cuộc.
GROUP BY cot_A nói với SQL: "Hãy gom các dòng có cùng giá trị cot_A lại thành một nhóm, rồi tính aggregate cho từng nhóm riêng biệt."
SELECT city, COUNT(*) AS so_don, SUM(amount) AS doanh_thu
FROM orders
GROUP BY city;
Kết quả giờ là một bảng nhỏ: mỗi thành phố một dòng, kèm số đơn và doanh thu của thành phố đó. Đây chính là một bản báo cáo.
Quy tắc bắt buộc phải nhớ: mọi cột xuất hiện trong SELECT mà không nằm trong một aggregate function thì phải có mặt trong GROUP BY. Nếu bạn viết SELECT city, product_name, COUNT(*) mà chỉ GROUP BY city, PostgreSQL sẽ báo lỗi ngay, còn MySQL (ở chế độ lỏng) có thể trả về kết quả ngẫu nhiên gây sai lệch âm thầm — nguy hiểm hơn nhiều.
HAVING — lọc trên kết quả đã tổng hợp
WHERE lọc từng dòng thô trước khi gom nhóm. Nhưng nếu bạn muốn lọc trên chính con số tổng hợp — ví dụ "chỉ lấy những thành phố có doanh thu trên 100 triệu" — thì WHERE không làm được, vì lúc WHERE chạy, doanh thu tổng chưa được tính. Đó là việc của HAVING.
SELECT city, SUM(amount) AS doanh_thu
FROM orders
GROUP BY city
HAVING SUM(amount) > 100000000;
Hãy nhớ thứ tự logic SQL thực thi: FROM → WHERE → GROUP BY → HAVING → SELECT → ORDER BY. WHERE lọc trước khi gom, HAVING lọc sau khi gom. Nắm thứ tự này, bạn sẽ không bao giờ nhầm hai mệnh đề nữa.
Tình huống thực tế
Ví dụ 1 — Báo cáo doanh thu theo danh mục cho một sàn TMĐT kiểu Tiki
Giả sử bạn là BA tại một sàn thương mại điện tử. Sếp marketing muốn biết: trong quý vừa rồi, mỗi danh mục sản phẩm đóng góp bao nhiêu doanh thu, bao nhiêu đơn, và giá trị đơn trung bình là bao nhiêu — để quyết định rót ngân sách quảng cáo.
Bảng order_items có các cột: order_id, category, quantity, unit_price, order_date.
SELECT
category,
COUNT(DISTINCT order_id) AS so_don,
SUM(quantity * unit_price) AS doanh_thu,
AVG(quantity * unit_price) AS gia_tri_dong_tb
FROM order_items
WHERE order_date >= '2026-04-01'
AND order_date < '2026-07-01'
GROUP BY category
ORDER BY doanh_thu DESC;
Diễn giải: WHERE lọc trước để chỉ giữ giao dịch trong quý 2. GROUP BY category gom theo danh mục. COUNT(DISTINCT order_id) đếm số đơn thật sự (vì một đơn có thể chứa nhiều dòng item cùng danh mục, nếu dùng COUNT() sẽ thổi phồng con số). SUM(quantity unit_price) tính doanh thu — lưu ý ta tính biểu thức ngay trong hàm. ORDER BY doanh_thu DESC đẩy danh mục bán chạy lên đầu.
Bài học: đừng quên DISTINCT khi đếm đơn hàng trên bảng chi tiết dòng. Đây là lỗi kinh điển khiến báo cáo "đẹp" nhưng sai gấp 2–3 lần thực tế.
Ví dụ 2 — Tìm tài xế "ngôi sao" cho một app gọi xe kiểu Be/Grab
Bạn làm BA cho một ứng dụng gọi xe. Đội vận hành muốn lập danh sách thưởng cho các tài xế hoàn thành từ 200 chuyến trở lên trong tháng và có điểm đánh giá trung bình ≥ 4.8.
Bảng trips: trip_id, driver_id, rating, fare, completed_at.
SELECT
driver_id,
COUNT(*) AS so_chuyen,
AVG(rating) AS diem_tb,
SUM(fare) AS tong_cuoc
FROM trips
WHERE completed_at >= '2026-06-01'
AND completed_at < '2026-07-01'
GROUP BY driver_id
HAVING COUNT(*) >= 200
AND AVG(rating) >= 4.8
ORDER BY diem_tb DESC, so_chuyen DESC;
Diễn giải: điều kiện "chuyến trong tháng 6" là điều kiện trên từng dòng nên đặt ở WHERE. Còn điều kiện "≥ 200 chuyến" và "điểm trung bình ≥ 4.8" là điều kiện trên con số đã tổng hợp nên bắt buộc đặt ở HAVING. Nhiều bạn mới sẽ thử nhét COUNT(*) >= 200 vào WHERE và nhận lỗi ngay — vì lúc đó SQL chưa đếm xong.
Bài học: Hỏi bản thân "điều kiện này áp lên một dòng đơn lẻ hay lên một nhóm đã gộp?" Nếu áp lên nhóm gộp → HAVING. Đây là kim chỉ nam phân biệt WHERE và HAVING.
Ví dụ 3 — Giám sát giá trị giao dịch tại một ví điện tử kiểu MoMo
Bạn là BA mảng rủi ro tại một ví điện tử. Đội risk muốn nắm khoảng giá trị giao dịch mỗi ngày để phát hiện ngày bất thường: giao dịch nhỏ nhất, lớn nhất, trung bình, và tổng giá trị xử lý trong ngày.
Bảng transactions: txn_id, user_id, amount, created_at.
SELECT
DATE(created_at) AS ngay,
COUNT(*) AS so_giao_dich,
COUNT(DISTINCT user_id) AS so_nguoi_dung,
MIN(amount) AS gd_nho_nhat,
MAX(amount) AS gd_lon_nhat,
AVG(amount) AS gd_trung_binh,
SUM(amount) AS tong_xu_ly
FROM transactions
WHERE created_at >= '2026-06-01'
GROUP BY DATE(created_at)
ORDER BY ngay;
Diễn giải: ta GROUP BY theo biểu thức DATE(created_at) (cắt phần giờ, chỉ giữ ngày) — hoàn toàn hợp lệ. MIN/MAX cho thấy biên độ giao dịch; nếu một ngày MAX(amount) đột nhiên gấp 50 lần ngày thường, đó là tín hiệu để đội risk soi kỹ. COUNT(DISTINCT user_id) cạnh COUNT(*) cho biết hôm đó nhiều giao dịch là do đông người dùng hay do vài người giao dịch dồn dập.
Bài học: kết hợp MIN, MAX, AVG cùng COUNT(DISTINCT) trong một query cho bạn bức tranh đa chiều chỉ trong một lần chạy — đúng tinh thần "một báo cáo, nhiều góc nhìn" mà BA cần.
Hướng dẫn từng bước
Khi đứng trước một yêu cầu báo cáo bất kỳ, hãy đi theo trình tự này:
- Xác định "đơn vị nhóm" (cột để GROUP BY). Sếp muốn xem theo gì? Theo tháng, theo thành phố, theo sản phẩm, theo tài xế? Cụm từ "theo từng..." trong câu hỏi chính là cột bạn sẽ
GROUP BY.
- Xác định các con số cần tính (aggregate). "Bao nhiêu" →
COUNT. "Tổng" →SUM. "Trung bình" →AVG. "Cao nhất / thấp nhất" →MAX/MIN. "Bao nhiêu cái khác nhau" →COUNT(DISTINCT ...).
- Tách điều kiện lọc làm hai loại. Điều kiện trên dòng thô (ngày, trạng thái, khu vực) →
WHERE. Điều kiện trên con số đã gộp (tổng > X, đếm ≥ Y) →HAVING.
- Ráp query theo đúng thứ tự cú pháp:
SELECT … FROM … WHERE … GROUP BY … HAVING … ORDER BY ….
- Đặt alias rõ ràng cho mọi cột tính toán bằng
AS, dùng tên dễ hiểu để người đọc báo cáo không phải đoánSUM(amount)là cái gì.
- Kiểm tra lại bằng con số nhỏ. Chạy thử trên một nhóm bạn biết trước đáp án (ví dụ một thành phố nhỏ) để chắc chắn logic đúng trước khi tin vào toàn bộ bảng.
Lỗi thường gặp & mẹo
Lỗi 1 — Quên đưa cột không-aggregate vào GROUP BY. Viết SELECT city, product, SUM(amount) mà chỉ GROUP BY city. PostgreSQL báo lỗi thẳng; MySQL có thể "âm thầm" chọn đại một product rồi trả kết quả sai. Mẹo: cứ cột nào trong SELECT không nằm trong hàm tổng hợp thì phải có trong GROUP BY.
Lỗi 2 — Nhầm WHERE với HAVING. Đặt WHERE COUNT(*) > 10 sẽ lỗi. Aggregate chỉ lọc được ở HAVING. Ngược lại, đừng lạm dụng HAVING cho điều kiện đáng lẽ thuộc WHERE (ví dụ lọc ngày) — vì HAVING chạy sau, nó bắt hệ thống gom nhóm cả dữ liệu thừa rồi mới loại bỏ, lãng phí hiệu năng.
Lỗi 3 — Dùng COUNT() khi cần COUNT(DISTINCT). Đếm "số khách" trên bảng đơn hàng bằng COUNT() sẽ ra "số đơn". Luôn tự hỏi: mình đang đếm dòng hay đếm thực thể duy nhất?
Lỗi 4 — Hiểu nhầm AVG với NULL. AVG bỏ qua NULL trong mẫu số. Nếu bạn muốn coi NULL như 0 (ví dụ cột "số tiền hoàn" trống nghĩa là 0), hãy chuyển đổi trước bằng COALESCE(refund, 0) rồi mới AVG — vấn đề xử lý NULL này sẽ được học sâu ở bài về COALESCE.
Mẹo — Tận dụng số thứ tự cột trong GROUP BY/ORDER BY. Nhiều hệ CSDL cho phép GROUP BY 1 hay ORDER BY 2 DESC (tham chiếu cột theo vị trí trong SELECT). Tiện khi viết nhanh, nhưng trong báo cáo chính thức nên ghi rõ tên cột để người sau dễ đọc.
Mẹo — Luôn cặp đôi COUNT(*) với COUNT(DISTINCT). Trong nhiều báo cáo, đặt cạnh nhau "số giao dịch" và "số người dùng" tiết lộ thông tin mà mỗi con số đứng riêng không nói được.
Bài tập thực hành
Dùng các bảng đã quen ở trên (orders, order_items, trips, transactions). Hãy thử tự viết query trước khi xem gợi ý:
- Đếm tổng số đơn hàng và tổng doanh thu của toàn bảng
orders(không chia nhóm).
- Tính doanh thu và số đơn theo từng tháng trong năm 2026. Gợi ý:
GROUP BYtheo một biểu thức cắt tháng từorder_date.
- Liệt kê các thành phố có trên 500 đơn hàng, kèm doanh thu trung bình mỗi đơn, sắp xếp doanh thu giảm dần. (Cần dùng
HAVING.)
- Với bảng
trips, tìm những tài xế có điểm trung bình dưới 4.0 và ít nhất 50 chuyến — danh sách cần đào tạo lại.
- Với bảng
transactions, tìm ngày có tổng giá trị xử lý cao nhất trong tháng 6. (Gộp theo ngày, sắp xếp tổng giảm dần, lấy dòng đầu.)
- Thử thách: Tính số khách hàng duy nhất mỗi tháng (
COUNT(DISTINCT customer_id)) và đặt cạnh tổng số đơn cùng tháng. So sánh hai con số cho bạn biết điều gì về tần suất mua lặp lại?
Tóm tắt
- Aggregate function (
COUNT,SUM,AVG,MIN,MAX) gom nhiều dòng thành một con số — đây là bước biến dữ liệu thô thành báo cáo. COUNT(*)đếm mọi dòng;COUNT(cột)bỏ qua NULL;COUNT(DISTINCT cột)đếm giá trị duy nhất. Chọn sai là báo cáo sai.SUM,AVG,MIN,MAXđều bỏ qua NULL — đặc biệt lưu ý vớiAVG.GROUP BYchia con số tổng hợp theo từng nhóm. Mọi cột không-aggregate trongSELECTphải có trongGROUP BY.WHERElọc dòng thô trước khi gộp;HAVINGlọc sau khi gộp, dùng cho điều kiện trên chính giá trị tổng hợp.- Thứ tự thực thi:
FROM → WHERE → GROUP BY → HAVING → SELECT → ORDER BY. Nắm chắc nó, mọi câu hỏi báo cáo "theo từng..." đều giải được.
GROUP BY với JOIN và subquery để tạo nên những báo cáo phức tạp và mạnh mẽ hơn nữa.