Menu
ESC

Nhập từ khóa để tìm kiếm

↑↓ Di chuyển
Enter Mở
ESC Đóng

Đang tải...

Bài 42 — SQL cho BA: GROUP BY, aggregate, window functions

Chương Trình Chứng Chỉ Business Analyst Hoàn Chỉnh Bài 42/60

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.
Khi dùng một mình, chúng tính trên toàn bộ bảng:

-- 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 WHEREHAVING. Cách phân biệt cực kỳ đơn giản:

  • WHERE lọc dòng gốc, chạy trước khi gom nhóm.
  • HAVING lọ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 BY giống như GROUP BY mini — chia dữ liệu thành các phân vùng.
  • ORDER BY (bên trong OVER) 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.
Ba nhóm window function BA hay dù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ặc PARTITION 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 BY với đủ tất cả cột không-tổng-hợp.
  • Lọc nhóm với HAVING nế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 BYLIMIT để ra báo cáo cuối cùng dễ đọc.
Ghi nhớ thứ tự thực thi logic của SQL (khác thứ tự viết): FROMWHEREGROUP BYHAVINGSELECT → window functions → ORDER BYLIMIT. 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 trong GROUP BY.
  • Dùng WHERE để lọc kết quả tổng hợp. WHERE SUM(...) > 100 sẽ lỗi. Dùng HAVING.
  • COUNT() vs COUNT(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ưng COUNT(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.
  • AVG bị 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ùng AVG(COALESCE(column, 0)).
  • Chia cho 0 khi tính tăng trưởng. LAG của dòng đầu tiên là NULL → phép chia ra NULL. Bọc bằng NULLIF(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 completed và tổng doanh thu theo từng category trong 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ới PARTITION 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.
Hãy chạy thử trên một database thật (bạn có thể tải SQLite/PostgreSQL miễn phí và tự tạo bảng mẫu). Gõ tay từng câu sẽ giúp bạn nhớ lâu hơn đọc mười lần.

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 BY chia 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 trong GROUP BY.
  • WHERE lọc dòng gốc, HAVING lọ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.
Ở bài tiếp theo (Bài 43), chúng ta sẽ rời khỏi database và bước sang thế giới API — REST, JSON và status code — một kỹ năng nền tảng khác mà BA hiện đại không thể thiếu.