Mở đầu — vì sao bài này quan trọng
Nếu bạn hỏi một Business Analyst rằng công việc của họ là gì, gần như chắc chắn sẽ có cụm từ "trả lời câu hỏi bằng số liệu". Mà phần lớn câu hỏi nghiệp vụ trên đời này đều quy về năm động từ rất quen: đếm bao nhiêu, cộng lại tổng bao nhiêu, trung bình là bao nhiêu, thấp nhất là bao nhiêu, cao nhất là bao nhiêu. Đó chính xác là năm hàm tổng hợp (aggregate functions) mà bài này tập trung: COUNT, SUM, AVG, MIN, MAX.
Nghe có vẻ đơn giản — và đúng là cú pháp của chúng đơn giản thật. Nhưng tôi đã chứng kiến vô số báo cáo sai lệch hàng tỷ đồng chỉ vì người viết query hiểu sai một chi tiết nhỏ: COUNT(*) khác COUNT(cột) thế nào, AVG xử lý giá trị NULL ra sao, hay tại sao SUM của một cột "có vẻ đầy đủ" lại ra con số thấp hơn thực tế. Những sai sót này không làm query báo lỗi — nó vẫn chạy, vẫn trả về một con số trông rất thuyết phục, và bạn đem con số sai đó vào cuộc họp với sếp.
Vì vậy, đừng coi thường bài này. Nắm chắc năm hàm tổng hợp và đặc biệt là cách chúng đối xử với NULL chính là nền móng để sau này bạn làm GROUP BY, HAVING, pivot, cohort hay bất kỳ phân tích nâng cao nào. Ở bài này, ta sẽ đi sâu vào bản chất của từng hàm, không chỉ cú pháp.
Khái niệm cốt lõi
Aggregate function là hàm nhận nhiều dòng dữ liệu và trả về một giá trị duy nhất. Khác với hàm thông thường (như UPPER, ROUND) chạy trên từng dòng, aggregate "gom" cả tập dữ liệu lại rồi tính ra một con số. Đó là lý do trong một câu SELECT COUNT(*) FROM orders bạn chỉ nhận về đúng một dòng kết quả.
COUNT — đếm số dòng
COUNT là hàm dễ gây nhầm nhất, vì nó có ba biến thể với hành vi khác nhau:
SELECT COUNT(*) FROM orders; -- đếm TẤT CẢ dòng, kể cả dòng có NULL
SELECT COUNT(phone) FROM customers; -- đếm số dòng có phone KHÁC NULL
SELECT COUNT(DISTINCT city) FROM customers; -- đếm số giá trị city DUY NHẤT (bỏ NULL)
Ghi nhớ then chốt: COUNT(*) đếm dòng — không quan tâm nội dung. COUNT(cột) chỉ đếm những dòng mà cột đó không NULL. COUNT(DISTINCT cột) đếm số giá trị phân biệt và cũng bỏ qua NULL.
Ví dụ bảng customers có 1000 dòng nhưng chỉ 850 khách có số điện thoại, 150 khách còn lại để trống (NULL). Khi đó COUNT(*) = 1000, còn COUNT(phone) = 850. Chính khoảng chênh 150 này là thông tin quý: nó cho bạn biết tỷ lệ thiếu dữ liệu liên hệ — một chỉ số chất lượng dữ liệu rất thực tế.
SUM — cộng dồn
SUM cộng tất cả giá trị số trong một cột. Điểm cần nhớ: SUM bỏ qua NULL, không coi NULL là 0 (mặc dù kết quả cuối thường giống nhau, bản chất xử lý là "loại bỏ" chứ không phải "thay bằng 0"). Hệ quả quan trọng: nếu toàn bộ các dòng đều NULL, SUM trả về NULL chứ không phải 0.
SELECT SUM(total_amount) FROM orders WHERE status = 'completed';
SUM chỉ dùng được trên kiểu số. Nếu lỡ áp lên cột text, database sẽ báo lỗi hoặc (ở vài hệ) tự ép kiểu rồi cho ra kết quả vô nghĩa.
AVG — trung bình
AVG là hàm bẫy nhiều BA nhất. Công thức của nó là tổng các giá trị không-NULL chia cho số dòng không-NULL. Tức là NULL bị loại khỏi cả tử số lẫn mẫu số.
Giả sử cột rating có 5 dòng: 5, 4, NULL, NULL, 3. AVG(rating) = (5+4+3) / 3 = 4.0, không phải (5+4+0+0+3) / 5 = 2.4. Nếu nghiệp vụ của bạn muốn coi "chưa đánh giá" là 0 điểm, bạn phải tự xử lý bằng cách thay NULL thành 0 trước (sẽ nói ở phần mẹo). Đây là ranh giới giữa một con số đúng và một con số sai lệch hoàn toàn.
MIN và MAX — nhỏ nhất và lớn nhất
MIN và MAX trả về giá trị nhỏ nhất và lớn nhất. Điểm thú vị là chúng không chỉ chạy trên số: với cột ngày, MIN(order_date) cho ra đơn hàng đầu tiên, MAX(order_date) cho đơn gần nhất. Với cột text, MIN/MAX so sánh theo thứ tự bảng chữ cái (alphabet). Cả hai đều bỏ qua NULL.
SELECT MIN(order_date) AS don_dau_tien,
MAX(order_date) AS don_gan_nhat
FROM orders;
Kết hợp nhiều hàm trong một query
Bạn không cần chạy năm query riêng. Tất cả gộp được vào một câu, cho ra một dòng "bảng điểm tổng quan":
SELECT COUNT(*) AS so_don,
SUM(total_amount) AS doanh_thu,
AVG(total_amount) AS gia_tri_tb,
MIN(total_amount) AS don_nho_nhat,
MAX(total_amount) AS don_lon_nhat
FROM orders
WHERE status = 'completed';
Lưu ý: ở bài này ta cố tình chưa dùng GROUP BY — tức là mỗi hàm đang gom toàn bộ bảng (sau khi lọc WHERE) thành một con số. Việc chia nhóm theo thành phố, theo tháng... sẽ là chủ đề của Bài 14.
Tình huống thực tế
Tình huống 1 — Tiki và bài toán "doanh thu thật" bị thổi phồng
Một BA tại sàn thương mại điện tử (giả định theo mô hình Tiki) nhận yêu cầu báo cáo doanh thu quý của ngành hàng Điện tử. Bạn viết nhanh:
SELECT SUM(total_amount) FROM orders WHERE category = 'Electronics';
Ra con số 48 tỷ đồng. Đẹp. Nhưng giám đốc tài chính phản hồi: "Số này cao hơn sổ kế toán 9 tỷ." Vấn đề ở đâu? Bảng orders chứa cả đơn đã hủy (status = 'cancelled') và đơn hoàn (status = 'refunded'). SUM không phân biệt — nó cộng tất tần tật. Sửa lại:
SELECT
SUM(total_amount) AS gross,
SUM(CASE WHEN status = 'completed' THEN total_amount END) AS net_revenue,
COUNT(*) AS tong_don,
COUNT(CASE WHEN status = 'cancelled' THEN 1 END) AS don_huy
FROM orders
WHERE category = 'Electronics';
Bây giờ net_revenue ra đúng 39 tỷ. Bài học: aggregate luôn cộng đúng những gì bạn đưa cho nó — sai số gần như luôn nằm ở bộ lọc (điều kiện WHERE hoặc CASE), không phải ở hàm. Trước khi tin một con số SUM, hãy luôn hỏi: "tôi đang cộng đúng tập dòng nào?"
Tình huống 2 — Grab và cái bẫy AVG trên thời gian chờ tài xế
Một BA tại nền tảng gọi xe (mô hình Grab/Be) cần báo cáo "thời gian chờ trung bình để ghép tài xế" cho khu vực TP.HCM trong giờ cao điểm. Cột match_time_seconds ghi số giây từ lúc đặt đến lúc có tài xế nhận. Bạn viết:
SELECT AVG(match_time_seconds) FROM ride_requests WHERE city = 'HCM';
Ra 95 giây — nghe khá ổn. Nhưng đội vận hành than phiền thực tế tệ hơn nhiều. Hóa ra những cuốc không ghép được tài xế (khách chờ quá lâu rồi hủy) có match_time_seconds = NULL. Mà AVG đã âm thầm loại toàn bộ các cuốc tệ nhất này ra khỏi phép tính. Con số 95 giây chỉ là trung bình của những cuốc thành công, vẽ nên bức tranh màu hồng giả tạo.
Cách xử lý đúng là tách bạch hai chỉ số:
SELECT
COUNT(*) AS tong_yeu_cau,
COUNT(match_time_seconds) AS so_ghep_thanh_cong,
COUNT(*) - COUNT(match_time_seconds) AS so_khong_ghep_duoc,
AVG(match_time_seconds) AS tg_cho_tb_khi_thanh_cong,
MAX(match_time_seconds) AS tg_cho_te_nhat
FROM ride_requests
WHERE city = 'HCM';
Kết quả: 12.000 yêu cầu, 9.800 ghép được, 2.200 cuốc không có tài xế — một tỷ lệ rớt 18% mà con số AVG ban đầu hoàn toàn che giấu. Bài học: AVG và NULL là cặp đôi nguy hiểm. Mỗi khi báo cáo trung bình, hãy đặt COUNT(*) và COUNT(cột) cạnh nhau để nhìn ra phần dữ liệu bị NULL "ăn mất".
Tình huống 3 — MoMo và việc dùng MIN/MAX để soi vòng đời khách hàng
Một BA tại ví điện tử (mô hình MoMo) muốn nắm nhanh "sức khỏe" giao dịch trong tháng. Thay vì chỉ đếm, bạn dùng MIN/MAX để hiểu biên độ dữ liệu:
SELECT
COUNT(*) AS so_giao_dich,
COUNT(DISTINCT user_id) AS so_nguoi_dung,
SUM(amount) AS tong_gia_tri,
AVG(amount) AS gia_tri_tb,
MIN(amount) AS gd_nho_nhat,
MAX(amount) AS gd_lon_nhat,
MIN(created_at) AS gd_dau_thang,
MAX(created_at) AS gd_gan_nhat
FROM transactions
WHERE created_at >= '2026-06-01';
Kết quả hé lộ vài điều: MIN(amount) = 1.000đ (giao dịch test hoặc nạp lẻ), MAX(amount) = 500.000.000đ (một khoản chuyển khoản lớn bất thường — đáng để đội rủi ro soi). Quan trọng hơn, COUNT() = 2,1 triệu nhưng COUNT(DISTINCT user_id) chỉ 340.000 — tức trung bình mỗi người dùng hơn 6 giao dịch/tháng, một tín hiệu giữ chân (retention) tốt. Bài học: MIN/MAX không chỉ để "tìm cực trị" — chúng là công cụ rẻ tiền để phát hiện outlier và kiểm tra phạm vi dữ liệu trước khi phân tích sâu. Và COUNT(DISTINCT) thường nói lên nhiều hơn COUNT().
Hướng dẫn từng bước
Giả sử bạn được giao: "Cho tôi báo cáo tổng quan đơn hàng đã hoàn tất trong tháng 6/2026." Cách tiếp cận có hệ thống:
- Xác định tập dòng cần tính. Trước khi nghĩ đến hàm, nghĩ đến WHERE. Ở đây là
status = 'completed'vàorder_datetrong tháng 6. Viết và chạy thử phầnSELECT * ... WHERE ...để xem dữ liệu thô đúng chưa.
- Chọn hàm tổng hợp phù hợp với từng câu hỏi. "Bao nhiêu đơn?" →
COUNT(*). "Tổng doanh thu?" →SUM(total_amount). "Giá trị đơn trung bình (AOV)?" →AVG(total_amount). "Đơn lớn nhất / nhỏ nhất?" →MAX/MIN.
- Đặt alias tiếng Việt rõ ràng bằng
ASđể người đọc báo cáo hiểu ngay, thay vìSUM(total_amount)khó đọc.
- Gộp tất cả vào một query:
SELECT
COUNT(*) AS so_don,
COUNT(DISTINCT customer_id) AS so_khach,
SUM(total_amount) AS doanh_thu,
AVG(total_amount) AS aov,
MIN(total_amount) AS don_nho_nhat,
MAX(total_amount) AS don_lon_nhat
FROM orders
WHERE status = 'completed'
AND order_date >= '2026-06-01'
AND order_date < '2026-07-01';
- Kiểm tra tính hợp lý (sanity check). AOV có nằm trong khoảng kỳ vọng không?
doanh_thuchiaso_doncó ra đúngaovkhông? NếuMINra số âm hoặc 0, đó có thể là lỗi dữ liệu cần điều tra.
- Đối chiếu COUNT(*) với COUNT(cột) ở các cột quan trọng để phát hiện NULL ẩn trước khi đưa số liệu đi xa.
Lỗi thường gặp & mẹo
Lỗi 1 — Nhầm COUNT() với COUNT(cột). Khi cột có NULL, hai cái cho kết quả khác nhau. Mẹo: muốn "đếm tất cả dòng" thì luôn dùng COUNT(); chỉ dùng COUNT(cột) khi bạn cố ý muốn loại NULL.
Lỗi 2 — Tưởng AVG coi NULL là 0. Nó không. Nếu nghiệp vụ cần coi NULL là 0, hãy dùng COALESCE (hoặc IFNULL trên MySQL):
SELECT AVG(COALESCE(rating, 0)) FROM reviews;
Như vậy NULL được tính vào mẫu số như một dòng có giá trị 0.
Lỗi 3 — SUM trả về NULL khi không có dòng nào. Nếu bộ lọc không khớp dòng nào, SUM ra NULL chứ không phải 0, dễ làm hỏng phép tính tiếp theo. Bọc lại cho an toàn: COALESCE(SUM(total_amount), 0).
Lỗi 4 — Đặt aggregate vào mệnh đề WHERE. Câu WHERE SUM(amount) > 1000 sẽ báo lỗi, vì WHERE lọc từng dòng trước khi tổng hợp, còn aggregate chỉ tồn tại sau khi gom nhóm. Lọc theo kết quả aggregate phải dùng HAVING — đây là cầu nối sang Bài 14.
Lỗi 5 — Quên DISTINCT khi đếm thực thể duy nhất. "Có bao nhiêu khách mua hàng?" mà viết COUNT(customer_id) sẽ đếm trùng (một khách mua 5 đơn bị tính 5 lần). Phải là COUNT(DISTINCT customer_id).
Mẹo — Dùng COUNT(*) - COUNT(cột) để đo độ thiếu dữ liệu của một cột bất kỳ, cực hữu ích khi đánh giá chất lượng dữ liệu nhanh.
Mẹo — COUNT(CASE WHEN ... THEN 1 END) là kỹ thuật "đếm có điều kiện" rất mạnh: đếm riêng đơn hủy, khách VIP, giao dịch lớn... ngay trong cùng một query mà không cần chạy nhiều lần.
Bài tập thực hành
Dùng bảng orders(order_id, customer_id, total_amount, status, order_date) và customers(customer_id, name, phone, city):
- Đếm tổng số đơn hàng, và đếm số đơn có trạng thái
'completed'— bằng đúng một query (gợi ý:COUNT(CASE WHEN ...)). - Tính doanh thu (chỉ đơn completed), giá trị đơn trung bình (AOV), đơn lớn nhất và nhỏ nhất.
- Tìm ngày phát sinh đơn đầu tiên và đơn gần nhất trong toàn bộ bảng.
- Đếm số khách hàng khác nhau đã từng đặt ít nhất một đơn.
- Trong bảng
customers, cho biết có bao nhiêu khách thiếu số điện thoại (gợi ý: hiệu củaCOUNT(*)vàCOUNT(phone)). - Tính
ratingtrung bình từ một bảngreviewstheo hai cách — một cách bỏ NULL (mặc định) và một cách coi NULL là 0 — rồi giải thích vì sao hai con số khác nhau.
COUNT(*) và COUNT(cột) không?Tóm tắt
Năm hàm tổng hợp COUNT, SUM, AVG, MIN, MAX là bộ công cụ cơ bản nhất nhưng cũng dễ dùng sai nhất của một BA. Ba điều quan trọng nhất cần khắc cốt:
- NULL là nhân vật chính ẩn mặt.
COUNT()đếm tất cả;COUNT(cột),SUM,AVG,MIN,MAXđều bỏ qua* NULL. Đặc biệtAVGloại NULL khỏi cả tử lẫn mẫu — đây là nguồn gốc của vô số báo cáo "đẹp giả tạo". - Sai số nằm ở bộ lọc, không ở hàm. Aggregate luôn cộng/đếm đúng những gì bạn đưa vào. Trước khi tin một con số, hãy chắc chắn WHERE (và CASE WHEN) đang chọn đúng tập dòng.
- Đặt các hàm cạnh nhau để hiểu sâu hơn.
COUNT(*)đi cùngCOUNT(cột)để soi NULL;SUM/AVGđi cùngMIN/MAXđể soi outlier;COUNT(DISTINCT)để đếm thực thể duy nhất.
GROUP BY và HAVING ở Bài 14 — nơi năm hàm này thực sự tỏa sáng.