SQL 2 — Truy vấn cốt lõi: WHERE, ORDER BY, GROUP BY, HAVING

13 thg 7, 2026 3 lượt xem
#sql
#where
#aggregate
#group-by

Mở đầu

Sau khi đã làm quen với SELECT cơ bản, đây là lúc bước vào phần "xương sống" của mọi câu truy vấn phân tích dữ liệu: lọc (WHERE), sắp xếp (ORDER BY), gộp nhóm (GROUP BY) và các hàm tổng hợp. Đây cũng là nơi người mới hay mắc lỗi nhất — không phải vì cú pháp khó, mà vì hiểu sai thứ tự thực thi logic của câu lệnh SQL.

Bài viết này đi từ con số 0: bạn sẽ học cách lọc dữ liệu chính xác, sắp xếp đúng ý, gộp nhóm để tính tổng/đếm, và quan trọng nhất — hiểu tại sao SQL hoạt động như nó hoạt động. Khi nắm được thứ tự thực thi logic, mọi lỗi "column not found", "không dùng được alias", hay "HAVING với WHERE khác gì nhau" sẽ tự sáng tỏ.

Tất cả ví dụ dùng bộ bảng sandbox sau (bạn có thể chạy ngay trong SQL Builder):

  • customers(id, full_name, city, created_at)
  • accounts(id, customer_id, account_no, balance, currency)
  • transactions(id, account_id, amount, kind['credit'|'debit'], created_at)
  • employees(id, name, department_id, salary, hired_at)
  • departments(id, name)

WHERE — lọc các hàng

WHERE đặt sau FROM, dùng để giữ lại những hàng thỏa mãn một điều kiện. Database duyệt qua từng hàng, đánh giá biểu thức điều kiện, và chỉ giữ hàng nào cho kết quả TRUE.

Toán tử so sánh

Các toán tử cơ bản: =, <> (hoặc !=), <, >, <=, >=.

-- ▶ Chạy được trong SQL Builder
-- Khách hàng ở Hà Nội
SELECT id, full_name, city
FROM customers
WHERE city = 'Hà Nội';
-- ▶ Chạy được trong SQL Builder
-- Tài khoản có số dư trên 10 triệu
SELECT account_no, balance, currency
FROM accounts
WHERE balance > 10000000;

AND, OR, NOT

Khi cần nhiều điều kiện, kết hợp bằng AND (tất cả phải đúng), OR (ít nhất một đúng), NOT (phủ định). Lưu ý: AND có độ ưu tiên cao hơn OR, nên hãy dùng dấu ngoặc để diễn đạt rõ ý.

-- ▶ Chạy được trong SQL Builder
-- Tài khoản VND có số dư từ 5 triệu trở lên
SELECT account_no, balance, currency
FROM accounts
WHERE currency = 'VND' AND balance >= 5000000;
-- ▶ Chạy được trong SQL Builder
-- Khách ở Hà Nội HOẶC Đà Nẵng (chú ý dấu ngoặc khi trộn AND/OR)
SELECT full_name, city
FROM customers
WHERE (city = 'Hà Nội' OR city = 'Đà Nẵng');

IN — thuộc một tập giá trị

IN là cách viết gọn của nhiều điều kiện OR trên cùng một cột.

-- ▶ Chạy được trong SQL Builder
-- Tương đương: city = 'Hà Nội' OR city = 'Đà Nẵng' OR city = 'Hồ Chí Minh'
SELECT full_name, city
FROM customers
WHERE city IN ('Hà Nội', 'Đà Nẵng', 'Hồ Chí Minh');

BETWEEN — nằm trong khoảng

BETWEEN a AND b bao gồm cả hai đầu mút (>= a AND <= b). Dùng được cho số, ngày, và cả chuỗi.

-- ▶ Chạy được trong SQL Builder
-- Tài khoản có số dư từ 1 triệu đến 5 triệu (bao gồm cả hai mốc)
SELECT account_no, balance
FROM accounts
WHERE balance BETWEEN 1000000 AND 5000000;

LIKE — so khớp mẫu chuỗi

LIKE dùng cho tìm kiếm theo mẫu. Hai ký tự đại diện:

  • % — khớp với 0 hoặc nhiều ký tự bất kỳ.
  • _ — khớp với đúng 1 ký tự.
-- ▶ Chạy được trong SQL Builder
-- Họ tên bắt đầu bằng "Nguyễn"
SELECT full_name
FROM customers
WHERE full_name LIKE 'Nguyễn%';
-- ▶ Chạy được trong SQL Builder
-- Số tài khoản kết thúc bằng "001"
SELECT account_no
FROM accounts
WHERE account_no LIKE '%001';

IS NULL — xử lý giá trị rỗng

NULL là "không có giá trị" — và nó không bằng bất cứ thứ gì, kể cả chính nó. Vì vậy city = NULL luôn cho kết quả UNKNOWN (không phải TRUE). Để kiểm tra NULL phải dùng IS NULL / IS NOT NULL.

-- ▶ Chạy được trong SQL Builder
-- Khách hàng chưa có thông tin thành phố
SELECT id, full_name
FROM customers
WHERE city IS NULL;

Lỗi kinh điển của người mới: viết WHERE city != 'Hà Nội' rồi ngạc nhiên vì các hàng có city = NULL không xuất hiện. Lý do: so sánh với NULL cho UNKNOWN, và WHERE chỉ giữ hàng TRUE. Muốn lấy cả hàng NULL, phải thêm OR city IS NULL.

ORDER BY — sắp xếp kết quả

ORDER BY quyết định thứ tự các hàng trong kết quả. Mặc định là tăng dần (ASC); thêm DESC để giảm dần.

-- ▶ Chạy được trong SQL Builder
-- Tài khoản sắp xếp theo số dư giảm dần
SELECT account_no, balance, currency
FROM accounts
ORDER BY balance DESC;

Có thể sắp xếp theo nhiều cột — database sắp theo cột đầu tiên trước, gặp giá trị bằng nhau mới xét đến cột tiếp theo.

-- ▶ Chạy được trong SQL Builder
-- Sắp theo loại tiền (A-Z), trong mỗi loại sắp theo số dư giảm dần
SELECT account_no, currency, balance
FROM accounts
ORDER BY currency ASC, balance DESC;

ORDER BY được thực thi sau SELECT (xem phần thứ tự thực thi bên dưới), bạn được phép dùng alias đã đặt trong SELECT ở đây:

-- ▶ Chạy được trong SQL Builder
-- Alias "so_du" dùng được trong ORDER BY
SELECT account_no, balance AS so_du
FROM accounts
ORDER BY so_du DESC;

LIMIT và OFFSET — phân trang

LIMIT n giới hạn số hàng trả về; OFFSET m bỏ qua m hàng đầu. Kết hợp với ORDER BY để có kết quả ổn định (không có ORDER BY thì thứ tự không được đảm bảo).

-- ▶ Chạy được trong SQL Builder
-- Top 5 tài khoản số dư cao nhất
SELECT account_no, balance
FROM accounts
ORDER BY balance DESC
LIMIT 5;
-- ▶ Chạy được trong SQL Builder
-- Trang 2, mỗi trang 5 dòng (bỏ qua 5 dòng đầu)
SELECT account_no, balance
FROM accounts
ORDER BY balance DESC
LIMIT 5 OFFSET 5;

DISTINCT — loại bỏ trùng lặp

DISTINCT loại bỏ các hàng trùng nhau trong kết quả. Nó áp dụng cho toàn bộ danh sách cột trong SELECT, không phải riêng cột đầu tiên.

-- ▶ Chạy được trong SQL Builder
-- Danh sách các thành phố (mỗi thành phố một lần)
SELECT DISTINCT city
FROM customers;
-- ▶ Chạy được trong SQL Builder
-- Các cặp (thành phố, ...) duy nhất — DISTINCT xét cả tổ hợp cột
SELECT DISTINCT city, full_name
FROM customers
ORDER BY city;

Hàm tổng hợp — COUNT, SUM, AVG, MIN, MAX

Hàm tổng hợp (aggregate functions) gom nhiều hàng thành một giá trị duy nhất:

  • COUNT(*) — đếm số hàng (kể cả hàng có NULL).
  • COUNT(cot) — đếm số hàng mà cot khác NULL.
  • SUM(cot) — tổng các giá trị.
  • AVG(cot) — trung bình cộng (bỏ qua NULL).
  • MIN(cot) / MAX(cot) — giá trị nhỏ nhất / lớn nhất.
-- ▶ Chạy được trong SQL Builder
-- Thống kê tổng quan trên toàn bộ bảng accounts
SELECT
  COUNT(*)        AS so_tai_khoan,
  SUM(balance)    AS tong_so_du,
  AVG(balance)    AS so_du_trung_binh,
  MIN(balance)    AS thap_nhat,
  MAX(balance)    AS cao_nhat
FROM accounts;

Khi không có GROUP BY, hàm tổng hợp coi toàn bộ bảng (sau khi lọc bởi WHERE) là một nhóm duy nhất và trả về đúng một hàng.

-- ▶ Chạy được trong SQL Builder
-- Đếm số tài khoản bằng tiền VND
SELECT COUNT(*) AS so_tai_khoan_vnd
FROM accounts
WHERE currency = 'VND';

GROUP BY — gộp nhóm

GROUP BY chia các hàng thành nhiều nhóm theo giá trị của một hay nhiều cột, rồi áp dụng hàm tổng hợp cho từng nhóm. Kết quả có một hàng cho mỗi nhóm.

-- ▶ Chạy được trong SQL Builder
-- Tổng số dư theo từng loại tiền
SELECT currency, SUM(balance) AS tong_so_du
FROM accounts
GROUP BY currency;
-- ▶ Chạy được trong SQL Builder
-- Đếm số tài khoản và số dư trung bình theo loại tiền
SELECT
  currency,
  COUNT(*)     AS so_tai_khoan,
  AVG(balance) AS so_du_tb
FROM accounts
GROUP BY currency
ORDER BY so_tai_khoan DESC;

Có thể gộp nhóm theo nhiều cột — mỗi tổ hợp giá trị tạo thành một nhóm riêng:

-- ▶ Chạy được trong SQL Builder
-- Tổng tiền theo từng tài khoản và loại giao dịch (credit/debit)
SELECT
  account_id,
  kind,
  SUM(amount) AS tong_tien,
  COUNT(*)    AS so_giao_dich
FROM transactions
GROUP BY account_id, kind
ORDER BY account_id, kind;

Quy tắc vàng của GROUP BY

Mọi cột xuất hiện trong SELECTkhô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. Lý do trực giác: nếu một nhóm có 10 hàng với 10 giá trị account_no khác nhau, database không biết phải hiển thị giá trị nào — nên nó cấm.

-- ▶ Chạy được trong SQL Builder
-- ĐÚNG: department_id nằm trong GROUP BY, salary nằm trong hàm tổng hợp
SELECT department_id, AVG(salary) AS luong_tb, COUNT(*) AS so_nhan_vien
FROM employees
GROUP BY department_id;

HAVING — lọc theo nhóm

WHERE lọc từng hàng trước khi gộp nhóm. Còn HAVING lọc từng nhóm sau khi gộp nhóm — nên HAVING có thể dùng kết quả của hàm tổng hợp, còn WHERE thì không.

-- ▶ Chạy được trong SQL Builder
-- Chỉ giữ loại tiền có TỔNG số dư trên 100 triệu
SELECT currency, SUM(balance) AS tong_so_du
FROM accounts
GROUP BY currency
HAVING SUM(balance) > 100000000;
-- ▶ Chạy được trong SQL Builder
-- Phòng ban có từ 3 nhân viên trở lên
SELECT department_id, COUNT(*) AS so_nhan_vien
FROM employees
GROUP BY department_id
HAVING COUNT(*) >= 3;

WHERE và HAVING khác nhau thế nào?

Cùng một bài toán có thể dùng cả hai, nhưng ý nghĩa khác hẳn:

-- ▶ Chạy được trong SQL Builder
-- WHERE lọc giao dịch 'credit' TRƯỚC khi gộp;
-- HAVING lọc các nhóm có tổng > 50 triệu SAU khi gộp.
SELECT account_id, SUM(amount) AS tong_credit
FROM transactions
WHERE kind = 'credit'
GROUP BY account_id
HAVING SUM(amount) > 50000000
ORDER BY tong_credit DESC;

Quy tắc ghi nhớ:

  • Điều kiện trên giá trị từng hàng (như kind = 'credit', created_at >= '2026-01-01') → đặt ở WHERE. Hiệu quả hơn vì lọc sớm, giảm số hàng phải gộp.
  • Điều kiện trên kết quả tổng hợp của nhóm (như SUM(...) > 50000000, COUNT(*) >= 3) → đặt ở HAVING.

Thứ tự thực thi logic của câu lệnh

Đây là phần quan trọng nhất của bài. Bạn viết câu SQL theo thứ tự SELECT ... FROM ... WHERE ..., nhưng database thực thi logic theo một thứ tự khác hẳn. Hiểu thứ tự này giải thích gần như mọi điều "kỳ lạ" trong SQL.

Diễn giải từng bước:

  1. FROM — xác định nguồn dữ liệu (bảng nào).
  2. WHERE — lọc các hàng riêng lẻ. Ở bước này chưa có nhóm, chưa có hàm tổng hợp, chưa có alias từ SELECT.
  3. GROUP BY — gom các hàng còn lại thành nhóm.
  4. HAVING — lọc các nhóm; tại đây hàm tổng hợp đã có giá trị.
  5. SELECT — tính toán biểu thức cột, đặt alias. Đây là lần đầu tiên alias ra đời.
  6. DISTINCT — loại bỏ hàng kết quả trùng nhau.
  7. ORDER BY — sắp xếp; tại đây alias đã tồn tại nên dùng được.
  8. LIMIT/OFFSET — cắt lấy số hàng cuối cùng.

Vì sao không dùng được alias trong WHERE?

Hãy nhìn lại sơ đồ: WHERE chạy ở bước 2, nhưng alias chỉ được tạo ở bước 5 (SELECT). Khi WHERE được đánh giá, alias chưa tồn tại — nên database báo lỗi.

-- ▶ Chạy được trong SQL Builder
-- SAI (sẽ lỗi ở nhiều DB): alias "so_du" chưa tồn tại khi WHERE chạy
-- SELECT balance AS so_du FROM accounts WHERE so_du > 1000000;

-- ĐÚNG: lặp lại biểu thức gốc trong WHERE
SELECT balance AS so_du
FROM accounts
WHERE balance > 1000000;

Cùng lý do đó, bạn cũng không dùng được hàm tổng hợp trong WHERE (WHERE SUM(...) > 100 là sai), vì SUM chỉ có nghĩa sau khi GROUP BY đã chạy ở bước 3 — phải đưa nó vào HAVING.

Ngược lại, ORDER BY ở bước 7 — sau SELECT — nên dùng alias thoải mái:

-- ▶ Chạy được trong SQL Builder
-- ORDER BY dùng được alias "tong_so_du" vì nó chạy sau SELECT
SELECT currency, SUM(balance) AS tong_so_du
FROM accounts
GROUP BY currency
ORDER BY tong_so_du DESC;

Một ví dụ "đủ bộ" để chốt lại

Câu dưới đây dùng gần như tất cả mệnh đề. Hãy đọc nó theo thứ tự thực thi (FROM → WHERE → GROUP BY → HAVING → SELECT → ORDER BY → LIMIT) chứ không theo thứ tự viết:

-- ▶ Chạy được trong SQL Builder
-- Top 3 tài khoản có tổng giao dịch 'credit' lớn nhất,
-- chỉ xét các tài khoản có tổng credit trên 10 triệu.
SELECT
  account_id,
  SUM(amount) AS tong_credit,
  COUNT(*)    AS so_lan_nap
FROM transactions
WHERE kind = 'credit'              -- B2: lọc từng giao dịch
GROUP BY account_id                -- B3: gộp theo tài khoản
HAVING SUM(amount) > 10000000      -- B4: lọc nhóm theo tổng
ORDER BY tong_credit DESC          -- B7: sắp xếp (dùng alias OK)
LIMIT 3                            -- B8: lấy 3 dòng đầu

Tóm tắt

  • WHERE lọc từng hàng trước khi gộp nhóm; hỗ trợ =, <>, <, >, AND/OR/NOT, IN, BETWEEN, LIKE, và IS NULL (vì NULL không so sánh bằng =).
  • ORDER BY sắp xếp (ASC/DESC, nhiều cột); LIMIT/OFFSET phân trang; DISTINCT loại trùng trên toàn bộ tổ hợp cột.
  • Hàm tổng hợp (COUNT, SUM, AVG, MIN, MAX) gom nhiều hàng thành một giá trị; GROUP BY tạo một hàng kết quả cho mỗi nhóm, và mọi cột không tổng hợp trong SELECT phải có trong GROUP BY.
  • HAVING lọc nhóm sau khi gộp và dùng được hàm tổng hợp; WHERE lọc hàng trước khi gộp và không dùng được hàm tổng hợp.
  • Thứ tự thực thi logic: FROM → WHERE → GROUP BY → HAVING → SELECT → ORDER BY → LIMIT. Vì alias sinh ra ở SELECT, ta không dùng được alias trong WHERE nhưng dùng được trong ORDER BY.

Tự kiểm tra

  1. Tại sao WHERE city = NULL không bao giờ trả về hàng nào, và phải viết thế nào cho đúng?
  2. Cho biết khác biệt giữa COUNT(*)COUNT(city) khi bảng có một số hàng city là NULL.
  3. Viết câu truy vấn trả về tổng số dư theo từng currency, chỉ giữ các loại tiền có tổng trên 100 triệu, sắp xếp giảm dần.
  4. Vì sao đặt kind = 'credit' vào WHERE lại hiệu quả hơn so với (nếu có thể) đặt vào HAVING?
  5. Giải thích vì sao SELECT balance AS so_du FROM accounts WHERE so_du > 0 lại báo lỗi, dựa trên thứ tự thực thi logic.
  6. Trong câu có cả GROUP BYORDER BY, vì sao ORDER BY được dùng alias từ SELECT còn HAVING thì không?

Đọc tiếp

SQL 3 — JOIN toàn tập

Bài viết liên quan

Khoá, ràng buộc, quan hệ và chuẩn hoá 1NF/2NF/3NF — thiết kế lược đồ đúng từ đầu.

13 thg 7, 2026 4

PostgreSQL, MySQL, SQL Server, Oracle, SQLite và NoSQL (MongoDB, Redis): khác biệt và khi nào dùng.

13 thg 7, 2026 3

Cách index hoạt động (B-Tree), đọc EXPLAIN, seq scan vs index scan và mẫu tối ưu truy vấn.

13 thg 7, 2026 3

date_trunc/extract/age, cộng trừ khoảng thời gian, hàm chuỗi, ép kiểu CAST, COALESCE/NULLIF và làm tròn.

13 thg 7, 2026 3