Bài 24 — SELECT, WHERE, ORDER BY, LIMIT¶
🎯 Học xong bài này, bạn sẽ
- Viết được một câu
SELECTđầy đủ: chọn cột, đặt bí danh, lọc bằngWHERE, sắp xếp, cắt trang - Dùng thành thạo bảy loại vị từ: so sánh,
BETWEEN,IN,LIKE/ILIKE,IS NULL, vàAND/OR/NOT - Giải thích được logic ba giá trị và giá trị
UNKNOWN— cái bẫy số một của người mới học SQL - Biết vì sao
WHERE diem_so <> 10lại làm biến mất những dòng códiem_solàNULL - Điều khiển được vị trí của
NULLkhi sắp xếp, bằngNULLS FIRST/NULLS LAST
🧠 Câu chuyện mở đầu¶
Cô thủ thư nhờ bạn kiểm tra sổ mượn sách. Trong sổ có 50 lượt mượn. Cột "ngày trả thực tế" của một số lượt đang để trống — nghĩa là sách chưa được mang trả.
Cô hỏi: "Em lọc giúp cô những lượt mà ngày trả khác ngày 10 tháng 9 nhé."
Bạn viết một câu lệnh rất hiển nhiên: lấy mọi dòng có ngày trả khác 10/09. Máy trả về một danh sách. Bạn đưa cô. Cô đếm và nhíu mày: "Sao thiếu mất mấy lượt thế em? Cô nhớ là có hơn thế cơ mà."
Bạn kiểm lại. Đúng là thiếu. Và thứ bị thiếu chính là những lượt đang để trống ngày trả — những cuốn sách chưa ai mang trả về.
Nhưng khoan đã. Một ô để trống thì rõ ràng là "khác ngày 10 tháng 9" chứ còn gì nữa? Sao máy lại bỏ chúng ra?
Đây không phải lỗi của máy. Đây là chỗ mà SQL hành xử theo một logic không phải hai giá trị đúng/sai như bạn học ở môn Toán. Và nếu không biết điều đó, bạn sẽ còn bị nó lừa nhiều lần nữa.
📖 Khái niệm & thuật ngữ¶
Khung xương của một câu SELECT¶
SELECT [DISTINCT] <danh sách cột> -- phép chiếu π
FROM <bảng>
WHERE <vị từ> -- phép chọn σ
ORDER BY <cột> [ASC|DESC] [NULLS FIRST|LAST]
LIMIT <n> OFFSET <m>
Nối lại với Bài 21: SELECT là phép chiếu π, WHERE là phép chọn σ. Còn ORDER BY và LIMIT thì không có trong đại số quan hệ — chúng là phần SQL tự thêm vào, vì kết quả trên màn hình thì cần thứ tự, còn tập hợp trong toán thì không.
SELECTnêu các cột cần lấy. Dấu*nghĩa là "mọi cột".- Bí danh (alias) là tên tạm đặt cho một cột hoặc một bảng bằng từ khoá
AS. Nó chính là phép ρ (đổi tên) ở Bài 21. Bí danh cột làm tiêu đề kết quả dễ đọc; bí danh bảng làm câu lệnh ngắn lại và là điều bắt buộc khi ghép một bảng với chính nó. DISTINCTbỏ các dòng kết quả trùng nhau. Nhớ lại Bài 21: nó là thứ biến "phép chiếu của đa tập" thành đúng phép chiếu π của toán học.WHEREnhận một vị từ.LIMIT ngiới hạn số dòng trả về;OFFSET mbỏ quamdòng đầu. Hai cái đi cùng nhau tạo nên phân trang (pagination) của mọi trang web bạn từng dùng.
Vị từ¶
Vị từ (predicate) là một biểu thức trả về giá trị chân lý: TRUE, FALSE, hoặc — và đây là chỗ bất ngờ — UNKNOWN.
WHERE giữ lại duy nhất những dòng làm vị từ trả về TRUE. Dòng nào cho FALSE bị loại. Dòng nào cho UNKNOWN cũng bị loại.
Bảy loại vị từ bạn sẽ dùng hằng ngày:
| Vị từ | Ví dụ | Nghĩa |
|---|---|---|
| So sánh | luong > 15000000 |
=, <>, <, >, <=, >= |
BETWEEN |
khoi BETWEEN 6 AND 9 |
Trong khoảng, tính cả hai đầu mút |
IN |
ma_lop IN ('L01', 'L02') |
Thuộc một danh sách cho trước |
LIKE |
ho_ten LIKE 'Nguyễn%' |
Khớp mẫu chuỗi, phân biệt hoa thường |
ILIKE |
ho_ten ILIKE 'nguyễn%' |
Như LIKE nhưng không phân biệt hoa thường |
IS NULL |
ngay_tra_thuc_te IS NULL |
Ô có rỗng hay không |
AND / OR / NOT |
gioi_tinh = 'Nữ' AND khoi = 9 |
Ghép các vị từ lại |
Trong LIKE, hai ký tự đại diện: % thay cho không hoặc nhiều ký tự bất kỳ, _ thay cho đúng một ký tự.
Logic ba giá trị — phần quan trọng nhất của bài¶
Ở môn Toán, một mệnh đề chỉ có hai khả năng: đúng hoặc sai. SQL thì có ba.
Logic ba giá trị (three-valued logic) là hệ logic mà một biểu thức có thể nhận TRUE, FALSE, hoặc chưa biết (UNKNOWN).
Vì sao phải có giá trị thứ ba? Vì NULL không phải là một giá trị — nó là sự vắng mặt của giá trị. Nó nghĩa là "chỗ này chưa biết" hoặc "chỗ này không áp dụng".
Bây giờ hãy nghĩ như một nhà logic học. Ô "ngày trả thực tế" của một lượt mượn đang chưa biết. Vậy mệnh đề "ngày trả khác ngày 10 tháng 9" đúng hay sai?
Không trả lời được. Vì chưa biết ngày trả là ngày nào, nên cũng chưa biết nó có khác 10/9 hay không. Kết quả là UNKNOWN.
Và vì WHERE chỉ giữ dòng cho TRUE, dòng đó bị loại. Máy không sai. Máy đang rất trung thực: "tôi không dám khẳng định dòng này thoả điều kiện, nên tôi không đưa nó cho bạn."
Quy tắc vàng: mọi phép so sánh với NULL đều cho UNKNOWN
NULL = 5 → UNKNOWN. NULL <> 5 → UNKNOWN. NULL > 5 → UNKNOWN.
Kể cả NULL = NULL cũng là UNKNOWN — vì hai thứ đều chưa biết thì làm sao khẳng định chúng bằng nhau?
Đó là lý do phải có toán tử riêng IS NULL và IS NOT NULL. Chỉ hai toán tử này mới trả về TRUE/FALSE thật sự khi gặp NULL.
Ba bảng chân lý của logic ba giá trị:
Phép AND — chỉ cần một vế FALSE là cả biểu thức FALSE:
AND |
TRUE | FALSE | UNKNOWN |
|---|---|---|---|
| TRUE | TRUE | FALSE | UNKNOWN |
| FALSE | FALSE | FALSE | FALSE |
| UNKNOWN | UNKNOWN | FALSE | UNKNOWN |
Phép OR — chỉ cần một vế TRUE là cả biểu thức TRUE:
OR |
TRUE | FALSE | UNKNOWN |
|---|---|---|---|
| TRUE | TRUE | TRUE | TRUE |
| FALSE | TRUE | FALSE | UNKNOWN |
| UNKNOWN | TRUE | UNKNOWN | UNKNOWN |
Phép NOT — và đây là ô đáng nhớ nhất cả bài:
NOT |
Kết quả |
|---|---|
| TRUE | FALSE |
| FALSE | TRUE |
| UNKNOWN | UNKNOWN |
Hãy dừng lại ở dòng cuối. NOT UNKNOWN vẫn là UNKNOWN. Phủ định một điều chưa biết thì vẫn chưa biết.
Hệ quả rất cụ thể: trong logic hai giá trị, A và NOT A chia đôi toàn bộ dữ liệu — cộng lại phải ra đủ. Trong SQL điều đó không còn đúng. Những dòng cho UNKNOWN rơi ra khỏi cả hai phía. Bạn sẽ thấy tận mắt ở phần thực hành.
Sắp xếp và NULL¶
NULL không lớn hơn cũng không nhỏ hơn giá trị nào. Nên khi sắp xếp, PostgreSQL phải tự quy ước — và quy ước là: NULL được coi là lớn nhất.
| Câu lệnh | NULL nằm ở đâu |
|---|---|
ORDER BY c ASC |
Cuối — vì NULL lớn nhất |
ORDER BY c DESC |
Đầu — vì NULL lớn nhất |
ORDER BY c NULLS FIRST |
Đầu, bất kể ASC hay DESC |
ORDER BY c NULLS LAST |
Cuối, bất kể ASC hay DESC |
NULLS FIRST và NULLS LAST cho bạn quyền quyết định thay vì nhận mặc định. Với danh sách "sách chưa trả", đưa NULL lên đầu thường mới là điều cô thủ thư muốn thấy.
Bảng thuật ngữ¶
| Tiếng Việt | English | Nghĩa dễ hiểu |
|---|---|---|
| Bí danh | alias | Tên tạm đặt cho cột hoặc bảng bằng AS — chính là phép ρ của đại số quan hệ |
| Vị từ | predicate | Biểu thức trong WHERE trả về TRUE, FALSE hoặc UNKNOWN |
| Logic ba giá trị | three-valued logic | Hệ logic của SQL, có thêm giá trị thứ ba UNKNOWN bên cạnh TRUE và FALSE |
| Chưa biết | UNKNOWN | Giá trị chân lý thứ ba, sinh ra mỗi khi so sánh với NULL; WHERE loại bỏ mọi dòng cho UNKNOWN |
| Không phân biệt được | not distinct | Quan hệ mà DISTINCT, GROUP BY và các phép tập hợp dùng thay cho =: hai NULL là không phân biệt được nên bị gộp thành một |
| Phân trang | pagination | Cắt kết quả thành từng trang bằng LIMIT và OFFSET |
🖼️ Sơ đồ¶
WHERE chia dữ liệu làm ba rổ chứ không phải hai — và chỉ rổ TRUE được đi tiếp:
flowchart TB
IN["50 lượt mượn sách"]
VT{"Vị từ<br/><b>ngay_tra_thuc_te > ngày 01-09-2026</b>"}
T["<b>TRUE</b> — 38 dòng<br/>đã trả, sau ngày đó"]
F["<b>FALSE</b> — 0 dòng<br/>đã trả, trước ngày đó"]
U["<b>UNKNOWN</b> — 12 dòng<br/>chưa trả · ngay_tra_thuc_te IS NULL"]
OK["✅ Đi tiếp vào kết quả"]
BO["🗑️ BỊ LOẠI"]
IN --> VT
VT --> T
VT --> F
VT --> U
T --> OK
F --> BO
U --> BO
style IN fill:#e3f2fd,stroke:#1565c0,stroke-width:2px
style VT fill:#fff8e1,stroke:#f9a825,stroke-width:2px
style T fill:#e8f5e9,stroke:#2e7d32,stroke-width:2px
style F fill:#eceff1,stroke:#607d8b
style U fill:#ffebee,stroke:#c62828,stroke-width:3px
style OK fill:#e8f5e9,stroke:#2e7d32
style BO fill:#ffebee,stroke:#c62828
Cùng một dữ liệu, ba câu lệnh, ba kết quả — và tổng của hai câu đầu không ra câu thứ ba:
flowchart LR
A["<b>WHERE điều kiện</b><br/>38 dòng"]
B["<b>WHERE NOT điều kiện</b><br/>0 dòng"]
C["<b>WHERE điều kiện<br/>OR cột IS NULL</b><br/>50 dòng ✅"]
X["38 + 0 = 38<br/><b>≠ 50</b><br/>12 dòng biến mất"]
A --> X
B --> X
style A fill:#e3f2fd,stroke:#1565c0
style B fill:#e3f2fd,stroke:#1565c0
style C fill:#e8f5e9,stroke:#2e7d32,stroke-width:2px
style X fill:#ffebee,stroke:#c62828,stroke-width:2px
💻 Thực hành¶
Chọn cột và đặt bí danh¶
-- KỲ VỌNG: 6 dòng
SELECT ma_hs AS ma,
ho_ten AS ten_hoc_sinh,
ngay_sinh AS sinh_ngay
FROM hoc_sinh
WHERE ma_lop = 'L01'
ORDER BY ma_hs;
Bí danh cho bảng thì dùng nhiều hơn nữa, vì nó rút ngắn mọi thứ phía sau:
-- KỲ VỌNG: 6 dòng
SELECT h.ma_hs, h.ho_ten
FROM hoc_sinh AS h
WHERE h.ma_lop = 'L01'
ORDER BY h.ma_hs;
Từ khoá AS có thể bỏ (FROM hoc_sinh h), nhưng viết rõ ra thì dễ đọc hơn.
DISTINCT¶
DISTINCT xét toàn bộ danh sách cột chứ không riêng cột đầu:
Sáu lớp × hai giới tính, và cả 12 tổ hợp đều có thật trong dữ liệu.
Chỗ DUY NHẤT mà NULL không cư xử như phần còn lại của bài này
Cả bài này nói một điều: NULL = NULL cho UNKNOWN, hai giá trị chưa biết không khẳng định được là bằng nhau.
Nhưng DISTINCT thì coi mọi NULL là BẰNG NHAU và gộp chúng thành một dòng duy nhất.
Nghe như tự mâu thuẫn, nhưng không: DISTINCT không dùng phép so sánh =. Nó dùng một quan hệ khác — thứ mà SQL gọi là không phân biệt được (not distinct), chính là toán tử IS NOT DISTINCT FROM mà bạn sẽ gặp ở phần sau. Với quan hệ đó, NULL và NULL là không phân biệt được, nên chúng bị gộp.
Cột lop.ma_gvcn có 5 giá trị thật cộng 1 ô rỗng (lớp 9A3), và DISTINCT cho ra 6 dòng chứ không phải 5:
Một dòng trong số đó là NULL. Nếu DISTINCT dùng = thì hai NULL sẽ không bao giờ khớp nhau và kết quả phải là 5 giá trị thật cộng mỗi ô rỗng một dòng riêng — tức 6 ở đây chỉ là tình cờ, vì lop chỉ có một ô rỗng. Kiểm bằng một cột có nhiều ô rỗng thì thấy rõ:
-- KỲ VỌNG: so_gia_tri_phan_biet = 27
-- KỲ VỌNG: so_gia_tri_that = 26
-- KỲ VỌNG: so_dong_null = 12
SELECT count(*) AS so_gia_tri_phan_biet,
count(*) FILTER (WHERE d IS NOT NULL) AS so_gia_tri_that,
(SELECT count(*) FROM muon_sach WHERE ngay_tra_thuc_te IS NULL) AS so_dong_null
FROM (SELECT DISTINCT ngay_tra_thuc_te AS d FROM muon_sach) AS t;
12 dòng rỗng bị gộp thành đúng 1: 26 ngày trả phân biệt cộng 1 dòng NULL = 27. (Bảng có 38 lượt đã trả nhưng chỉ 26 ngày khác nhau — nhiều cuốn được trả cùng ngày.)
Quy tắc chung — và DISTINCT không phải ngoại lệ duy nhất. Bảng này đáng học thuộc:
| Chỗ | NULL cư xử thế nào |
|---|---|
WHERE, ON, CHECK, HAVING — mọi vị từ |
NULL = NULL là UNKNOWN, dòng bị loại |
DISTINCT |
Mọi NULL bằng nhau, gộp thành một |
GROUP BY (Bài 26) |
Mọi NULL bằng nhau, gộp thành một nhóm |
UNION / INTERSECT / EXCEPT (Bài 21) |
Mọi NULL bằng nhau |
UNIQUE (Bài 15) |
NULL khác mọi NULL — nên nhiều dòng rỗng cùng lọt qua UNIQUE |
Đọc dòng cuối cho kỹ: UNIQUE lại quay về ngữ nghĩa vị từ. Đó là lý do lop.ma_gvcn có UNIQUE mà vẫn cho nhiều lớp chưa có chủ nhiệm — trong dữ liệu mẫu hiện chỉ có một, nhưng ràng buộc không hề cấm lớp thứ hai.
Cách nhớ gọn nhất: khi SQL đi so sánh thì NULL là "chưa biết"; khi SQL đi gom nhóm thì NULL là "một giá trị như mọi giá trị khác".
Các vị từ¶
So sánh và BETWEEN:
-- KỲ VỌNG: so_gv_luong_tam_trung = 4
SELECT count(*) AS so_gv_luong_tam_trung
FROM giao_vien
WHERE luong BETWEEN 13000000 AND 16000000;
BETWEEN a AND b tính cả hai đầu mút, tức là tương đương >= a AND <= b.
IN và NOT IN:
-- KỲ VỌNG: trong_hai_lop = 12
-- KỲ VỌNG: ngoai_hai_lop = 28
SELECT count(*) FILTER (WHERE ma_lop IN ('L01', 'L02')) AS trong_hai_lop,
count(*) FILTER (WHERE ma_lop NOT IN ('L01', 'L02')) AS ngoai_hai_lop
FROM hoc_sinh;
Ở đây 12 + 28 = 40, đủ cả bảng — vì cột ma_lop được khai NOT NULL. Nếu cột cho phép NULL thì hai con số này sẽ không cộng lại thành tổng nữa; đó chính là bẫy mà phần sau sẽ mổ xẻ.
LIKE — phân biệt hoa thường:
-- KỲ VỌNG: ho_nguyen = 2
-- KỲ VỌNG: co_chu_thi = 20
-- KỲ VỌNG: co_chu_van = 10
SELECT count(*) FILTER (WHERE ho_ten LIKE 'Nguyễn%') AS ho_nguyen,
count(*) FILTER (WHERE ho_ten LIKE '%Thị%') AS co_chu_thi,
count(*) FILTER (WHERE ho_ten LIKE '%Văn%') AS co_chu_van
FROM hoc_sinh;
Bây giờ đổi một chữ hoa thành chữ thường, và xem chuyện gì xảy ra:
-- KỲ VỌNG: like_chu_thuong = 0
-- KỲ VỌNG: ilike_chu_thuong = 20
SELECT count(*) FILTER (WHERE ho_ten LIKE '%thị%') AS like_chu_thuong,
count(*) FILTER (WHERE ho_ten ILIKE '%thị%') AS ilike_chu_thuong
FROM hoc_sinh;
LIKE tìm được không dòng nào, vì trong dữ liệu chữ đó luôn viết hoa là Thị. ILIKE — chữ I là insensitive, không phân biệt — tìm ra đủ 20 dòng. Đây là mở rộng riêng của PostgreSQL; nhiều DBMS khác phải viết WHERE lower(ho_ten) LIKE '%thị%'.
Ký tự _ khớp đúng một ký tự:
Chín mã HS001…HS009 — không lấy HS010, vì mẫu chỉ chừa đúng một ô trống mà 10 lại chiếm hai ký tự.
Logic ba giá trị — xem tận mắt¶
Bảng muon_sach có 50 lượt, trong đó 12 lượt chưa trả nên ngay_tra_thuc_te IS NULL:
-- KỲ VỌNG: tong_so_luot = 50
-- KỲ VỌNG: da_tra = 38
-- KỲ VỌNG: chua_tra = 12
SELECT count(*) AS tong_so_luot,
count(ngay_tra_thuc_te) AS da_tra,
count(*) - count(ngay_tra_thuc_te) AS chua_tra
FROM muon_sach;
Bây giờ là màn chính. Ba phép đếm dưới đây dùng cùng một điều kiện, và kết quả của chúng phá vỡ mọi trực giác từ môn Toán.
Cả ba được gói vào một câu lệnh nhờ FILTER (WHERE ...) — cú pháp bạn đã gặp lần đầu ở Bài 22 và sẽ học đầy đủ ở Bài 26. Ở đây chỉ cần đọc count(*) FILTER (WHERE đk) là "đếm số dòng thoả điều kiện đk". Gói chung một câu là có chủ đích: nó bảo đảm cả bốn con số được đếm trên đúng cùng một bảng, cùng một thời điểm — không ai bắt bẻ được.
-- KỲ VỌNG: dieu_kien_dung = 38
-- KỲ VỌNG: phu_dinh_dieu_kien = 0
-- KỲ VỌNG: tong_hai_cai_tren = 38
-- KỲ VỌNG: so_dong_that_su = 50
SELECT count(*) FILTER (WHERE ngay_tra_thuc_te > DATE '2026-09-01') AS dieu_kien_dung,
count(*) FILTER (WHERE NOT (ngay_tra_thuc_te > DATE '2026-09-01')) AS phu_dinh_dieu_kien,
count(*) FILTER (WHERE ngay_tra_thuc_te > DATE '2026-09-01')
+ count(*) FILTER (WHERE NOT (ngay_tra_thuc_te > DATE '2026-09-01')) AS tong_hai_cai_tren,
count(*) AS so_dong_that_su
FROM muon_sach;
Đọc kỹ bốn con số: 38 + 0 = 38, nhưng bảng có 50 dòng. Mười hai dòng rơi ra khỏi cả điều kiện lẫn phủ định của chính nó.
Trong logic hai giá trị điều này là bất khả thi. Trong SQL nó là chuyện bình thường, vì 12 dòng đó cho UNKNOWN, và NOT UNKNOWN vẫn là UNKNOWN.
Cách sửa: nói rõ ý định của bạn với NULL.
-- KỲ VỌNG: 50 dòng
SELECT ma_muon, ngay_tra_thuc_te
FROM muon_sach
WHERE ngay_tra_thuc_te > DATE '2026-09-01'
OR ngay_tra_thuc_te IS NULL
ORDER BY ma_muon;
Đủ 50 dòng. Nhờ bảng chân lý của OR: khi vế trái là UNKNOWN mà vế phải là TRUE, cả biểu thức thành TRUE.
Bẫy <> 10 với điểm số¶
Đúng tình huống mà câu chuyện mở đầu mô tả, nhưng với bảng điểm. Ta dựng một bảng nháp có NULL thật:
DROP TABLE IF EXISTS b24_diem_nhap CASCADE;
CREATE TABLE b24_diem_nhap (
ma_hs CHAR(5) PRIMARY KEY,
diem_so NUMERIC(4,2) -- NULL nghĩa là CHƯA CHẤM
);
INSERT INTO b24_diem_nhap (ma_hs, diem_so) VALUES
('HS001', 10.00),
('HS002', 8.50),
('HS003', NULL), -- bài này cô chưa chấm
('HS004', 7.00);
-- KỲ VỌNG: 2 dòng
SELECT ma_hs, diem_so
FROM b24_diem_nhap
WHERE diem_so <> 10
ORDER BY ma_hs;
Câu hỏi đặt ra là "những bạn không được 10 điểm". Bạn HS003 chưa được chấm — rõ ràng bạn ấy chưa có điểm 10. Nhưng câu lệnh chỉ trả về 2 dòng, và HS003 biến mất.
Vì sao? NULL <> 10 cho UNKNOWN, không phải TRUE. Và WHERE loại mọi dòng UNKNOWN.
Viết lại cho đúng ý định:
-- KỲ VỌNG: 3 dòng
SELECT ma_hs, diem_so
FROM b24_diem_nhap
WHERE diem_so IS DISTINCT FROM 10
ORDER BY ma_hs;
IS DISTINCT FROM là phiên bản "biết điều" của <>: nó coi NULL như một giá trị bình thường và luôn trả về TRUE hoặc FALSE, không bao giờ UNKNOWN. Cách viết dài dòng hơn nhưng tương đương là WHERE diem_so <> 10 OR diem_so IS NULL.
Và đây là bộ so sánh đầy đủ về NULL, đáng học thuộc:
-- KỲ VỌNG: null_bang_null = NULL
-- KỲ VỌNG: null_khac_null = NULL
-- KỲ VỌNG: dung_is_null = true
-- KỲ VỌNG: is_distinct_from = false
SELECT (NULL = NULL) AS null_bang_null,
(NULL <> NULL) AS null_khac_null,
(NULL IS NULL) AS dung_is_null,
(NULL IS DISTINCT FROM NULL) AS is_distinct_from;
Ba bảng chân lý ở phần lý thuyết, kiểm chứng bằng chính PostgreSQL:
-- KỲ VỌNG: true_or_unknown = true
-- KỲ VỌNG: false_and_unknown = false
-- KỲ VỌNG: true_and_unknown = NULL
-- KỲ VỌNG: not_unknown = NULL
SELECT (TRUE OR NULL) AS true_or_unknown,
(FALSE AND NULL) AS false_and_unknown,
(TRUE AND NULL) AS true_and_unknown,
(NOT NULL::boolean) AS not_unknown;
Dọn bảng nháp:
DROP TABLE IF EXISTS b24_diem_nhap CASCADE;
-- KỲ VỌNG: con_lai = 0
SELECT count(*) AS con_lai
FROM information_schema.tables
WHERE table_name = 'b24_diem_nhap';
ORDER BY, NULLS FIRST / NULLS LAST¶
Cô thủ thư muốn xem những cuốn chưa trả trước tiên. Mặc định của ASC là đẩy NULL xuống cuối, nên phải nói rõ:
-- KỲ VỌNG: 5 dòng
-- KỲ VỌNG: ngay_tra_thuc_te = NULL
SELECT ma_muon, ma_hs, ngay_tra_thuc_te
FROM muon_sach
ORDER BY ngay_tra_thuc_te NULLS FIRST, ma_muon
LIMIT 5;
Dòng đầu tiên có ngay_tra_thuc_te rỗng — đúng như yêu cầu. Còn DESC thì mặc định đã đặt NULL lên đầu rồi, vì PostgreSQL coi NULL là lớn nhất:
-- KỲ VỌNG: ngay_tra_thuc_te = NULL
SELECT ma_muon, ngay_tra_thuc_te
FROM muon_sach
ORDER BY ngay_tra_thuc_te DESC, ma_muon
LIMIT 1;
Muốn ngược lại — đẩy NULL xuống dưới cùng dù đang DESC — thì viết ORDER BY ngay_tra_thuc_te DESC NULLS LAST.
Sắp xếp theo nhiều cột: cột đầu quyết định trước, cột sau chỉ dùng để phân xử khi cột đầu bằng nhau.
-- KỲ VỌNG: 40 dòng
SELECT ma_lop, ma_hs, ngay_sinh
FROM hoc_sinh
ORDER BY ma_lop ASC, ngay_sinh DESC;
LIMIT và OFFSET — phân trang¶
-- KỲ VỌNG: 5 dòng
-- KỲ VỌNG: ma_hs = HS011
SELECT ma_hs, ho_ten
FROM hoc_sinh
ORDER BY ma_hs
LIMIT 5 OFFSET 10;
Đây là "trang 3" nếu mỗi trang 5 dòng: bỏ qua 10 dòng đầu (trang 1 và 2), lấy 5 dòng tiếp theo — bắt đầu từ HS011.
LIMIT mà không có ORDER BY là sai — kể cả khi nó có vẻ chạy đúng
Nếu không có ORDER BY, PostgreSQL không hề hứa hẹn thứ tự nào. Hôm nay nó trả về HS001…HS005; ngày mai, sau khi ai đó UPDATE một dòng hoặc sau khi bộ tối ưu chọn một đường đi khác, nó có thể trả về năm dòng hoàn toàn khác.
Với LIMIT, hậu quả không chỉ là "thứ tự hơi lạ" mà là kết quả sai: bạn nhận về đúng 5 dòng, nhưng không phải 5 dòng bạn muốn. Và nó không báo lỗi gì cả.
Quy tắc: có LIMIT thì phải có ORDER BY, và ORDER BY đó phải đủ chặt để không còn hai dòng nào ngang nhau — thường là thêm cột khoá chính vào cuối.
⚠️ Lỗi thường gặp¶
Lỗi 1: Viết = NULL thay vì IS NULL
Câu này không báo lỗi. Nó chạy ngon lành và trả về 0 dòng — luôn luôn, trên mọi bảng, mọi dữ liệu.
Vì bất_kỳ_gì = NULL cho UNKNOWN, và WHERE loại hết UNKNOWN. Nên nó là một câu lệnh vô nghĩa nhưng im lặng.
Cách viết đúng:
Lỗi 2: Tưởng WHERE đk và WHERE NOT đk chia đôi bảng
Đã chứng minh ở phần thực hành: 38 + 0 = 38, không phải 50.
Bất cứ khi nào cột trong vị từ có thể là NULL, hãy tự hỏi: "tôi muốn những dòng NULL đó rơi vào đâu?" rồi viết ra cho tường minh bằng OR ... IS NULL hoặc IS DISTINCT FROM.
Đây không phải chuyện bắt bẻ lý thuyết. Một báo cáo "số sách đã trả đúng hạn" cộng với "số sách trả muộn" mà không bằng tổng số lượt mượn là một báo cáo sai — và không ai phát hiện ra cho tới khi có người đi đối chiếu.
Lỗi 3: Quên rằng BETWEEN tính cả hai đầu mút
WHERE diem_so BETWEEN 5 AND 8 bao gồm cả đúng 5.00 và đúng 8.00.
Hậu quả hay gặp nhất là khi chia khoảng: viết BETWEEN 0 AND 5 cho loại "yếu" rồi BETWEEN 5 AND 8 cho loại "trung bình", thì học sinh được đúng 5.00 rơi vào cả hai nhóm — và tổng số học sinh trong báo cáo lớn hơn sĩ số thật.
Với ngày giờ thì bẫy còn kín hơn: ngay_nhap BETWEEN '2026-01-01' AND '2026-01-31' trên một cột TIMESTAMP sẽ bỏ sót mọi bản ghi của ngày 31 sau 0 giờ 0 phút, vì '2026-01-31' được hiểu là 2026-01-31 00:00:00. Cách an toàn: >= '2026-01-01' AND < '2026-02-01'.
Lỗi 4: Dùng LIKE khi cần không phân biệt hoa thường
WHERE ho_ten LIKE '%thị%' trả về 0 dòng, trong khi dữ liệu có 20 dòng chứa Thị.
Dùng ILIKE (PostgreSQL) hoặc lower(ho_ten) LIKE lower('%thị%') (chạy ở mọi DBMS).
Lưu ý về tốc độ: ILIKE và LIKE '%...%' — mẫu bắt đầu bằng % — không dùng được index thông thường. Trên bảng nhỏ thì không sao; trên bảng 500.000 dòng ở Cấp 4, đó là lý do một trang tìm kiếm chạy mất mấy giây.
Lỗi 5: ORDER BY theo số thứ tự cột
SQL cho phép ORDER BY 2 nghĩa là "sắp theo cột thứ hai trong danh sách SELECT". Nó gọn, nhưng rất dễ hỏng: chỉ cần ai đó chèn thêm một cột vào giữa SELECT, câu lệnh lặng lẽ sắp theo cột khác.
Hãy viết tên cột hoặc tên bí danh: ORDER BY ten_hoc_sinh.
✍️ Bài tập¶
-
Viết truy vấn liệt kê mã và họ tên các học sinh nữ thuộc khối 9, sắp theo ngày sinh từ lớn tuổi nhất tới nhỏ tuổi nhất. (Gợi ý: cần ghép với bảng
lopđể biết khối — hoặc lọc theo danh sách mã lớp khối 9.) -
Ba câu lệnh sau cho kết quả khác nhau trên bảng
muon_sach. Dự đoán số dòng của từng câu và giải thích:a.
WHERE ngay_tra_thuc_te IS NOT NULLb.
WHERE NOT (ngay_tra_thuc_te IS NULL)c.
WHERE ngay_tra_thuc_te <> ngay_tra_du_kien -
Cô thủ thư muốn một danh sách: "mọi lượt mượn mà sách được trả không đúng vào ngày hẹn — kể cả những cuốn chưa trả." Viết truy vấn đúng ý cô, và giải thích vì sao câu
WHERE ngay_tra_thuc_te <> ngay_tra_du_kienlà sai. -
Viết truy vấn lấy trang thứ 4 của danh sách giáo viên, mỗi trang 2 người, sắp theo mã giáo viên. Cho biết
LIMITvàOFFSETphải là bao nhiêu, và vì sao. -
Giải thích vì sao câu dưới đây luôn trả về 0 dòng, dù bảng
hoc_sinhcó 40 dòng và có bạn tên đệm làThị:
Đáp án
Câu 1.
-- KỲ VỌNG: 10 dòng
SELECT h.ma_hs, h.ho_ten, h.ngay_sinh
FROM hoc_sinh h
JOIN lop l ON l.ma_lop = h.ma_lop
WHERE h.gioi_tinh = 'Nữ'
AND l.khoi = 9
ORDER BY h.ngay_sinh ASC, h.ma_hs;
Khối 9 gồm L04, L05, L06 với tổng 20 học sinh, trong đó 3 + 3 + 4 = 10 bạn nữ.
Chú ý ORDER BY h.ngay_sinh ASC: "lớn tuổi nhất trước" nghĩa là ngày sinh nhỏ nhất trước, tức là tăng dần. Rất dễ viết nhầm thành DESC. Thêm h.ma_hs ở cuối để hai bạn cùng ngày sinh cũng có thứ tự ổn định.
Câu 2.
a. 38 dòng. IS NOT NULL trả về TRUE/FALSE thật, không bao giờ UNKNOWN, nên nó lọc đúng 38 lượt đã trả.
b. 38 dòng. NOT (x IS NULL) tương đương hoàn toàn với x IS NOT NULL. Khác với NOT (x = y), ở đây NOT áp lên một biểu thức không bao giờ cho UNKNOWN, nên nó an toàn.
c. 38 dòng — nhưng vì một lý do khác, và đây là chỗ đáng suy nghĩ. 12 lượt chưa trả cho UNKNOWN nên bị loại. Trong 38 lượt còn lại, dữ liệu mẫu không có lượt nào trả đúng vào ngày hẹn, nên cả 38 đều thoả <>.
Ba câu cho cùng một con số, nhưng câu (c) dễ vỡ: chỉ cần có một lượt trả đúng hạn là nó khác ngay, trong khi (a) và (b) thì không. Kiểm chứng:
-- KỲ VỌNG: a_is_not_null = 38
-- KỲ VỌNG: b_not_is_null = 38
-- KỲ VỌNG: c_khac_ngay_hen = 38
SELECT count(*) FILTER (WHERE ngay_tra_thuc_te IS NOT NULL) AS a_is_not_null,
count(*) FILTER (WHERE NOT (ngay_tra_thuc_te IS NULL)) AS b_not_is_null,
count(*) FILTER (WHERE ngay_tra_thuc_te <> ngay_tra_du_kien) AS c_khac_ngay_hen
FROM muon_sach;
Câu 3.
-- KỲ VỌNG: 50 dòng
SELECT ma_muon, ma_hs, ngay_tra_du_kien, ngay_tra_thuc_te
FROM muon_sach
WHERE ngay_tra_thuc_te IS DISTINCT FROM ngay_tra_du_kien
ORDER BY ma_muon;
Câu WHERE ngay_tra_thuc_te <> ngay_tra_du_kien sai vì nó bỏ sót 12 lượt chưa trả. Với những lượt đó, ngay_tra_thuc_te là NULL, phép <> cho UNKNOWN, và WHERE loại chúng đi.
Nhưng về mặt nghiệp vụ, một cuốn sách chưa được trả thì chắc chắn là "không trả đúng vào ngày hẹn" — cô thủ thư muốn thấy chúng, và chúng còn quan trọng hơn cả 38 lượt kia.
IS DISTINCT FROM xử lý đúng: nó coi NULL là một giá trị và trả lời dứt khoát "khác nhau". Cách viết tương đương: WHERE ngay_tra_thuc_te <> ngay_tra_du_kien OR ngay_tra_thuc_te IS NULL.
Câu 4.
-- KỲ VỌNG: 2 dòng
-- KỲ VỌNG: ma_gv = GV07
SELECT ma_gv, ho_ten
FROM giao_vien
ORDER BY ma_gv
LIMIT 2 OFFSET 6;
Công thức chung: với kích thước trang n và số trang p đếm từ 1 thì LIMIT n OFFSET (p - 1) * n.
Ở đây n = 2, p = 4 → OFFSET = 3 × 2 = 6. Bỏ qua 6 người của ba trang đầu, lấy GV07 và GV08.
Câu 5.
Vì LIKE 'Thị' không có ký tự đại diện nào. Không có %, không có _. Nên nó đòi giá trị của cột phải bằng đúng chuỗi Thị — cả họ tên chỉ vỏn vẹn ba chữ cái.
Không bạn nào tên là Thị, nên kết quả rỗng. Khi không có ký tự đại diện, LIKE hành xử y hệt dấu =.
Muốn tìm tên chứa Thị thì phải bọc hai dấu %: WHERE ho_ten LIKE '%Thị%' — và khi đó ra 20 dòng.
🔑 Tóm tắt¶
- Khung một câu truy vấn:
SELECT(phép chiếu π) →FROM→WHERE(phép chọn σ) →ORDER BY→LIMIT/OFFSET. Bí danh đặt bằngAS, chính là phép ρ của đại số quan hệ;DISTINCTlà thứ biếnSELECTthành đúng phép chiếu của toán học. - SQL dùng logic ba giá trị:
TRUE,FALSEvàUNKNOWN. Mọi phép so sánh vớiNULL— kể cảNULL = NULL— đều choUNKNOWN, vàWHEREloại bỏ mọi dòng choUNKNOWN. - Hệ quả cụ thể phải thuộc lòng:
NOT UNKNOWNvẫn làUNKNOWN, nênWHERE đkvàWHERE NOT đkkhông chia đôi bảng. Trênmuon_sach,38 + 0 = 38chứ không phải 50 — 12 dòngNULLrơi khỏi cả hai phía. - Ba công cụ để xử lý
NULLcho đúng:IS NULL/IS NOT NULL(không bao giờ choUNKNOWN),IS DISTINCT FROM(phiên bản "biết điều" của<>), và viết tường minhOR cot IS NULL. Tuyệt đối không dùng= NULL— nó luôn trả về 0 dòng mà không báo lỗi. Ngoại lệ phải nhớ:DISTINCT,GROUP BYvàUNION/INTERSECT/EXCEPTcoi mọiNULLlà BẰNG NHAU và gộp chúng lại — khi SQL đi so sánh thìNULLlà "chưa biết", khi SQL đi gom nhóm thìNULLlà một giá trị như mọi giá trị khác. ORDER BYmặc định coiNULLlà lớn nhất (ASCđẩy xuống cuối,DESCđưa lên đầu); dùngNULLS FIRST/NULLS LASTđể tự quyết định. Và cóLIMITthì bắt buộc phải cóORDER BYđủ chặt, nếu không bạn sẽ nhận đúng số dòng nhưng sai nội dung.
⬅️ Bài 23 — DML: INSERT, UPDATE, DELETE · ➡️ Bài 25 — JOIN: sáu cách ghép bảng