Menu
ESC

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

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

Đang tải...

GROUP BY và HAVING: pattern thường gặp

SQL and Data Analysis cho BA Bài 14/60

Mở đầu — vì sao bài này quan trọng

Nếu bạn làm Business Analyst (BA), thì 80% câu hỏi bạn nhận từ sếp, từ phòng marketing, từ phòng vận hành đều có dạng: "Mỗi nhóm X thì con số Y là bao nhiêu?". Mỗi tỉnh có bao nhiêu đơn hàng? Mỗi danh mục sản phẩm doanh thu thế nào? Mỗi tháng có bao nhiêu khách mới? Mỗi nhân viên sales chốt được bao nhiêu deal? Tất cả những câu hỏi đó đều quy về một thao tác duy nhất trong SQL: gom dữ liệu thành nhóm rồi tính toán trên từng nhóm. Đó chính là GROUP BY.

Ở bài trước bạn đã làm quen với các hàm tổng hợp (aggregate functions) như COUNT, SUM, AVG. Nhưng khi chỉ dùng một mình, chúng cho ra đúng một con số cho toàn bộ bảng. GROUP BY là thứ biến "một con số cho cả công ty" thành "một con số cho mỗi nhóm" — và đó mới là thứ BA thực sự cần để so sánh, xếp hạng, phát hiện điểm bất thường.

Đi kèm GROUP BY luôn có một người bạn song hành: HAVING. Rất nhiều bạn mới học nhầm HAVING với WHERE, dẫn đến query chạy sai mà không hề báo lỗi — kiểu lỗi nguy hiểm nhất, vì bạn vẫn ra số, chỉ là số sai. Bài này sẽ giúp bạn nắm vững quy tắc vàng của GROUP BY, phân biệt rạch ròi WHERE với HAVING, và thuộc lòng những pattern thường gặp mà một BA gặp gần như hàng ngày.

Khái niệm cốt lõi

GROUP BY làm gì?

GROUP BY lấy tất cả các dòng có cùng giá trị ở một (hoặc nhiều) cột rồi "gấp" chúng lại thành một dòng đại diện cho mỗi nhóm. Sau khi gấp lại, bạn dùng aggregate function để tính toán trên các dòng bên trong mỗi nhóm.

Hãy hình dung bảng orders của một sàn thương mại điện tử:

SELECT city, COUNT(*) AS so_don
FROM orders
GROUP BY city;

Kết quả không còn là từng đơn hàng riêng lẻ nữa, mà là một bảng tóm tắt: Hà Nội — 4.200 đơn, TP.HCM — 6.800 đơn, Đà Nẵng — 1.150 đơn... Mỗi dòng đại diện cho một thành phố, và COUNT(*) đếm số đơn trong nhóm đó.

Quy tắc vàng — học thuộc một lần, dùng cả đời

> Mọi cột xuất hiện trong SELECT phải hoặc nằm trong GROUP BY, hoặc nằm bên trong một aggregate function.

Tại sao? Vì sau khi gom nhóm, mỗi nhóm chỉ còn một dòng đại diện. Nếu bạn yêu cầu hiển thị một cột mà SQL không biết phải lấy giá trị nào trong nhóm, nó sẽ bối rối.

-- ĐÚNG: city nằm trong GROUP BY, COUNT(*) là aggregate
SELECT city, COUNT(*) AS so_don
FROM orders
GROUP BY city;

-- SAI: customer_name không nằm trong GROUP BY, cũng không phải aggregate SELECT city, customer_name, COUNT(*) FROM orders GROUP BY city;

Trong câu sai, một thành phố như TP.HCM có 6.800 khách khác nhau — SQL biết hiển thị tên nào? PostgreSQL sẽ báo lỗi thẳng. MySQL (với chế độ mặc định cũ) đôi khi "im lặng" trả về một tên ngẫu nhiên — và đây chính là cái bẫy khiến bạn ra báo cáo sai. Hãy luôn tuân thủ quy tắc vàng bất kể database nào.

GROUP BY nhiều cột

Bạn có thể gom theo tổ hợp nhiều cột. Khi đó mỗi nhóm là một tổ hợp giá trị duy nhất:

SELECT city, status, COUNT(*) AS so_don
FROM orders
GROUP BY city, status;

Câu này cho bạn: Hà Nội — completed — 3.900; Hà Nội — cancelled — 300; TP.HCM — completed — 6.100... Mỗi cặp (city, status) là một nhóm riêng. Đây là pattern cực kỳ hữu ích để làm bảng chéo (cross-tab).

HAVING — lọc trên nhóm, không phải trên dòng

Đây là phần khiến nhiều người vấp. Hãy ghi nhớ sự khác biệt then chốt:

  • WHERE lọc từng dòng trước khi gom nhóm. Nó không được dùng aggregate function.
  • HAVING lọc từng nhóm sau khi đã gom nhóm. Nó được phép dùng aggregate function.
-- Chỉ giữ những thành phố có TRÊN 1000 đơn
SELECT city, COUNT(*) AS so_don
FROM orders
GROUP BY city
HAVING COUNT(*) > 1000;

Bạn không thể viết WHERE COUNT() > 1000 được — vì tại thời điểm WHERE chạy, các nhóm còn chưa hình thành, COUNT() chưa tồn tại. Phải dùng HAVING.

Thứ tự thực thi logic — chìa khóa để hiểu mọi thứ

SQL không thực thi theo thứ tự bạn viết. Thứ tự logic thực tế là:

  • FROM — lấy bảng nguồn
  • WHERE — lọc bỏ các dòng không thỏa
  • GROUP BY — gom các dòng còn lại thành nhóm
  • HAVING — lọc bỏ các nhóm không thỏa
  • SELECT — tính toán và chọn cột để hiển thị
  • ORDER BY — sắp xếp kết quả
Nắm được thứ tự này, bạn sẽ tự trả lời được mọi câu hỏi "tại sao query của tôi báo lỗi/chạy sai". Ví dụ: tại sao không dùng được alias đặt ở SELECT bên trong WHERE? Vì WHERE chạy trước SELECT, lúc đó alias chưa ra đời.

Tình huống thực tế

Tình huống 1: Tiki muốn biết tỉnh nào đang "gánh" doanh thu

Bối cảnh: Bạn là BA tại một sàn TMĐT kiểu Tiki. Giám đốc vùng yêu cầu một báo cáo: "Cho anh danh sách các tỉnh/thành mang về trên 500 triệu đồng doanh thu trong quý vừa rồi, sắp xếp từ cao xuống thấp, kèm số đơn và giá trị đơn trung bình."

Đây là một yêu cầu kinh điển kết hợp gần như mọi thứ trong bài:

SELECT
    city,
    COUNT(*)              AS so_don,
    SUM(total_amount)     AS doanh_thu,
    ROUND(AVG(total_amount)) AS gia_tri_don_tb
FROM orders
WHERE status = 'completed'
  AND order_date >= '2026-04-01'
  AND order_date <  '2026-07-01'
GROUP BY city
HAVING SUM(total_amount) > 500000000
ORDER BY doanh_thu DESC;

Diễn giải từng tầng:

  • WHERE lọc trước: chỉ lấy đơn đã hoàn tất, trong quý 2/2026. (Lưu ý: đơn cancelled mà tính vào doanh thu là sai lè — WHERE cứu bạn ở đây.)
  • GROUP BY city gom các đơn còn lại theo tỉnh.
  • HAVING SUM(total_amount) > 500000000 chỉ giữ tỉnh đạt ngưỡng nửa tỷ.
  • ORDER BY xếp hạng.
Bài học rút ra: Khi yêu cầu có cả "lọc theo điều kiện đơn lẻ" (đơn hoàn tất, trong quý) và "lọc theo điều kiện trên tổng" (doanh thu trên 500 triệu), bạn cần cả WHERE lẫn HAVING trong cùng một query. Đây là dấu hiệu nhận biết một BA đã vững tay: biết điều kiện nào thuộc WHERE, điều kiện nào thuộc HAVING.

Tình huống 2: Grab phát hiện tài xế "ảo" có hành vi bất thường

Bối cảnh: Bạn làm BA mảng chống gian lận tại một hãng gọi xe kiểu Grab/Be. Đội vận hành nghi ngờ có một số tài xế tự đặt cuốc cho chính mình để cày thưởng. Một dấu hiệu: cùng một tài xế có nhiều cuốc với cùng một khách trong một ngày một cách bất thường.

SELECT
    driver_id,
    rider_id,
    DATE(pickup_time)  AS ngay,
    COUNT(*)           AS so_cuoc
FROM trips
WHERE status = 'completed'
GROUP BY driver_id, rider_id, DATE(pickup_time)
HAVING COUNT(*) >= 5
ORDER BY so_cuoc DESC;

Diễn giải: Ta gom theo tổ hợp ba cột (tài xế, khách, ngày). Mỗi nhóm đại diện cho một cặp tài xế–khách trong một ngày cụ thể. HAVING COUNT(*) >= 5 chỉ ra những cặp đi với nhau từ 5 cuốc/ngày trở lên — một con số rất khó xảy ra với khách thật. Danh sách này được chuyển cho đội điều tra.

Bài học rút ra: GROUP BY nhiều cột + HAVING COUNT(*) >= n là pattern phát hiện trùng lặp/bất thường cực mạnh. Bạn sẽ gặp lại pattern này khi tìm khách hàng trùng, email trùng, giao dịch trùng. Hãy ghi nhớ nó như một "khuôn mẫu" sẵn có.

Tình huống 3: MoMo lọc ra khách hàng "VIP nhưng tần suất thấp"

Bối cảnh: Bạn là BA tại một ví điện tử kiểu MoMo. Đội marketing muốn tìm nhóm khách "cá voi ngủ đông": tổng giá trị giao dịch cao (trên 50 triệu) nhưng số lần giao dịch ít (dưới 10 lần) trong 6 tháng — đây là nhóm tiềm năng để chăm sóc, vì họ có tiền nhưng chưa dùng app thường xuyên.

SELECT
    user_id,
    COUNT(*)            AS so_giao_dich,
    SUM(amount)         AS tong_gia_tri
FROM transactions
WHERE created_at >= '2026-01-01'
  AND txn_type = 'payment'
GROUP BY user_id
HAVING SUM(amount) > 50000000
   AND COUNT(*) < 10
ORDER BY tong_gia_tri DESC;

Diễn giải: Điểm hay ở đây là HAVING ghép hai điều kiện trên nhóm bằng AND: tổng giá trị cao và đồng thời số lần thấp. Một mình mỗi điều kiện không đủ — phải kết hợp mới ra đúng chân dung "cá voi ngủ đông".

Bài học rút ra: HAVING không chỉ chứa được một điều kiện. Bạn có thể ghép nhiều điều kiện aggregate bằng AND/OR để mô tả những phân khúc khách hàng tinh vi. Đây là nơi SQL bắt đầu trở thành công cụ phân tích chứ không chỉ là công cụ truy vấn.

Hướng dẫn từng bước

Khi nhận một yêu cầu báo cáo, hãy đi theo quy trình 5 bước sau để dịch nó thành câu GROUP BY chuẩn:

Bước 1 — Xác định "mỗi cái gì". Đọc kỹ yêu cầu, tìm cụm từ "mỗi...". "Mỗi tỉnh", "mỗi tháng", "mỗi nhân viên" → đó chính là (các) cột cho GROUP BY. Nếu có hai chữ "mỗi" (mỗi tỉnh mỗi tháng) thì GROUP BY hai cột.

Bước 2 — Xác định con số cần tính. "Bao nhiêu đơn" → COUNT(*). "Tổng doanh thu" → SUM(...). "Trung bình" → AVG(...). "Cao nhất/thấp nhất" → MAX/MIN(...). Đây là phần aggregate trong SELECT.

Bước 3 — Tách điều kiện lọc thành hai loại. Điều kiện về từng dòng dữ liệu thô (ngày, trạng thái, loại) → cho vào WHERE. Điều kiện về con số đã tính trên nhóm (tổng trên X, đếm dưới Y) → cho vào HAVING.

Bước 4 — Viết theo đúng thứ tự cú pháp. SELECTFROMWHEREGROUP BYHAVINGORDER BY. Luôn theo đúng trình tự này, sai thứ tự sẽ báo lỗi cú pháp.

Bước 5 — Kiểm tra quy tắc vàng. Rà lại SELECT: mọi cột không nằm trong aggregate đã có mặt trong GROUP BY chưa? Nếu thiếu một cột, hoặc bổ sung vào GROUP BY, hoặc bọc nó bằng aggregate.

Áp dụng vào yêu cầu mẫu: "Mỗi danh mục sản phẩm, doanh thu là bao nhiêu, chỉ lấy danh mục trên 100 triệu, năm 2026."

  • Bước 1: "mỗi danh mục" → GROUP BY category
  • Bước 2: "doanh thu" → SUM(total_amount)
  • Bước 3: "năm 2026" → WHERE; "trên 100 triệu" → HAVING
  • Bước 4 & 5:
SELECT category, SUM(total_amount) AS doanh_thu
FROM order_items
WHERE order_date >= '2026-01-01' AND order_date < '2027-01-01'
GROUP BY category
HAVING SUM(total_amount) > 100000000
ORDER BY doanh_thu DESC;

Lỗi thường gặp & mẹo

Lỗi 1 — Dùng WHERE cho điều kiện aggregate. Viết WHERE COUNT(*) > 100 sẽ báo lỗi (hoặc tệ hơn, ở vài DB cũ là chạy sai). Hễ điều kiện của bạn có COUNT, SUM, AVG... thì nó phải nằm trong HAVING.

Lỗi 2 — Quên một cột không-aggregate trong GROUP BY. Đây là lỗi phổ biến nhất. PostgreSQL báo column ... must appear in the GROUP BY clause. MySQL cũ thì âm thầm trả số sai. Mẹo: trước khi chạy, nhìn lướt SELECT, mọi cột "trần" (không bọc aggregate) đều phải có trong GROUP BY.

Lỗi 3 — Đếm nhầm vì NULL. COUNT() đếm mọi dòng kể cả NULL, nhưng COUNT(cot_x) chỉ đếm các dòng có cot_x khác NULL. Nếu muốn đếm số khách có số điện thoại, dùng COUNT(phone) chứ không phải COUNT(). Khác biệt nhỏ này gây sai số báo cáo rất hay gặp.

Lỗi 4 — Đếm trùng khi cần đếm duy nhất. "Mỗi tỉnh có bao nhiêu khách hàng?" — nếu một khách đặt 10 đơn, COUNT() đếm thành 10. Bạn cần COUNT(DISTINCT customer_id) để đếm số khách khác nhau*. Phân biệt "số đơn" và "số khách" là kỹ năng cốt tử của BA.

Mẹo 1 — WHERE trước cho nhanh. Luôn lọc bớt dữ liệu bằng WHERE trước khi gom nhóm. Lọc sớm thì có ít dòng để gom hơn, query chạy nhanh hơn. Đừng để mọi dòng vào nhóm rồi mới lọc bằng HAVING nếu điều kiện đó vốn thuộc về WHERE.

Mẹo 2 — HAVING có thể dùng aggregate không xuất hiện trong SELECT. Bạn hoàn toàn có thể viết HAVING SUM(amount) > 1000000 ngay cả khi không hiển thị SUM(amount) trong SELECT. Tận dụng điều này để lọc mà không làm rối bảng kết quả.

Mẹo 3 — Cẩn thận với ORDER BY và alias. Phần lớn database cho phép ORDER BY doanh_thu dùng alias, nhưng HAVING ở nhiều DB lại không cho dùng alias đặt ở SELECT — phải viết lại đầy đủ HAVING SUM(total_amount) > .... Khi nghi ngờ, cứ viết đầy đủ biểu thức aggregate trong HAVING cho chắc.

Bài tập thực hành

Giả sử bạn có bảng orders(order_id, customer_id, city, status, total_amount, order_date) của một sàn TMĐT Việt Nam. Hãy viết query cho các yêu cầu sau:

  • Cơ bản: Đếm số đơn hàng theo từng trạng thái (status), sắp xếp giảm dần theo số đơn.
  • WHERE + GROUP BY: Với các đơn completed trong năm 2026, tính tổng doanh thu theo từng thành phố.
  • HAVING: Liệt kê các thành phố có trên 2.000 đơn hoàn tất, kèm số đơn và doanh thu.
  • Đếm duy nhất: Mỗi thành phố có bao nhiêu khách hàng khác nhau đã từng mua? (Gợi ý: COUNT(DISTINCT ...).)
  • WHERE + HAVING kết hợp: Tìm các khách hàng (customer_id) có tổng chi tiêu trên 20 triệu từ 5 đơn trở lên trong năm 2026.
  • Nâng cao (phát hiện bất thường): Tìm các khách hàng đặt từ 3 đơn trở lên trong cùng một ngày — dấu hiệu cần kiểm tra. (Gợi ý: GROUP BY customer_id, order_date.)
Hãy tự viết, chạy thử, rồi đối chiếu với quy trình 5 bước ở phần Hướng dẫn. Với mỗi câu, tự hỏi: điều kiện này thuộc WHERE hay HAVING? Đó là câu hỏi quan trọng nhất.

Tóm tắt

  • GROUP BY gom các dòng cùng giá trị thành nhóm, rồi aggregate function tính toán trên mỗi nhóm — biến "một con số cho cả công ty" thành "một con số cho mỗi nhóm".
  • Quy tắc vàng: mọi cột trong SELECT phải nằm trong GROUP BY hoặc trong một aggregate function.
  • WHERE lọc dòng trước khi gom (không dùng được aggregate); HAVING lọc nhóm sau khi gom (dùng được aggregate). Nhầm hai cái này là lỗi nguy hiểm nhất vì query vẫn ra số.
  • Thứ tự thực thi logic: FROMWHEREGROUP BYHAVINGSELECTORDER BY. Hiểu thứ tự này, bạn tự gỡ được mọi lỗi.
  • Các pattern BA dùng hàng ngày: tổng hợp theo nhóm + HAVING ngưỡng (báo cáo doanh thu), GROUP BY nhiều cột + COUNT(*) >= n (phát hiện trùng/bất thường), HAVING ghép nhiều điều kiện aggregate (phân khúc khách hàng tinh vi).
  • Luôn phân biệt COUNT(*) với COUNT(DISTINCT ...) — "số đơn" khác "số khách".
Khi đã thành thạo GROUP BYHAVING, bạn đã nắm trong tay công cụ trả lời được phần lớn các câu hỏi nghiệp vụ thường ngày. Ở bài tiếp theo, chúng ta sẽ bước sang subqueries để trả lời những câu hỏi cần "kết quả của một query làm đầu vào cho query khác".