DA & DE

[DA & DE Blog #62] Làm Chủ SQL Phân Tích Thực Chiến Từ Con Số 0: Thứ Tự Thực Thi Câu Lệnh, Phân Biệt Rạch Ròi WHERE vs. HAVING, Bản Chất Các Phép JOIN & Hàm COALESCE

0 views
[DA & DE Blog #62] Làm Chủ SQL Phân Tích Thực Chiến Từ Con Số 0: Thứ Tự Thực Thi Câu Lệnh, Phân Biệt Rạch Ròi WHERE vs. HAVING, Bản Chất Các Phép JOIN & Hàm COALESCE

1. Bối cảnh thực tế (Context & Problem Statement)

Rất nhiều bạn mới học SQL thường gặp phải những lỗi cú pháp (Syntax Error) kỳ quặc khiến bản thân cảm thấy bế tắc:

  • Bạn vừa đặt tên bí danh cho một cột: SELECT amount * 0.1 AS discount ... nhưng khi xuống mệnh đề WHERE discount > 50 thì Database lập tức báo lỗi: Column 'discount' does not exist!
  • Bạn muốn lọc các phòng ban có hơn 10 nhân viên bằng câu lệnh: WHERE COUNT(employee_id) > 10 và Database lại báo lỗi: Aggregate functions not allowed in WHERE!
  • Bạn thực hiện phép LEFT JOIN để lấy toàn bộ danh sách khách hàng kể cả người chưa mua hàng, nhưng sau khi thêm một điều kiện lọc ở WHERE, kết quả lại bị mất sạch những người chưa mua, biến LEFT JOIN thành INNER JOIN một cách khó hiểu!

Tất cả những lỗi trên đều bắt nguồn từ một sự thật cốt lõi: Thứ tự bạn viết câu lệnh SQL (Lexical Order) hoàn toàn khác biệt với Thứ tự mà Database Engine thực sự tính toán (Logical Execution Order).


2. Các Khái Niệm & Cơ Chế Cốt Lõi

2.1. Bản Đồ Thứ Tự Thực Thi Của Câu Lệnh SQL (SQL Logical Order of Execution)

Một câu lệnh SQL hoàn chỉnh thường được viết theo thứ tự:
SELECT →\rightarrow FROM →\rightarrow JOIN →\rightarrow WHERE →\rightarrow GROUP BY →\rightarrow HAVING →\rightarrow ORDER BY →\rightarrow LIMIT.

Tuy nhiên, bên dưới động cơ cơ sở dữ liệu, thứ tự tính toán thực tế lại diễn ra qua 8 bước tuần tự chặt chẽ:

+-----------------------------------------------------------------------------------+
| THỨ TỰ THỰC THI THỰC TẾ TRONG DATABASE (LOGICAL ORDER OF EXECUTION)                |
+-----------------------------------------------------------------------------------+
  Bước 1: FROM & JOIN   ---> Tìm và ghép các bảng dữ liệu nguồn lại với nhau
  Bước 2: WHERE         ---> Lọc từng dòng dữ liệu thô thỏa mãn điều kiện
  Bước 3: GROUP BY      ---> Gom các dòng dữ liệu vào các nhóm (ví dụ theo Phòng ban)
  Bước 4: HAVING        ---> Lọc bỏ các nhóm không thỏa mãn tiêu chí tổng hợp
  Bước 5: SELECT        ---> Chỉ định các cột cần lấy và tính toán biểu thức (Đặt Alias)
  Bước 6: DISTINCT      ---> Loại bỏ các dòng bị trùng lặp kết quả
  Bước 7: ORDER BY      ---> Sắp xếp kết quả tăng dần (ASC) hoặc giảm dần (DESC)
  Bước 8: LIMIT / OFFSET---> Cắt lấy số lượng dòng hiển thị cuối cùng

💡 Giải mã câu đố:
Tại sao không dùng được Alias ở WHERE?
Vì WHERE chạy ở Bước 2, trong khi SELECT (nơi đặt Alias) mãi tận Bước 5 mới chạy! Tại Bước 2, Database hoàn toàn chưa biết cái tên Alias đó là gì!


2.2. Phân Biệt Tuyệt Đối: WHERE vs. HAVING

  • Mệnh đề WHERE (Lọc trước khi gom nhóm):
    • Chạy ở Bước 2.
    • Tác động lên từng dòng dữ liệu đơn lẻ (Row-level) trước khi bất kỳ phép gom nhóm nào diễn ra.
    • Tuyệt đối không được chứa các hàm tổng hợp như SUM(), COUNT(), AVG(), MAX(), MIN().
  • Mệnh đề HAVING (Lọc sau khi gom nhóm):
    • Chạy ở Bước 4 (ngay sau GROUP BY).
    • Tác động lên cả một nhóm dữ liệu tổng hợp (Group-level).
    • Bắt buộc phải đi kèm hàm tổng hợp hoặc cột nằm trong GROUP BY.
Tập dữ liệu thô ---> [ Lọc WHERE: Chỉ lấy đơn năm 2026 ] ---> [ GROUP BY: Gom theo Khách hàng ]
                                                                             |
                                                                             v
[ Danh sách cuối cùng ] <--- [ Lọc HAVING: Chỉ lấy khách có SUM(tiền) > 10M ]

2.3. Bản Chất Trực Quan Của Các Phép JOIN

Giả sử có 2 bảng: Bảng bên trái là Khach_Hang (chứa 10 người), Bảng bên phải là Don_Hang (chứa các đơn mua).

1. INNER JOIN (Giao tập hợp):
Chỉ giữ lại những dòng có xuất hiện ở CẢ HAI BẢNG.
(Khách nào có mua hàng thì mới hiện; khách chưa mua bị loại bỏ hoàn toàn).

2. LEFT JOIN (Ưu tiên bảng trái):
Giữ lại 100% khách hàng ở Bảng Trái.
- Nếu khách có mua hàng: Hiển thị thông tin đơn hàng.
- Nếu khách chưa từng mua: Các cột thông tin đơn hàng sẽ hiển thị là NULL.

3. RIGHT JOIN (Ưu tiên bảng phải):
Giữ lại 100% dữ liệu ở Bảng Phải (Ít dùng, vì thường đổi vị trí bảng và dùng LEFT JOIN cho thuận mắt).

4. FULL OUTER JOIN (Hợp nhất toàn bộ):
Lấy tất cả mọi dòng từ cả 2 bảng. Chỗ nào không khớp sẽ điền NULL.

2.4. Phân Nhánh Logic Bằng CASE WHEN & Đếm Có Điều Kiện (Conditional Aggregation)

Hàm CASE WHEN tương đương với cấu trúc IF - ELSE IF - ELSE trong lập trình, giúp bạn biến đổi dữ liệu mềm dẻo ngay trong câu query:

CASE 
    WHEN diem_so >= 8.5 THEN 'Giỏi'
    WHEN diem_so >= 6.5 THEN 'Khá'
    ELSE 'Trung bình'
END AS xep_loai

Kỹ thuật Đỉnh Cao: Đếm có điều kiện (COUNT(CASE WHEN ...)):

Thay vì phải viết nhiều câu query rồi dùng UNION, bạn có thể đếm nhiều nhóm tiêu chí khác nhau trong cùng 1 câu lệnh duy nhất:

SELECT 
    COUNT(order_id) AS tong_so_don,
    -- Đếm số đơn hoàn thành:
    COUNT(CASE WHEN status = 'COMPLETED' THEN order_id END) AS don_thanh_cong,
    -- Đếm số đơn bị hủy:
    COUNT(CASE WHEN status = 'CANCELLED' THEN order_id END) AS don_bi_huy
FROM orders;

2.5. Xử Lý Giá Trị NULL & Hàm Cứu Cánh COALESCE()

Trong SQL, NULL không phải là số 0, cũng không phải là chuỗi rỗng "". NULL đại diện cho "Không biết" hoặc "Chưa có thông tin".

  • Cạm bẫy toán học của NULL (Tam trị luận - Three-Valued Logic):
    • Trong SQL, phép so sánh: NULL = NULL sẽ không trả về TRUE mà trả về UNKNOWN!
    • Do đó, câu lệnh: WHERE email = NULL sẽ luôn luôn không trả về dòng nào cả!
    • Cách viết chuẩn xác duy nhất: Luôn dùng toán tử IS NULL hoặc IS NOT NULL.
  • Hàm COALESCE(val1, val2, val3, ...):
    • Trả về giá trị đầu tiên khác NULL trong danh sách truyền vào.
    • Ứng dụng thực tế: Khi LEFT JOIN bảng khách hàng với bảng đơn hàng, khách chưa mua sẽ có SUM(amount) IS NULL. Sử dụng COALESCE(SUM(amount), 0) sẽ tự động thay thế số NULL xấu xí thành số 0 tròn trĩnh!

3. Các Bảng Markdown So Sánh Chi Tiết

📊 Bảng 1: Thứ Tự Viết Code SQL vs. Thứ Tự Database Thực Thi

Thứ tự viết code (Lexical Order)Thứ tự Database thực thi (Logical Order)Nhiệm vụ của từng bước
1. SELECTBước 1: FROM & JOINNạp bảng nguồn và kết nối các bảng theo điều kiện ON
2. FROMBước 2: WHEREQuét và lọc từng dòng dữ liệu thô trước khi gom nhóm
3. JOIN ... ONBước 3: GROUP BYGom các dòng dữ liệu có cùng giá trị vào một nhóm
4. WHEREBước 4: HAVINGLọc bỏ các nhóm không thỏa mãn điều kiện hàm tổng hợp
5. GROUP BYBước 5: SELECTTrích xuất các cột cần lấy, tính toán biểu thức, gán Alias
6. HAVINGBước 6: DISTINCTLoại bỏ các dòng kết quả bị trùng lặp
7. ORDER BYBước 7: ORDER BYSắp xếp thứ tự các dòng (Ở bước này ĐÃ dùng được Alias!)
8. LIMITBước 8: LIMITLấy ra đúng số lượng dòng hiển thị mong muốn

📊 Bảng 2: So Sánh Toàn Diện Mệnh Đề WHERE vs. HAVING

Tiêu chíMệnh đề WHEREMệnh đề HAVING
Thời điểm thực thiChạy TRƯỚC khi gom nhóm (Bước 2)Chạy SAU khi gom nhóm (Bước 4)
Đối tượng tác độngTừng dòng dữ liệu riêng lẻ (Row-level)Cả một nhóm dữ liệu tổng hợp (Group-level)
Chứa hàm tổng hợp (SUM, COUNT)?TUYỆT ĐỐI KHÔNGCÓ HỖ TRỢ (Ví dụ: HAVING COUNT(*) > 5)
Đi kèm mệnh đề GROUP BY?Có thể dùng độc lập, không cần GROUP BYThường bắt buộc phải đi kèm với GROUP BY
Tác động hiệu năng truy vấnRất nhanh (Lọc sớm giúp giảm bớt dữ liệu cần gom nhóm)Chậm hơn (Database phải gom nhóm xong xuôi rồi mới lọc)

📊 Bảng 3: Ma Trận So Sánh Các Phép JOIN Căn Bản

Loại JOINKết quả trả vềXử lý các dòng không khớp (Unmatched Rows)Ví dụ ứng dụng thực tế
INNER JOINChỉ các dòng khớp nhau ở cả 2 bảngLoại bỏ hoàn toàn khỏi kết quảTìm danh sách các đơn hàng đã có người mua cụ thể
LEFT JOINToàn bộ bảng Trái + Phần khớp ở bảng PhảiĐiền NULL cho các cột của bảng Phải nếu không khớpTìm danh sách khách hàng chưa mua đơn hàng nào
RIGHT JOINPhần khớp ở bảng Trái + Toàn bộ bảng PhảiĐiền NULL cho các cột của bảng Trái nếu không khớpÍt dùng (Có thể đảo vị trí bảng và dùng LEFT JOIN)
FULL JOINToàn bộ dữ liệu của cả 2 bảngĐiền NULL cho bất kỳ bên nào bị thiếuĐối soát dữ liệu giữa 2 hệ thống kế toán độc lập

4. Thực Hành Thực Chiến: Bài Tập SQL Phân Tích Thực Tế (Walkthrough)

📌 Tập dữ liệu mẫu:

Cho 2 bảng trong cơ sở dữ liệu bán hàng:

  1. Bảng customers: customer_id, customer_name, city
  2. Bảng orders: order_id, customer_id, order_date, total_amount, status ('COMPLETED', 'CANCELLED')

💻 Bài Toán 1: Tìm các khách hàng ở "Hà Nội" đã chi tiêu tổng cộng trên 10 triệu đồng trong năm 2026:

SELECT 
    c.customer_id,
    c.customer_name,
    SUM(o.total_amount) AS tong_chi_tieu
FROM customers c
INNER JOIN orders o ON c.customer_id = o.customer_id
-- Bước 2 (WHERE): Lọc dòng thô trước - Chỉ lấy khách Hà Nội, đơn hoàn thành năm 2026:
WHERE c.city = 'Hà Nội' 
  AND o.status = 'COMPLETED'
  AND o.order_date >= '2026-01-01' AND o.order_date < '2027-01-01'
-- Bước 3 (GROUP BY): Gom nhóm theo từng khách hàng:
GROUP BY c.customer_id, c.customer_name
-- Bước 4 (HAVING): Lọc nhóm sau khi tính toán - Chỉ lấy tổng tiền > 10 triệu:
HAVING SUM(o.total_amount) > 10000000
-- Bước 7 (ORDER BY): Sắp xếp người chi nhiều nhất lên đầu:
ORDER BY tong_chi_tieu DESC;

💻 Bài Toán 2: Tìm danh sách các khách hàng chưa từng mua bất kỳ đơn hàng nào (Lead tồn kho):

SELECT 
    c.customer_id,
    c.customer_name,
    c.city
FROM customers c
-- Dùng LEFT JOIN để giữ lại toàn bộ khách hàng:
LEFT JOIN orders o ON c.customer_id = o.customer_id
-- Mấu chốt kỹ thuật: Nếu khách chưa mua, cột khóa ngoại của bảng orders sẽ là NULL:
WHERE o.order_id IS NULL;

💻 Bài Toán 3: Báo cáo tổng hợp số lượng đơn thành công, số đơn hủy và phân loại khách hàng bằng COALESCE và CASE WHEN:

SELECT 
    c.customer_id,
    c.customer_name,
    -- Dùng COALESCE để nếu khách chưa có đơn thì hiển thị 0đ thay vì hiển thị NULL:
    COALESCE(SUM(o.total_amount), 0) AS tong_tien,
    -- Đếm có điều kiện:
    COUNT(CASE WHEN o.status = 'COMPLETED' THEN o.order_id END) AS so_don_thanh_cong,
    COUNT(CASE WHEN o.status = 'CANCELLED' THEN o.order_id END) AS so_don_huy,
    -- Phân loại giá trị khách hàng:
    CASE 
        WHEN COALESCE(SUM(o.total_amount), 0) >= 20000000 THEN 'Khách Hàng VIP'
        WHEN COALESCE(SUM(o.total_amount), 0) >= 5000000 THEN 'Khách Tiềm Năng'
        ELSE 'Khách Tiêu Chuẩn'
    END AS phan_hang_khach
FROM customers c
LEFT JOIN orders o ON c.customer_id = o.customer_id
GROUP BY c.customer_id, c.customer_name
ORDER BY tong_tien DESC;

5. Góc Ôn Luyện Phỏng Vấn (Interview Corner)

❓ Câu hỏi 1: Tại sao bạn không thể sử dụng bí danh (Alias) được khai báo trong mệnh đề SELECT để làm điều kiện lọc ở mệnh đề WHERE? Nhưng tại sao ở mệnh đề ORDER BY thì lại dùng được bình thường?

  • Gợi ý trả lời ngắn gọn, chuẩn xác:
    • Giải thích bằng Thứ tự thực thi logic của SQL:
      • Mệnh đề WHERE được Database thực thi ở Bước 2, trong khi mệnh đề SELECT (nơi định nghĩa các cột mới và đặt tên bí danh Alias) mãi đến Bước 5 mới được tính toán.
      • Do đó, tại thời điểm WHERE đang quét các dòng dữ liệu thô, tên bí danh Alias đó hoàn toàn chưa tồn tại trong bộ nhớ của Database Engine, dẫn đến lỗi Column does not exist.
      • Ngược lại, mệnh đề ORDER BY được thực thi ở Bước 7 (nằm sau SELECT). Tại thời điểm sắp xếp, Database đã hoàn thành việc tính toán biểu thức và đã ghi nhận đầy đủ các tên bí danh Alias, do đó chúng ta có thể sử dụng Alias trong ORDER BY một cách hoàn toàn hợp lệ.

❓ Câu hỏi 2: Sự khác nhau bản chất giữa WHERE và HAVING trong SQL là gì? Cho ví dụ một câu truy vấn thực tế bắt buộc phải sử dụng đồng thời cả WHERE lẫn HAVING.

  • Gợi ý trả lời:
    • Sự khác biệt bản chất:
      • WHERE là bộ lọc áp dụng trên từng dòng dữ liệu đơn lẻ (Row-level) trước khi dữ liệu được đưa vào gom nhóm. WHERE không bao giờ được chứa các hàm tổng hợp như SUM(), COUNT(), AVG().
      • HAVING là bộ lọc áp dụng trên từng nhóm dữ liệu đã tổng hợp (Group-level) sau khi thực hiện GROUP BY. HAVING chuyên dùng để lọc các giá trị tính toán từ hàm tổng hợp.
    • Ví dụ bắt buộc dùng cả hai:
      • Bài toán: Tìm các cửa hàng có doanh thu từ các mặt hàng điện tử trong tháng 8 đạt trên 500 triệu:
        SELECT store_id, SUM(amount) AS dt_dientu
        FROM sales
        -- Dùng WHERE để lọc sớm các dòng thô: Chỉ lấy ngành hàng Điện tử trong Tháng 8:
        WHERE category = 'Điện tử' AND sale_date BETWEEN '2026-08-01' AND '2026-08-31'
        GROUP BY store_id
        -- Dùng HAVING để lọc trên kết quả tổng cộng sau khi gom nhóm theo cửa hàng:
        HAVING SUM(amount) > 500000000;
        

❓ Câu hỏi 3: Khi thực hiện phép LEFT JOIN, một lập trình viên đặt điều kiện lọc ở mệnh đề WHERE thay vì mệnh đề ON. Tại sao điều này lại vô tình biến câu lệnh LEFT JOIN thành INNER JOIN? Cho ví dụ minh họa.

  • Gợi ý trả lời:
    • Cơ chế hoạt động:
      • Khi thực hiện LEFT JOIN, những dòng ở bảng bên trái không tìm thấy bản ghi khớp ở bảng bên phải sẽ được giữ lại, và các cột của bảng bên phải sẽ được điền giá trị NULL.
      • Mệnh đề ON quy định điều kiện để ghép nối dữ liệu giữa 2 bảng trong quá trình JOIN (Bước 1).
      • Mệnh đề WHERE diễn ra sau đó (Bước 2) để lọc trên tập kết quả vừa ghép xong.
    • Cạm bẫy biến thành INNER JOIN:
      • Giả sử ta muốn lấy tất cả khách hàng và các đơn hàng có trạng thái 'COMPLETED':
        -- ❌ CÁCH VIẾT SAI (Biến thành INNER JOIN):
        SELECT c.customer_id, c.customer_name, o.order_id
        FROM customers c
        LEFT JOIN orders o ON c.customer_id = o.customer_id
        WHERE o.status = 'COMPLETED';
        
      • Hiện tượng: Với những khách hàng chưa từng mua hàng, cột o.status của họ nhận giá trị là NULL. Khi mệnh đề WHERE o.status = 'COMPLETED' chạy, phép so sánh NULL = 'COMPLETED' trả về UNKNOWN →\rightarrow Toàn bộ các khách hàng chưa mua hàng bị loại bỏ sạch sẽ! Câu lệnh vô tình hoạt động hệt như một phép INNER JOIN.
    • Cách sửa đúng: Chuyển điều kiện lọc vào thẳng mệnh đề ON của LEFT JOIN:
      -- ✅ CÁCH VIẾT ĐÚNG:
      SELECT c.customer_id, c.customer_name, o.order_id
      FROM customers c
      LEFT JOIN orders o ON c.customer_id = o.customer_id AND o.status = 'COMPLETED';
      

6. Tóm Tắt Ghi Nhớ Nhanh (Key Takeaways)

  1. Nhớ nằm lòng Thứ tự thực thi SQL: FROM/JOIN →\rightarrow WHERE →\rightarrow GROUP BY →\rightarrow HAVING →\rightarrow SELECT →\rightarrow ORDER BY →\rightarrow LIMIT.
  2. WHERE lọc từng dòng thô (chạy trước, không dùng hàm tổng hợp); HAVING lọc cả nhóm (chạy sau, chuyên dùng cho SUM/COUNT).
  3. Dùng LEFT JOIN kết hợp WHERE right_table.id IS NULL để tìm các bản ghi không có giao dịch phát sinh.
  4. Trong SQL, NULL = NULL là vô nghĩa; luôn dùng toán tử IS NULL và hàm COALESCE(col, default_val) để thay thế giá trị mặc định.
  5. Kỹ thuật COUNT(CASE WHEN ... THEN ... END) là vũ khí tối thượng để đếm nhiều điều kiện kinh doanh trong 1 câu truy vấn duy nhất.
0Claps