Giao diện
SELECT / WHERE / ORDER BY Thực Chiến 🔍
Hình dung bạn là backend developer tại một sàn e-commerce kiểu Shopee. Sáng thứ Hai, PM chạy sang hỏi:
"Cho tôi danh sách sản phẩm đang giảm giá trên 30%, sắp xếp theo giá từ thấp đến cao, chỉ lấy 20 sản phẩm đầu."
Một câu — bốn khái niệm: SELECT, WHERE, ORDER BY, LIMIT. Đây là bread and butter của mọi backend developer, những câu lệnh bạn viết hàng trăm lần mỗi tuần.
Bài này không chỉ dạy cú pháp — bạn sẽ hiểu khi nào nên dùng gì, cái gì giết performance, và những cái bẫy mà developer 3 năm kinh nghiệm vẫn dính.
SELECT — Chọn đúng thứ cần lấy
⚠️ Quy tắc vàng: KHÔNG BAO GIỜ dùng SELECT * trong production
SELECT * là thói quen xấu nhất khi mới học SQL. Xem phần Anti-pattern bên dưới.
Explicit Columns & Aliasing
sql
-- ✅ GOOD: Chỉ lấy những gì cần
SELECT id, name, price, stock FROM products;
-- ❌ BAD: Lấy tất cả — kể cả cột description 10KB
SELECT * FROM products;
-- Alias: Contract giữa backend và frontend
SELECT
p.product_name AS name,
p.unit_price AS price,
p.qty_in_stock AS stock
FROM products p;💡 HPN Pro Tip
Alias là contract giữa backend và frontend. Frontend dùng response.price mà không cần biết tên cột thật. DBA đổi tên cột? Bạn sửa query, frontend không bị ảnh hưởng.
Computed Columns & DISTINCT
sql
-- Tính toán ngay trong DB — nhanh hơn tính ở application layer
SELECT
name,
price * quantity AS total_value,
price * quantity * 1.1 AS total_with_tax
FROM order_items;
-- Loại bỏ trùng lặp
SELECT DISTINCT city FROM users WHERE country = 'VN';⚠️ DISTINCT ≠ Giải pháp
Nếu bạn phải dùng DISTINCT để kết quả không trùng, thường là query có vấn đề — JOIN sai hoặc thiếu điều kiện. Fix root cause, đừng dùng DISTINCT như band-aid.
💼 Ví dụ: Product Catalog với giá sau giảm
sql
SELECT
p.id, p.name,
p.price AS original_price,
ROUND(p.price * (1 - p.discount_pct / 100.0), 0) AS sale_price,
c.name AS category
FROM products p
JOIN categories c ON p.category_id = c.id
WHERE p.is_active = true;| id | name | original_price | sale_price | category |
|---|---|---|---|---|
| 1 | Áo Thun Basic | 299000 | 239200 | Thời trang |
| 2 | Tai nghe Bluetooth | 890000 | 578500 | Điện tử |
WHERE — Bộ lọc quyền lực
Comparison, BETWEEN, IN
sql
SELECT * FROM products WHERE price > 500000;
SELECT * FROM products WHERE stock <= 10; -- Sắp hết hàng!
SELECT name, price FROM products
WHERE price BETWEEN 100000 AND 500000; -- BETWEEN: inclusive cả hai đầu
SELECT * FROM orders
WHERE status IN ('pending', 'processing', 'shipped'); -- Gọn hơn 3x OR💡 HPN Pro Tip — BETWEEN với TIMESTAMP
BETWEEN '...' AND '2024-01-31' bỏ sót rows lúc 2024-01-31 14:30:00. An toàn hơn:
sql
WHERE created_at >= '2024-01-01' AND created_at < '2024-02-01'IS NULL / IS NOT NULL
sql
SELECT * FROM users WHERE verified_at IS NULL; -- ✅ Chưa verify
SELECT * FROM users WHERE verified_at IS NOT NULL; -- ✅ Đã verify🚨 BẪY CHẾT NGƯỜI: = NULL KHÔNG HOẠT ĐỘNG
sql
SELECT * FROM users WHERE email = NULL; -- ❌ Luôn 0 dòng!
SELECT * FROM users WHERE email != NULL; -- ❌ Cũng 0 dòng!NULL không phải giá trị — nó là "không biết". So sánh với NULL đều ra NULL, không phải TRUE. Chi tiết: phần Gotcha: NULL.
AND, OR, NOT — Cẩn thận Operator Precedence
⚠️ AND ưu tiên cao hơn OR — Bug kinh điển
sql
-- ❌ BUG: Lấy TẤT CẢ sản phẩm category 5, bất kể is_active!
SELECT * FROM products
WHERE is_active = true AND category_id = 3 OR category_id = 5;
-- DB đọc: (is_active AND cat=3) OR (cat=5) ← cat=5 không cần active!
-- ✅ FIX: Dùng ngoặc hoặc IN
SELECT * FROM products
WHERE is_active = true AND category_id IN (3, 5);💼 Ví dụ: Active Users trong 30 ngày
sql
SELECT id, full_name, email, city, created_at
FROM users
WHERE is_active = true
AND verified_at IS NOT NULL
AND created_at >= CURRENT_DATE - INTERVAL '30 days'
AND city IN ('Hồ Chí Minh', 'Hà Nội')
ORDER BY created_at DESC;⚠️ CẢNH BÁO: LIKE '%keyword%' — Kẻ giết Index
Đây là phần quan trọng nhất bài này. Nhiều developer dùng LIKE '%keyword%' vô tư mà không biết nó đang giết chết performance.
Cú pháp LIKE
sql
WHERE name LIKE 'Áo%' -- Bắt đầu bằng "Áo" (% = bất kỳ chuỗi nào)
WHERE name LIKE '%Bluetooth' -- Kết thúc bằng "Bluetooth"
WHERE name LIKE '%tai nghe%' -- Chứa "tai nghe" (_ = đúng 1 ký tự)Prefix vs. Infix — Khác biệt sống còn
B-Tree Index trên cột "name":
[M]
/ \
[D] [T]
/ \ / \
[An] [Ha] [Na] [Vi]
✅ LIKE 'Na%' → Nhảy thẳng tới [Na] → O(log n)
❌ LIKE '%na%' → Duyệt TẤT CẢ leaf → O(n) Full Table Scan!📊 Benchmark: 10 triệu dòng
| Query | Index? | Thời gian |
|---|---|---|
WHERE name LIKE 'Áo%' | ✅ Index Scan | ~2-5 ms |
WHERE name LIKE '%Áo%' | ❌ Full Table Scan | ~3-5 giây |
WHERE name LIKE '%Áo' | ❌ Full Table Scan | ~3-5 giây |
Full-Text Search (to_tsvector) | ✅ GIN Index | ~5-20 ms |
🚨 Chênh lệch: ~1000 lần!
Với traffic 1000 req/s, đây là khác biệt giữa "hệ thống chạy mượt" và "database cháy CPU, toàn bộ sàn TMĐT sập".
🛠 Giải pháp thay thế
1. Full-Text Search (PostgreSQL)
sql
ALTER TABLE products ADD COLUMN name_search tsvector
GENERATED ALWAYS AS (to_tsvector('simple', name)) STORED;
CREATE INDEX idx_products_fts ON products USING GIN (name_search);
-- Query: nhanh gấp 1000 lần LIKE '%...%'
SELECT * FROM products WHERE name_search @@ to_tsquery('simple', 'tai & nghe');2. Trigram Index (pg_trgm) — cho phép LIKE '%...%' dùng index
sql
CREATE EXTENSION IF NOT EXISTS pg_trgm;
CREATE INDEX idx_name_trgm ON products USING GIN (name gin_trgm_ops);
-- LIKE '%bluetooth%' dùng GIN index: ~3-5s → ~20-50ms3. Elasticsearch / Meilisearch — search phức tạp (fuzzy, typo-tolerance)
💡 HPN Pro Tip
Quy tắc ngón tay cái:
- < 100K dòng:
LIKE '%...%'OK, đừng over-engineer - 100K–1M: Trigram index (
pg_trgm) - > 1M: Full-Text Search hoặc search engine riêng
ORDER BY — Sắp xếp thông minh
Single & Multi-column
sql
SELECT name, price FROM products ORDER BY price; -- ASC mặc định
SELECT name, price FROM products ORDER BY price DESC; -- Giảm dần
-- Leaderboard: Điểm cao nhất trước, bằng điểm → ai đăng ký trước xếp trên
SELECT username, score, registered_at
FROM players
ORDER BY score DESC, registered_at ASC;| username | score | registered_at |
|---|---|---|
| alice | 9500 | 2024-01-15 |
| bob | 9500 | 2024-03-20 |
| charlie | 8700 | 2024-02-10 |
NULLS FIRST / NULLS LAST (PostgreSQL)
sql
-- Đưa sản phẩm chưa có giá lên đầu (để review)
SELECT name, price FROM products
ORDER BY price ASC NULLS FIRST;💡 HPN Pro Tip — MySQL workaround
sql
ORDER BY price IS NULL, price ASC;
-- IS NULL → 0 (false) cho non-null → xếp trước; 1 (true) cho NULL → xếp sauKết hợp tất cả — Real-world Query
Quay lại yêu cầu PM. Query hoàn chỉnh:
sql
SELECT
p.id,
p.name,
p.price,
p.price * (1 - p.discount_pct / 100.0) AS sale_price,
c.name AS category_name
FROM products p
JOIN categories c ON p.category_id = c.id
WHERE p.is_active = true
AND p.discount_pct > 30
AND p.stock > 0
AND p.name LIKE 'Áo%' -- ✅ Prefix match, dùng được Index
ORDER BY sale_price ASC
LIMIT 20;| Phần | Ghi chú |
|---|---|
SELECT p.id, p.name, ... | Explicit columns, không SELECT * |
price * (1 - discount_pct / 100.0) | Computed column — tính giá sale trong DB |
JOIN categories c | Lấy tên category trong 1 query |
WHERE ... LIKE 'Áo%' | ✅ Prefix match — dùng B-Tree index |
ORDER BY sale_price ASC | Giá thấp → cao (yêu cầu PM) |
LIMIT 20 | Pagination cơ bản |
🏋️ Bài tập nhanh
Đề bài: Viết query tìm đơn hàng trong 7 ngày gần nhất, status
'pending'hoặc'processing', sắp xếp theo tổng tiền giảm dần, lấy top 50.
sql
CREATE TABLE orders (
id SERIAL PRIMARY KEY,
user_id INT NOT NULL,
status VARCHAR(20) NOT NULL,
total DECIMAL(12, 2) NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT NOW()
);🔑 Xem đáp án
sql
SELECT id, user_id, status, total, created_at
FROM orders
WHERE created_at >= CURRENT_DATE - INTERVAL '7 days'
AND status IN ('pending', 'processing')
ORDER BY total DESC
LIMIT 50;Key points: Không wrap hàm trên cột indexed (DATE(created_at)) → giữ index. IN gọn hơn OR.
sql
CREATE INDEX idx_orders_status_created ON orders (status, created_at DESC);⚠️ Gotcha: NULL — Cái bẫy vô hình
NULL không phải 0, không phải "", không phải false. NULL = "không biết" (unknown).
sql
SELECT * FROM users WHERE email = NULL; -- ❌ Trả về 0 dòng!
SELECT * FROM users WHERE email IS NULL; -- ✅ Đúng!
-- NULL = NULL → NULL (không phải TRUE!)Three-Valued Logic
| A | B | A AND B | A OR B | NOT A |
|---|---|---|---|---|
| TRUE | NULL | NULL | TRUE | — |
| FALSE | NULL | FALSE | NULL | — |
| NULL | NULL | NULL | NULL | NULL |
⚠️ Hệ quả thực tế
sql
-- Query này BỎ SÓT user có phone = NULL!
SELECT * FROM users WHERE phone != '0901234567';
-- Fix:
SELECT * FROM users
WHERE phone != '0901234567' OR phone IS NULL;⚡ Ghi chú hiệu năng
sql
-- 1. SELECT * ngăn Index-Only Scan
SELECT * FROM products WHERE category_id = 5; -- ❌ Đọc cả bảng
SELECT id, name FROM products WHERE category_id = 5; -- ✅ Chỉ đọc index → 5-10x nhanh hơn
-- 2. Function trên indexed column = Giết index
WHERE YEAR(created_at) = 2024; -- ❌ Index vô dụng
WHERE created_at >= '2024-01-01' AND created_at < '2025-01-01'; -- ✅ Index hoạt động💡 HPN Pro Tip
Giữ cột indexed "sạch" bên trái phép so sánh. Đừng wrap trong hàm — biến đổi giá trị so sánh thay vì biến đổi cột.
| Kịch bản | Thời gian (10M rows) |
|---|---|
LIKE '%keyword%' (no index) | ~3-5 giây |
Full-Text Search (to_tsvector) | ~5-20 ms |
Trigram Index (pg_trgm) | ~20-50 ms |
| Elasticsearch | ~2-10 ms |
🚫 Anti-pattern thực tế: SELECT * trong API
Bối cảnh: API endpoint /api/products, code dùng SELECT *. Chạy tốt 6 tháng.
python
# ❌ Ban đầu — response ~50ms
def get_products():
cursor.execute("SELECT * FROM products WHERE is_active = true")
return jsonify(cursor.fetchall())Tháng 7: DBA thêm cột product_image BYTEA (~500KB/ảnh).
Trước: SELECT * → 20 cột × 50 rows = ~25KB → 50ms
Sau: SELECT * → 21 cột × 50 rows = ~25MB (!) → 2000ms 🔥API từ 50ms lên 2 giây. Mobile timeout. Bạn không đổi dòng code nào.
python
# ✅ Fix: Explicit columns — defensive programming
def get_products():
cursor.execute("""
SELECT id, name, price, discount_pct, stock, category_id
FROM products WHERE is_active = true
""")
return jsonify(cursor.fetchall())🚨 Bài học
SELECT * = quả bom hẹn giờ. Schema thay đổi ngoài tầm kiểm soát. Luôn liệt kê cột cụ thể.
🎮 Playground
Chạy trên PostgreSQL hoặc DB Fiddle:
sql
-- SETUP
DROP TABLE IF EXISTS demo_products CASCADE;
DROP TABLE IF EXISTS demo_categories CASCADE;
CREATE TABLE demo_categories (id SERIAL PRIMARY KEY, name VARCHAR(50) NOT NULL);
CREATE TABLE demo_products (
id SERIAL PRIMARY KEY, name VARCHAR(100) NOT NULL,
price DECIMAL(12,2) NOT NULL, discount_pct INT DEFAULT 0,
stock INT DEFAULT 0, is_active BOOLEAN DEFAULT true,
category_id INT REFERENCES demo_categories(id)
);
INSERT INTO demo_categories (name) VALUES ('Thời trang'),('Điện tử'),('Gia dụng'),('Sách');
INSERT INTO demo_products (name, price, discount_pct, stock, is_active, category_id) VALUES
('Áo Thun Basic', 299000, 20, 150, true, 1),
('Áo Khoác Denim', 890000, 35, 42, true, 1),
('Tai nghe BT Pro', 1290000, 40, 30, true, 2),
('Chuột gaming', 650000, 50, 0, true, 2),
('Nồi chiên không dầu', 2100000, 25, 60, true, 3),
('Máy xay sinh tố', 780000, 10, 0, false, 3),
('Sách Clean Code', 350000, 5, 500, true, 4),
('Sách System Design', 420000, NULL, 120, true, 4);
-- 1️⃣ Computed column + COALESCE
SELECT name, price,
ROUND(price * (1 - COALESCE(discount_pct,0) / 100.0), 0) AS sale_price
FROM demo_products WHERE is_active = true;
-- 2️⃣ WHERE đa điều kiện
SELECT name, price, stock FROM demo_products
WHERE is_active = true AND stock > 0 AND price BETWEEN 200000 AND 1000000;
-- 3️⃣ LIKE: prefix ✅ vs infix ❌
SELECT name FROM demo_products WHERE name LIKE 'Áo%';
SELECT name FROM demo_products WHERE name LIKE '%Pro%';
-- 4️⃣ ORDER BY + NULL trap (đoán kết quả trước khi chạy!)
SELECT name, discount_pct FROM demo_products WHERE discount_pct != 0;
-- "Sách System Design" (NULL) có xuất hiện? → KHÔNG!
SELECT name, discount_pct FROM demo_products
WHERE discount_pct != 0 OR discount_pct IS NULL; -- Fix!
-- 5️⃣ Real-world combo
SELECT p.id, p.name, p.price,
ROUND(p.price * (1 - COALESCE(p.discount_pct,0) / 100.0), 0) AS sale_price,
c.name AS category
FROM demo_products p
JOIN demo_categories c ON p.category_id = c.id
WHERE p.is_active = true AND COALESCE(p.discount_pct,0) > 30 AND p.stock > 0
ORDER BY sale_price ASC LIMIT 20;🧠 Quiz — 📝 Kiểm tra nhanh
Câu 1: Query nào CÓ THỂ dùng B-Tree index trên cột name?
A. WHERE name LIKE '%iphone%' · B. WHERE name LIKE 'iphone%' · C. WHERE name LIKE '%iphone' · D. WHERE LOWER(name) LIKE 'iphone%'
Đáp án
B — Chỉ prefix match dùng được B-Tree. A & C có % đầu → Full Scan. D wrap hàm → index vô hiệu.
Câu 2: SELECT NULL = NULL trả về gì?
A. TRUE · B. FALSE · C. NULL · D. Error
Đáp án
C — NULL = NULL → NULL. Three-valued logic: NULL không bằng bất cứ gì, kể cả chính nó.
Câu 3: Tại sao SELECT * nguy hiểm trong production API?
A. DB parse chậm hơn · B. ORM không tương thích · C. Schema thay đổi gây hỏng API · D. PostgreSQL 16+ không hỗ trợ
Đáp án
C — DBA thêm cột BLOB → response size tăng 1000x → API timeout. Explicit columns = defensive programming.
🧩 Parsons Problem — 🧩 Sắp xếp Query
Sắp xếp thành query hợp lệ: top 10 sản phẩm giảm giá nhiều nhất, còn hàng, category "Điện tử":
LIMIT 10
WHERE p.is_active = true
ORDER BY p.discount_pct DESC
FROM products p
AND c.name = 'Điện tử'
JOIN categories c ON p.category_id = c.id
SELECT p.name, p.price, p.discount_pct
AND p.stock > 0Đáp án
sql
SELECT p.name, p.price, p.discount_pct
FROM products p
JOIN categories c ON p.category_id = c.id
WHERE p.is_active = true
AND p.stock > 0
AND c.name = 'Điện tử'
ORDER BY p.discount_pct DESC
LIMIT 10Thứ tự: SELECT → FROM → JOIN → WHERE → ORDER BY → LIMIT
📍 Điều hướng
🗺️ Bạn đang ở đây
Phase 1 · Bài 2/N · SELECT / WHERE / ORDER BY Thực Chiến
| Hướng | Trang | Mô tả |
|---|---|---|
| ⬅️ Trước | Bài 1: SQL Tư Duy Nền Tảng | Mindset & kiến trúc SQL |
| ➡️ Tiếp | Bài 3: GROUP BY & Aggregation | Gom nhóm & hàm tổng hợp |
| 🏋️ Luyện tập | Practice: Basic Queries | Bài tập tương tác |