Mở đầu — vì sao bài này quan trọng
Ở Bài 41 bạn đã học cách "lấy đúng dòng dữ liệu mình cần" với SELECT, WHERE, JOIN. Nhưng trong công việc thực tế của một Business Analyst, sếp hay Product Owner gần như không bao giờ hỏi bạn "cho tôi xem từng dòng đơn hàng". Họ hỏi: "Tháng 4 doanh thu bao nhiêu?", "Top 10 sản phẩm bán chạy nhất là gì?", "Tỷ lệ khách quay lại của tỉnh nào cao nhất?", "Khách hàng này là người mua hàng lần thứ mấy?".
Tất cả những câu hỏi đó đều có chung một đặc điểm: chúng yêu cầu bạn gom dữ liệu lại và tính toán trên từng nhóm, chứ không phải đọc từng dòng. Đây chính xác là vùng đất của ba công cụ trong bài này: GROUP BY, các hàm tổng hợp (aggregate functions), và bộ công cụ cao cấp hơn là window functions.
Tôi nói thẳng với bạn điều này: ranh giới giữa một BA "biết viết SQL cơ bản" và một BA "tự phân tích dữ liệu được, không cần làm phiền team Data" nằm chính ở bài này. Khi bạn nắm vững GROUP BY và window functions, bạn có thể tự trả lời 80% câu hỏi định lượng mà stakeholder ném vào bạn trong một buổi họp — ngay tại chỗ, không cần chờ ai. Đó là một lợi thế nghề nghiệp cực lớn. Hãy đầu tư nghiêm túc vào bài này.
Khái niệm cốt lõi
1. Aggregate functions — biến nhiều dòng thành một con số
Hàm tổng hợp nhận vào nhiều dòng và trả về một giá trị duy nhất. Năm hàm bạn dùng 95% thời gian:
COUNT()— đếm số dòng.COUNT(*)đếm tất cả,COUNT(column)chỉ đếm dòng có giá trị (bỏ NULL),COUNT(DISTINCT column)đếm số giá trị khác nhau.SUM()— cộng tổng (chỉ dùng cho số).AVG()— trung bình.MIN()/MAX()— nhỏ nhất / lớn nhất.
-- Tổng quan toàn bộ đơn hàng tháng 4
SELECT
COUNT(*) AS tong_so_don,
COUNT(DISTINCT user_id) AS so_khach_mua,
SUM(amount) AS tong_doanh_thu,
AVG(amount) AS gia_tri_don_trung_binh
FROM orders
WHERE order_date >= '2026-04-01' AND order_date < '2026-05-01';
2. GROUP BY — chia dữ liệu thành nhóm rồi tính trên từng nhóm
GROUP BY nói với database: "hãy gom các dòng có cùng giá trị ở cột này lại thành một nhóm, rồi áp dụng hàm tổng hợp cho từng nhóm". Đây là khái niệm quan trọng nhất của bài.
-- E-commerce VN: top 10 sản phẩm bán chạy tháng 4
SELECT
product_id,
COUNT(*) AS so_don,
SUM(amount) AS doanh_thu
FROM orders
WHERE order_date >= '2026-04-01' AND order_date < '2026-05-01'
GROUP BY product_id
ORDER BY doanh_thu DESC
LIMIT 10;
Quy tắc vàng phải khắc cốt ghi tâm: mọi cột xuất hiện trong SELECT mà không nằm trong một hàm tổng hợp thì bắt buộc phải có mặt trong GROUP BY. Nếu bạn SELECT product_id, product_name, SUM(amount) thì GROUP BY phải có cả product_id lẫn product_name. Đây là lỗi số một của người mới.
3. HAVING — lọc trên kết quả đã gom nhóm
Nhiều người nhầm WHERE và HAVING. Cách phân biệt cực kỳ đơn giản:
WHERElọc dòng gốc, chạy trước khi gom nhóm.HAVINGlọc nhóm, chạy sau khi gom nhóm — và đây là chỗ duy nhất bạn được dùng hàm tổng hợp để lọc.
-- Chỉ lấy những sản phẩm có doanh thu trên 100 triệu
SELECT product_id, SUM(amount) AS doanh_thu
FROM orders
GROUP BY product_id
HAVING SUM(amount) > 100000000;
Bạn không thể viết WHERE SUM(amount) > 100000000 — sẽ báo lỗi, vì lúc WHERE chạy thì chưa có khái niệm "tổng" nào cả.
4. Window functions — tính toán "có nhìn quanh" mà không làm mất chi tiết dòng
Đây là phần nâng cao và cũng là phần khiến BA trông thật sự "pro". Khác biệt cốt lõi so với GROUP BY:
> GROUP BY gom nhiều dòng thành một dòng. Window function giữ nguyên từng dòng nhưng vẫn cho phép bạn tính toán dựa trên một "cửa sổ" (window) các dòng xung quanh.
Cú pháp tổng quát:
hàm() OVER (PARTITION BY ... ORDER BY ...)
PARTITION BYgiống nhưGROUP BYmini — chia dữ liệu thành các phân vùng.ORDER BY(bên trongOVER) sắp xếp các dòng trong phân vùng để các hàm xếp hạng/chạy hoạt động.
a) Ranking — xếp hạng:
ROW_NUMBER()— đánh số thứ tự 1, 2, 3... (không lặp).RANK()— xếp hạng, đồng hạng thì nhảy số (1, 1, 3).DENSE_RANK()— xếp hạng, đồng hạng không nhảy số (1, 1, 2).
-- Xếp hạng sản phẩm theo doanh thu TRONG TỪNG danh mục
SELECT
category,
product_name,
doanh_thu,
RANK() OVER (PARTITION BY category ORDER BY doanh_thu DESC) AS hang_trong_nganh
FROM product_revenue;
b) Running total & so sánh dòng — cộng dồn và nhìn dòng trước/sau:
SUM(...) OVER (ORDER BY ...)— tổng lũy kế (running total).LAG()/LEAD()— lấy giá trị của dòng trước / dòng sau (dùng để tính tăng trưởng so với kỳ trước).
-- Doanh thu theo tháng và % tăng trưởng so với tháng trước
SELECT
thang,
doanh_thu,
LAG(doanh_thu) OVER (ORDER BY thang) AS doanh_thu_thang_truoc,
ROUND(
(doanh_thu - LAG(doanh_thu) OVER (ORDER BY thang))
/ LAG(doanh_thu) OVER (ORDER BY thang) * 100, 1
) AS tang_truong_pct
FROM monthly_revenue;
c) NTILE — chia nhóm phần trăm: NTILE(4) chia thành 4 nhóm bằng nhau (tứ phân vị) — cực hữu ích để phân khúc khách hàng.
Tình huống thực tế
Ví dụ 1 — Tiki: tìm top sản phẩm và phát hiện "cú lừa doanh thu"
Bối cảnh: Bạn là BA cho team Category của một sàn TMĐT kiểu Tiki/Lazada. Giám đốc ngành hàng yêu cầu báo cáo "top 10 sản phẩm bán chạy tháng 4" để quyết định nhập thêm hàng.
Lần đầu, bạn xếp hạng theo COUNT(*) (số đơn) và gửi đi. Nhưng một bạn trong team Finance phản hồi: "Mấy sản phẩm này bán nhiều đơn nhưng toàn hàng giá 20–30 nghìn (ốp lưng, dây sạc), đóng góp doanh thu rất thấp". Bạn viết lại để nhìn cả hai góc:
SELECT
p.product_name,
COUNT(*) AS so_don,
SUM(o.amount) AS doanh_thu,
AVG(o.amount) AS gia_tri_don_tb,
COUNT(DISTINCT o.user_id) AS so_khach
FROM orders o
JOIN products p ON p.product_id = o.product_id
WHERE o.order_date >= '2026-04-01' AND o.order_date < '2026-05-01'
AND o.status = 'completed'
GROUP BY p.product_name
HAVING COUNT(*) >= 50 -- bỏ nhiễu sản phẩm bán quá ít
ORDER BY doanh_thu DESC
LIMIT 10;
Bài học: Một con số duy nhất luôn kể chuyện thiếu. "Bán chạy" theo số đơn và "bán chạy" theo doanh thu là hai bảng xếp hạng hoàn toàn khác nhau. Là BA, bạn phải hỏi lại stakeholder "anh đang muốn tối ưu cái gì?" rồi mới chọn cột để ORDER BY. Lưu ý chi tiết AND o.status = 'completed' — luôn lọc bỏ đơn hủy/hoàn, nếu không doanh thu của bạn sẽ thổi phồng.
Ví dụ 2 — MoMo: phân khúc khách hàng theo RFM bằng NTILE
Bối cảnh: Bạn làm BA cho một ví điện tử kiểu MoMo/ZaloPay. Team Marketing muốn chia tệp khách thành "VIP", "thường", "ngủ đông" để gửi chương trình khác nhau, thay vì gửi đại trà tốn ngân sách.
Bạn dùng NTILE để chia khách thành 4 nhóm theo tổng chi tiêu — không cần định nghĩa ngưỡng cứng (vì ngưỡng cứng sẽ lỗi thời mỗi quý):
WITH chi_tieu_khach AS (
SELECT
user_id,
SUM(amount) AS tong_chi_tieu,
COUNT(*) AS so_giao_dich,
MAX(txn_date) AS lan_giao_dich_cuoi
FROM transactions
WHERE txn_date >= '2026-01-01'
GROUP BY user_id
)
SELECT
user_id,
tong_chi_tieu,
so_giao_dich,
NTILE(4) OVER (ORDER BY tong_chi_tieu DESC) AS phan_khuc
FROM chi_tieu_khach;
Nhóm phan_khuc = 1 là 25% khách chi nhiều nhất (VIP), nhóm 4 là chi ít nhất. Để ý cách tôi dùng WITH ... AS (...) — đây gọi là CTE (Common Table Expression), giúp bạn gom nhóm ở bước một rồi mới xếp hạng ở bước hai cho dễ đọc.
Bài học: Window function cho phép bạn phân khúc tương đối (top 25%) thay vì tuyệt đối (chi trên 5 triệu). Phân khúc tương đối tự thích nghi khi tệp khách thay đổi — một yêu cầu nghiệp vụ mà Marketing rất thích vì họ không phải đến nhờ bạn chỉnh ngưỡng mỗi tháng.
Ví dụ 3 — Chuỗi The Coffee House: tăng trưởng doanh thu chi nhánh với LAG
Bối cảnh: Bạn là BA cho một chuỗi F&B kiểu The Coffee House/Highlands. Sếp vùng hỏi: "Chi nhánh nào tháng này tăng trưởng so với tháng trước, chi nhánh nào tụt?".
Đây là bài toán kinh điển của LAG() kết hợp PARTITION BY:
WITH doanh_thu_thang AS (
SELECT store_id, DATE_TRUNC('month', sale_date) AS thang, SUM(amount) AS doanh_thu
FROM sales
GROUP BY store_id, DATE_TRUNC('month', sale_date)
)
SELECT
store_id,
thang,
doanh_thu,
LAG(doanh_thu) OVER (PARTITION BY store_id ORDER BY thang) AS thang_truoc,
doanh_thu - LAG(doanh_thu) OVER (PARTITION BY store_id ORDER BY thang) AS chenh_lech
FROM doanh_thu_thang
ORDER BY store_id, thang;
PARTITION BY store_id đảm bảo LAG chỉ so sánh trong cùng một chi nhánh — doanh thu tháng 4 của cửa hàng Nguyễn Huệ không bị so nhầm với cửa hàng Thảo Điền.
Bài học: LAG/LEAD thay thế cho cách làm thủ công cũ là tự join bảng với chính nó (self-join lệch tháng) — vốn rất dễ sai và khó đọc. Khi cần so sánh "kỳ này với kỳ trước", phản xạ đầu tiên của bạn nên là window function.
Hướng dẫn từng bước
Khi đối mặt một câu hỏi phân tích, hãy đi theo trình tự sau:
- Xác định đơn vị phân tích (mức gom nhóm). Stakeholder muốn xem theo cái gì? Theo sản phẩm? theo khách? theo tháng? theo chi nhánh? Câu trả lời chính là cột trong
GROUP BY(hoặcPARTITION BY). - Xác định các chỉ số cần tính. "Bao nhiêu" →
COUNT. "Tổng" →SUM. "Trung bình" →AVG. "Cao/thấp nhất" →MAX/MIN. - Lọc dữ liệu gốc trước với
WHERE. Khoảng thời gian, trạng thái đơn (completed), loại bỏ test data. Lọc sớm giúp truy vấn nhanh và đúng. - Viết
GROUP BYvới đủ tất cả cột không-tổng-hợp. - Lọc nhóm với
HAVINGnếu cần (ví dụ chỉ lấy nhóm có trên N giao dịch). - Cần xếp hạng / so kỳ trước / cộng dồn / giữ chi tiết dòng? → chuyển sang window function. Nếu logic phức tạp, tách bước gom nhóm vào CTE (
WITH) rồi xếp hạng ở ngoài. ORDER BYvàLIMITđể ra báo cáo cuối cùng dễ đọc.
FROM → WHERE → GROUP BY → HAVING → SELECT → window functions → ORDER BY → LIMIT. Hiểu thứ tự này sẽ giải thích được hầu hết lỗi bạn gặp.Lỗi thường gặp & mẹo
- Quên cột trong
GROUP BY. Báo lỗi kiểu "column must appear in the GROUP BY clause". Nhớ quy tắc vàng: cột không nằm trong hàm tổng hợp thì phải nằm trongGROUP BY. - Dùng
WHEREđể lọc kết quả tổng hợp.WHERE SUM(...) > 100sẽ lỗi. DùngHAVING. COUNT()vsCOUNT(DISTINCT user_id). Đếm số đơn và số khách là hai con số khác nhau. Một khách mua 5 đơn:COUNT()=5 nhưngCOUNT(DISTINCT user_id)=1. Lẫn lộn hai cái này là nguồn gốc của vô số báo cáo sai.AVGbị NULL "ăn gian".AVG(column)tự bỏ qua dòng NULL ở mẫu số. Nếu nghiệp vụ coi NULL là 0, hãy dùngAVG(COALESCE(column, 0)).- Chia cho 0 khi tính tăng trưởng.
LAGcủa dòng đầu tiên là NULL → phép chia ra NULL. Bọc bằngNULLIF(mau_so, 0)để tránh lỗi. - Nhầm window function với GROUP BY. Nếu bạn vẫn cần nhìn thấy từng dòng chi tiết mà vẫn muốn có cột tổng/xếp hạng bên cạnh → dùng window. Nếu chỉ cần dòng tóm tắt → dùng
GROUP BY. - Mẹo trình bày cho stakeholder: đừng dán SQL hay bảng số thô vào slide. Hãy diễn giải con số thành insight ("chi nhánh Thảo Điền giảm 12% — cần điều tra"). Là BA, giá trị của bạn nằm ở ý nghĩa, không phải ở truy vấn.
Bài tập thực hành
Giả định bạn có bảng orders(order_id, user_id, product_id, category, amount, status, order_date) của một sàn TMĐT Việt Nam.
- Cơ bản: Viết truy vấn đếm số đơn
completedvà tổng doanh thu theo từngcategorytrong quý 2/2026, sắp xếp doanh thu giảm dần. - HAVING: Liệt kê các
user_idđã mua từ 3 đơn trở lên trong tháng 4, kèm tổng chi tiêu của họ. - Window — ranking: Trong mỗi
category, tìm 3 sản phẩm doanh thu cao nhất (gợi ý:ROW_NUMBER()/RANK()vớiPARTITION BY category, rồi lọc<= 3ở lớp ngoài qua CTE). - Window — LAG: Tính doanh thu theo tháng của toàn sàn và % tăng trưởng so với tháng liền trước, nhớ tránh chia cho 0.
- Tổng hợp thực chiến: Dùng
NTILE(5)chia khách hàng thành 5 nhóm theo tổng chi tiêu năm 2026, rồi với mỗi nhóm tính số khách và chi tiêu trung bình. Viết một câu insight bạn sẽ trình bày cho team Marketing từ kết quả này.
Tóm tắt
- Aggregate functions (
COUNT,SUM,AVG,MIN,MAX) biến nhiều dòng thành một con số tóm tắt. GROUP BYchia dữ liệu thành nhóm rồi tính tổng hợp trên từng nhóm; mọi cột không-tổng-hợp phải nằm trongGROUP BY.WHERElọc dòng gốc,HAVINGlọc nhóm — đừng lẫn lộn.- Window functions (
ROW_NUMBER,RANK,DENSE_RANK,LAG,LEAD,NTILE,SUM() OVER) giữ nguyên từng dòng nhưng cho phép xếp hạng, so sánh kỳ trước, cộng dồn và phân khúc — đây là kỹ năng nâng tầm BA. - Phản xạ nghề nghiệp: trước khi viết SQL, luôn hỏi "đơn vị phân tích là gì?" và "stakeholder đang muốn tối ưu chỉ số nào?". Con số chỉ có giá trị khi bạn diễn giải được nó thành quyết định.