Skip to content

SQL Tư Duy Nền Tảng 🧠

Mình từng review code của một bạn junior — bạn ấy viết một vòng for trong Python để duyệt qua toàn bộ bảng users (hơn 2 triệu dòng), lọc ra những ai ở Hà Nội, rồi đếm xem có bao nhiêu người. Kết quả? Script chạy mất 47 giây. Cùng logic đó, một câu SELECT COUNT(*) FROM users WHERE city = 'Hanoi' chạy trong 12 mili-giây. Nhanh hơn gần 4000 lần.

Vấn đề không phải bạn ấy kém — mà là bạn ấy đang nghĩ bằng ngôn ngữ sai. Khi viết Python hay Java, bạn ra lệnh từng bước: "lấy dòng này, kiểm tra điều kiện kia, tăng biến đếm". Nhưng SQL không hoạt động như vậy. SQL là ngôn ngữ declarative — bạn chỉ nói CÁI GÌ bạn muốn, không cần nói LÀM THẾ NÀO.

Hãy nghĩ thế này: khi vào quán phở, bạn nói với anh phục vụ "Cho tôi một phở bò tái chín, ít bánh, nhiều hành". Bạn không chạy vào bếp bảo đầu bếp: "Đun nước lên 100 độ, cho xương bò vào ninh 8 tiếng, thái thịt dày 2mm...". Bạn khai báo kết quả mong muốn, còn bếp (database engine) tự tìm cách tối ưu nhất để phục vụ. Đó chính là tư duy SQL.

Declarative vs Imperative — Hai thế giới 🌍

Trước khi đi sâu, hãy nhìn sự khác biệt rõ ràng nhất giữa hai cách tư duy:

Imperative (Python) — Bạn ra lệnh từng bước

python
# Tìm tổng doanh thu theo từng thành phố, chỉ lấy thành phố > 1 tỷ
result = {}
for order in all_orders:
    if order.status == 'completed':
        city = order.city
        if city not in result:
            result[city] = 0
        result[city] += order.amount

filtered = {}
for city, total in result.items():
    if total > 1_000_000_000:
        filtered[city] = total

sorted_result = sorted(filtered.items(), key=lambda x: x[1], reverse=True)

Declarative (SQL) — Bạn mô tả kết quả

sql
SELECT city, SUM(amount) AS total_revenue
FROM orders
WHERE status = 'completed'
GROUP BY city
HAVING SUM(amount) > 1000000000
ORDER BY total_revenue DESC;

💡 HPN Pro Tip

Database optimizer giống như Google Maps vậy — bạn chỉ cần nhập điểm đến, nó tự tìm đường đi nhanh nhất. Bạn không cần (và không nên) bảo nó rẽ trái ở ngã tư nào. Khi bạn viết SQL, optimizer sẽ quyết định: dùng index nào, đọc bảng theo thứ tự nào, có nên song song hóa không. Việc của bạn là mô tả chính xác cái bạn cần.

Sự khác biệt cốt lõi:

Imperative (Python/Java)Declarative (SQL)
Bạn nóiTừng bước phải làm gìKết quả bạn muốn
Ai tối ưuBạn tự loDatabase engine lo
Thay đổi dataSửa codeKhông cần (SQL tự adapt)
Xử lý song songBạn tự implementDatabase tự quyết

Thứ tự thực thi của SQL — Bản đồ quan trọng nhất 🗺️

Đây là kiến thức mà 90% developer mới hiểu sai, và nó gây ra vô số lỗi khó debug.

Bạn viết SQL theo thứ tự này:

SELECT → FROM → WHERE → GROUP BY → HAVING → ORDER BY → LIMIT

Nhưng database chạy theo thứ tự hoàn toàn khác:

📝 Thứ tự bạn VIẾT:          📝 Thứ tự database CHẠY:

1. SELECT                     1. FROM        ← Lấy bảng nào?
2. FROM                       2. WHERE       ← Lọc dòng nào?
3. WHERE                      3. GROUP BY    ← Gom nhóm nào?
4. GROUP BY                   4. HAVING      ← Lọc nhóm nào?
5. HAVING                     5. SELECT      ← Chọn cột nào?
6. ORDER BY                   6. ORDER BY    ← Sắp xếp thế nào?
7. LIMIT                      7. LIMIT       ← Lấy bao nhiêu?

Hãy hình dung như một dây chuyền sản xuất trong nhà máy:

┌─────────┐    ┌─────────┐    ┌──────────┐    ┌─────────┐
│  FROM   │───▶│  WHERE  │───▶│ GROUP BY │───▶│ HAVING  │
│ Lấy NVL │    │ Lọc phế │    │ Phân loại│    │ KCS nhóm│
└─────────┘    └─────────┘    └──────────┘    └─────────┘


               ┌─────────┐    ┌──────────┐    ┌─────────┐
               │  LIMIT  │◀───│ ORDER BY │◀───│ SELECT  │
               │ Đóng gói│    │ Sắp xếp  │    │ Chọn SP │
               └─────────┘    └──────────┘    └─────────┘

⚠️ Ghi nhớ quan trọng

SELECT không phải bước đầu tiên! Đây là sai lầm #1 của người mới. Database phải biết lấy dữ liệu từ đâu (FROM) và lọc gì (WHERE) trước khi biết bạn muốn hiển thị gì (SELECT). Giống như bạn phải vào siêu thị (FROM), đi đến kệ rau (WHERE) rồi mới chọn được rau muống hay rau cải (SELECT).

Ví dụ minh họa — đi qua từng bước

Cho bảng orders:

iduser_idcityamountstatus
1101HN500000completed
2102HCM800000completed
3103HN300000cancelled
4104HN1200000completed
5105HCM900000completed
6106DN400000completed

Câu query:

sql
SELECT city, SUM(amount) AS total
FROM orders
WHERE status = 'completed'
GROUP BY city
HAVING SUM(amount) > 1000000
ORDER BY total DESC;

Từng bước database thực thi:

BướcThao tácKết quả
① FROMLấy bảng orders6 dòng
② WHERELọc status = 'completed'5 dòng (bỏ id=3)
③ GROUP BYGom theo city3 nhóm: HN, HCM, DN
④ HAVINGLọc nhóm có SUM > 1M2 nhóm: HN (1.7M), HCM (1.7M)
⑤ SELECTLấy citySUM(amount)2 dòng với 2 cột
⑥ ORDER BYSắp theo total DESCHCM trước (hoặc HN, tùy tie)

⚠️ Gotcha: SELECT alias không dùng được trong WHERE

Đây là lỗi mà ai cũng mắc ít nhất một lần:

sql
-- ❌ LỖI: column "total_spent" does not exist
SELECT user_id, SUM(amount) AS total_spent
FROM orders
GROUP BY user_id
WHERE total_spent > 1000000;

Tại sao lỗi? Nhìn lại thứ tự thực thi:

  1. FROM orders ← OK
  2. WHERE total_spent > 1000000 ← ❌ Alias total_spent chưa tồn tại! SELECT chưa chạy!
  3. GROUP BY user_id
  4. SELECT ... AS total_spent ← Alias được tạo ở đây, quá muộn

Cách fix: Dùng HAVING (vì HAVING chạy sau GROUP BY nhưng trước ORDER BY):

sql
-- ✅ ĐÚNG: Dùng HAVING cho điều kiện trên aggregate
SELECT user_id, SUM(amount) AS total_spent
FROM orders
GROUP BY user_id
HAVING SUM(amount) > 1000000;

💡 HPN Pro Tip

Quy tắc vàng: WHERE lọc dòng (trước GROUP BY), HAVING lọc nhóm (sau GROUP BY). Nếu điều kiện liên quan đến hàm aggregate (SUM, COUNT, AVG...), luôn dùng HAVING.

Tư duy tập hợp (Set-Based Thinking) 🧩

Đây là bước nhảy tư duy khó nhất khi chuyển từ lập trình thủ tục sang SQL.

Trong Python/Java, bạn quen nghĩ: "Duyệt từng phần tử, kiểm tra, xử lý". Trong SQL, bạn phải nghĩ: "Tôi có một TẬP HỢP dữ liệu, tôi muốn BIẾN ĐỔI nó thành tập hợp khác".

Ví dụ tư duy

Cách nghĩ sai (imperative): "Duyệt qua từng đơn hàng, nếu amount > 1 triệu thì thêm vào danh sách kết quả"

Cách nghĩ đúng (set-based): "Cho tôi TẬP HỢP các đơn hàng MÀ amount > 1 triệu"

sql
-- Set-based: lọc một tập hợp thành tập con
SELECT * FROM orders WHERE amount > 1000000;

Hình dung như thế này:

┌─────────────────────────────────┐
│         orders (toàn bộ)        │
│  ┌───┐ ┌───┐ ┌───┐ ┌───┐      │
│  │500│ │800│ │1.2│ │300│ ...   │
│  │ K │ │ K │ │ M │ │ K │      │
│  └───┘ └───┘ └─┬─┘ └───┘      │
│                 │               │
└─────────────────┼───────────────┘
                  │ WHERE amount > 1M

        ┌─────────────────┐
        │  kết quả (tập   │
        │   con nhỏ hơn)  │
        │  ┌───┐ ┌───┐   │
        │  │1.2│ │2.5│   │
        │  │ M │ │ M │   │
        │  └───┘ └───┘   │
        └─────────────────┘

Ba phép biến đổi cơ bản trên tập hợp

Phép biến đổiSQLÝ nghĩa
Lọc (Filter)WHERE / HAVINGGiữ lại dòng thỏa điều kiện
Chiếu (Project)SELECT col1, col2Chỉ lấy một số cột
Kết hợp (Combine)JOINGhép hai tập hợp lại

Mỗi phép biến đổi nhận vào một (hoặc nhiều) bảng → trả ra một bảng mới. Đây chính là nền tảng để hiểu mọi thứ trong SQL.

Mental Model: Bảng là tất cả 📐

Trong SQL, mọi thứ đều là bảng — hoặc có thể coi như bảng:

sql
-- 1. SELECT trả về một bảng
SELECT name, email FROM users;
-- → Kết quả là một bảng (2 cột, N dòng)

-- 2. Subquery LÀ một bảng
SELECT * FROM (
    SELECT city, COUNT(*) AS user_count
    FROM users
    GROUP BY city
) AS city_stats
WHERE user_count > 100;

-- 3. CTE đặt tên cho một bảng
WITH active_users AS (
    SELECT * FROM users WHERE last_login > '2024-01-01'
)
SELECT city, COUNT(*) FROM active_users GROUP BY city;

💡 HPN Pro Tip

Khi bạn gặp một câu query phức tạp, hãy đọc từ trong ra ngoài (hoặc từ trên xuống dưới với CTE). Mỗi "tầng" tạo ra một bảng tạm, tầng tiếp theo làm việc trên bảng đó. Giống như xếp LEGO vậy — mỗi khối là một bảng, bạn xếp chúng lên nhau.

Khi bạn nắm được mental model này, nhiều thứ trở nên dễ hiểu hơn:

  • JOIN = ghép hai bảng thành một bảng rộng hơn
  • WHERE = lọc bảng thành bảng ít dòng hơn
  • SELECT = lọc bảng thành bảng ít cột hơn
  • GROUP BY = nén bảng thành bảng ít dòng hơn (mỗi nhóm = 1 dòng)
  • UNION = xếp hai bảng chồng lên nhau (nhiều dòng hơn)

Ghi chú hiệu năng (Performance Note)

Set-based operations nhanh hơn row-by-row processing hàng trăm đến hàng nghìn lần. Đây không phải nói quá — đây là thực tế:

Cách xử lý1,000 dòng100,000 dòng1,000,000 dòng
Loop (Python/cursor)~100ms~10s~2 phút
Single SQL statement~2ms~50ms~500ms
Tỷ lệ50x200x240x

Tại sao? Database engine có thể:

  • 🚀 Sử dụng index để tìm dữ liệu không cần quét toàn bộ bảng
  • 🚀 Song song hóa xử lý trên nhiều CPU core
  • 🚀 Tối ưu bộ nhớ với buffer pool và cache
  • 🚀 Batch I/O — đọc/ghi đĩa theo khối lớn thay vì từng dòng

Khi bạn dùng cursor/loop, bạn tước bỏ tất cả khả năng tối ưu này khỏi database engine. Giống như thuê một đầu bếp 5 sao rồi bắt anh ấy chỉ được rửa bát vậy.

🚫 Anti-pattern thực tế: Cursor/Loop abuse

Đây là anti-pattern mình gặp hàng tuần khi review code:

sql
-- 🚫 ANTI-PATTERN: Xử lý từng dòng trong stored procedure
-- "Cập nhật status cho tất cả đơn hàng pending quá 7 ngày"
DECLARE @order_id INT;
DECLARE cur CURSOR FOR
    SELECT id FROM orders
    WHERE status = 'pending'
    AND created_at < DATEADD(DAY, -7, GETDATE());

OPEN cur;
FETCH NEXT FROM cur INTO @order_id;

WHILE @@FETCH_STATUS = 0
BEGIN
    UPDATE orders SET status = 'expired' WHERE id = @order_id;
    INSERT INTO order_logs (order_id, action) VALUES (@order_id, 'auto_expired');
    FETCH NEXT FROM cur INTO @order_id;
END;

CLOSE cur;
DEALLOCATE cur;

Đoạn code trên update từng dòng một — mỗi lần gọi UPDATE là một transaction riêng, một lần lock riêng, một lần ghi log riêng.

sql
-- ✅ FIX: Tư duy set-based — một câu cho mỗi thao tác
-- Bước 1: Update tất cả cùng lúc
UPDATE orders
SET status = 'expired'
WHERE status = 'pending'
AND created_at < CURRENT_DATE - INTERVAL '7 days';

-- Bước 2: Log tất cả cùng lúc
INSERT INTO order_logs (order_id, action)
SELECT id, 'auto_expired'
FROM orders
WHERE status = 'expired'
AND created_at < CURRENT_DATE - INTERVAL '7 days';

🚨 Quy tắc sống còn

Nếu bạn thấy mình viết CURSOR, WHILE loop, hoặc gọi SQL trong vòng lặp của application code — DỪNG LẠI. 99% trường hợp có cách viết set-based tốt hơn. Cursor chỉ hợp lý trong những tình huống rất đặc biệt (admin task, migration script một lần).

🏋️ Bài tập nhanh

Câu hỏi: Cho câu query sau, hãy liệt kê thứ tự database thực thi từng clause:

sql
SELECT department, COUNT(*) AS total
FROM employees
WHERE salary > 5000000
GROUP BY department
HAVING COUNT(*) > 5
ORDER BY total DESC
LIMIT 3;
💡 Đáp án (click để mở)

Thứ tự thực thi:

Thứ tựClauseGiải thích
FROM employeesLấy toàn bộ bảng employees
WHERE salary > 5000000Lọc: chỉ giữ nhân viên lương > 5 triệu
GROUP BY departmentGom nhóm theo phòng ban
HAVING COUNT(*) > 5Lọc nhóm: chỉ giữ phòng ban có > 5 người (sau khi đã lọc WHERE)
SELECT department, COUNT(*) AS totalChọn cột hiển thị, tạo alias total
ORDER BY total DESCSắp xếp giảm dần theo total (alias dùng được ở ORDER BY vì nó chạy SAU SELECT)
LIMIT 3Chỉ lấy 3 dòng đầu tiên

Chú ý: Alias total dùng được trong ORDER BY (bước ⑥) vì ORDER BY chạy sau SELECT (bước ⑤). Nhưng total không dùng được trong WHERE hay HAVING vì chúng chạy trước SELECT.

🎮 Playground

Copy đoạn SQL dưới đây và chạy trên bất kỳ PostgreSQL/SQLite playground nào (khuyên dùng SQLite Online hoặc DB Fiddle):

sql
-- 🎮 Playground: Chứng minh thứ tự thực thi
-- Tạo bảng và insert dữ liệu mẫu

CREATE TABLE IF NOT EXISTS demo_employees (
    id INTEGER PRIMARY KEY,
    name TEXT,
    department TEXT,
    salary INTEGER
);

INSERT INTO demo_employees (name, department, salary) VALUES
('An', 'Engineering', 8000000),
('Bình', 'Engineering', 6000000),
('Cường', 'Marketing', 7000000),
('Dung', 'Marketing', 4000000),
('Em', 'Engineering', 9000000),
('Phong', 'Sales', 5500000),
('Giang', 'Sales', 3000000),
('Hùng', 'Engineering', 7500000);

-- Query 1: Quan sát thứ tự thực thi
-- FROM → WHERE → GROUP BY → HAVING → SELECT → ORDER BY
SELECT department, AVG(salary) AS avg_salary, COUNT(*) AS headcount
FROM demo_employees
WHERE salary > 5000000
GROUP BY department
HAVING COUNT(*) > 1
ORDER BY avg_salary DESC;

-- Query 2: Thử dùng alias trong WHERE → sẽ LỖI!
-- Uncomment để xem lỗi:
-- SELECT department, AVG(salary) AS avg_sal
-- FROM demo_employees
-- WHERE avg_sal > 6000000   ← LỖI: avg_sal chưa tồn tại
-- GROUP BY department;

-- Query 3: Fix bằng HAVING
SELECT department, AVG(salary) AS avg_sal
FROM demo_employees
GROUP BY department
HAVING AVG(salary) > 6000000;

-- Dọn dẹp
DROP TABLE IF EXISTS demo_employees;

🧪 Thử nghiệm thêm

  1. Bỏ WHERE salary > 5000000 ở Query 1 — kết quả thay đổi thế nào?
  2. Thay HAVING COUNT(*) > 1 thành HAVING COUNT(*) > 2 — phòng ban nào bị loại?
  3. Uncomment Query 2 để xem thông báo lỗi thực tế

📋 Tổng kết chương

ConceptGhi nhớ
DeclarativeNói CÁI GÌ, không nói LÀM SAO
Execution OrderFROM → WHERE → GROUP BY → HAVING → SELECT → ORDER BY → LIMIT
Set-BasedNghĩ theo tập hợp, không nghĩ từng dòng
Bảng là tất cảSELECT trả bảng, subquery là bảng, CTE đặt tên bảng
Alias trong WHERE❌ Không được — WHERE chạy trước SELECT
Alias trong ORDER BY✅ Được — ORDER BY chạy sau SELECT
Cursor/Loop🚫 Anti-pattern — luôn ưu tiên set-based

📍 Trang tiếp theo

HướngTrangMô tả
➡️ TiếpBài 2: SELECT/WHERE/ORDER BY thực chiếnÁp dụng tư duy vừa học vào thực tế
📊 Mở rộngGROUP BY & Tổng HợpGom nhóm và aggregate
🏋️ Luyện tậpPractice: Basic QueriesBài tập tương tác