Thứ Tự Thực Thi Của Một Câu Lệnh SQL

CU
cuongnv
Middle

1. Mở Đầu: Nỗi Đau "Invalid Column Name" Và Lỗ Hổng Tư Duy Của Lập Trình Viên

Trong suốt hơn một thập kỷ làm việc với cơ sở dữ liệu quan hệ (RDBMS), từ những hệ thống tài chính xử lý hàng chục nghìn transaction mỗi giây cho đến các Data Warehouse chứa hàng chục terabyte dữ liệu, tôi đã phỏng vấn hàng trăm kỹ sư phần mềm. Một câu hỏi kiểm tra căn bản nhưng lại khiến không ít bạn làm việc 2-4 năm kinh nghiệm phải lúng túng là:

"Đoạn code sau đây có chạy được trên MS SQL Server hay PostgreSQL không? Nếu không, tại sao?"


SELECT 
    customer_id, 
    YEAR(order_date) AS order_year, 
    COUNT(order_id) AS total_orders
FROM orders
WHERE order_year = 2025
GROUP BY customer_id, order_year
HAVING total_orders > 5;

Phần lớn câu trả lời nhận được là: "Có vẻ ổn anh ạ, vì trong SELECT đã alias order_year rồi nên WHERE chỉ cần gọi lại thôi!"

Khi chạy thực tế, Database Engine ngay lập tức ném ra một ngoại lệ lạnh lùng: Msg 207: Invalid column name 'order_year'.

Tại sao lại có nghịch lý này? Chúng ta viết SELECT ngay dòng đầu tiên của câu lệnh cơ mà? Tại sao Engine lại "mù" trước một bí danh (alias) đã được khai báo rành rành ở dòng 2?

Nguyên nhân cốt lõi nằm ở sự xung đột giữa Cú pháp trình bày (Lexical/Syntactic Order)Thứ tự thực thi logic (Logical Query Processing Order).

SQL không phải là một ngôn ngữ mệnh lệnh (Imperative Language) như C++, Java, hay Python — nơi các dòng lệnh được biên dịch và thực thi tuần tự từ trên xuống dưới. SQL là ngôn ngữ khai báo (Declarative Language). Bạn nói cho hệ quản trị cơ sở dữ liệu biết "BẠN MUỐN DỮ LIỆU GÌ", chứ không phải "BƯỚC NÀO LÀM TRƯỚC BƯỚC NÀO LÀM SAU".

Để hiện thực hóa câu lệnh khai báo đó thành tập kết quả cuối cùng, Database Engine bên dưới phải tuân thủ một chu trình xử lý logic vô cùng nghiêm ngặt. Nếu bạn không nắm vững quy trình này, bạn sẽ mãi chỉ là người viết SQL theo bản năng: code sai thì sửa mò, query chạy chậm thì không biết tại sao nghẽn, và việc tối ưu hóa Index hay Execution Plan sẽ mãi là một bức màn đen tối.

Bài viết này được đúc kết từ kinh nghiệm thực chiến, nhằm bóc tách tường tận từng nơ-ron xử lý của SQL Engine, biến tư duy cơ sở dữ liệu của bạn trở nên sắc bén và chuẩn mực.

2. Cú Pháp Viết (Lexical Syntax) vs. Thứ Tự Xử Lý Logic (Logical Processing)

Để hiểu rõ sự khác biệt, hãy nhìn vào hai cột so sánh dưới đây:

BướcCú Pháp Viết Code (Bạn gõ trên IDE)Thứ Tự Thực Thi Logic (Engine xử lý bên dưới)
1SELECTFROM (kèm các phép JOIN)
2DISTINCTON
3FROM & JOINJOIN (kết hợp các hàng theo kiểu kết nối)
4ONWHERE
5WHEREGROUP BY
6GROUP BYWITH CUBE / WITH ROLLUP (nếu có)
7HAVINGHAVING
8WINDOW FUNCTIONS (nằm trong SELECT)SELECT (Tính toán biểu thức, gán Alias)
9ORDER BYWINDOW FUNCTIONS (Hàm cửa sổ)
10OFFSET / FETCH / LIMIT / TOPDISTINCT
11 ORDER BY
12 TOP / LIMIT / OFFSET

Tại Sao Lại Có Sự "Ngược Đời" Này?

Lịch sử của SQL bắt nguồn từ ngôn ngữ SEQUEL do IBM phát triển vào những năm 1970, dựa trên đại số quan hệ của Edgar F. Codd. Cú pháp viết được thiết kế nhằm mô phỏng tiếng Anh tự nhiên:

"Hãy CHỌN các thông tin X, Y, Z TỪ nguồn bảng A, B, NƠI MÀ điều kiện thỏa mãn..." (SELECT X, Y FROM table WHERE condition...)

Nhưng dưới góc nhìn của một cỗ máy tính toán quan hệ, bạn không thể chọn lựa dữ liệu (SELECT) nếu bạn chưa biết dữ liệu đó được lấy từ đâu (FROM), kết nối với nguồn nào (JOIN), và bản ghi nào đủ tư cách ở lại (WHERE). Do đó, RDBMS bắt buộc phải đảo ngược hoàn toàn quy trình nhận diện để phục vụ tính toàn vẹn của đại số quan hệ.
3. Bức Tranh Tổng Thể: Luồng Xử Lý Dữ Liệu Qua Các Bảng Ảo (Virtual Tables)

Mỗi bước trong quá trình xử lý truy vấn logic không tác động trực tiếp lên dữ liệu vật lý trên đĩa (disk page) hay làm thay đổi bảng nguồn. Thay vào đó, mỗi mệnh đề nhận đầu vào là một hoặc nhiều Bảng Ảo (Virtual Table - viết tắt là VTVT) và sản sinh ra một Bảng Ảo mới làm đầu vào cho bước kế tiếp:

Mermaid diagram

Hiểu được mô hình bảng ảo này là "chìa khóa vàng" giúp bạn đọc hiểu mọi execution plan và tự tin giải thích hành vi của database mà không cần phải phỏng đoán.

 

4. Mổ Xẻ Chi Tiết Từng Mắt Xích Trong Chuỗi Thực Thi Logic

Hãy cùng đi sâu vào từng bước của quy trình với tư duy của một Database Internals Engineer.

Bước 1: FROM & Tích Đề-các (Cartesian Product)

Mọi hành trình của dữ liệu đều phải bắt đầu từ cội nguồn: Mệnh đề FROM.

Nếu trong mệnh đề FROM chỉ có một bảng đơn lẻ:

 FROM Employees

Bảng ảo VT1VT1​ đơn giản là bản sao logic của toàn bộ các dòng và cột trong bảng Employees.

Tuy nhiên, nếu có từ hai bảng trở lên được liệt kê phân tách bằng dấu phẩy (hoặc cú pháp CROSS JOIN)

FROM Orders, Customers
-- hoặc
FROM Orders CROSS JOIN Customers

Engine sẽ thực hiện phép toán Tích Đề-các (Cartesian Product) giữa hai bảng. Nếu bảng Orders có 1.000 dòng và bảng Customers có 500 dòng, bảng ảo VT1VT1​ sẽ chứa chính xác: 1.000×500=500.000 doˋng1.000×500=500.000 doˋng

Mỗi dòng của bảng thứ nhất sẽ được ghép đôi vô điều kiện với tất cả các dòng của bảng thứ hai. Mỗi dòng trong VT1VT1​ chứa toàn bộ các cột từ cả hai bảng.

Kinh nghiệm Senior: Trên môi trường Production, việc vô tình viết thiếu điều kiện kết nối dẫn đến Cartesian Product ngoài ý muốn là nguyên nhân hàng đầu khiến CPU chạm ngưỡng 100%, RAM cạn kiệt và gây tràn bảng nhớ tạm (TempDB trong SQL Server hoặc file sort tạm trên đĩa của PostgreSQL).

Bước 2: Mệnh Đề ON – Thiết Lập Điều Kiện Kết Hợp

Mệnh đề ON đóng vai trò là "người gác cổng" đầu tiên sau khi hai bảng được đặt cạnh nhau. Nó nhận đầu vào là bảng ảo VT1VT1​ từ Bước 1 và áp dụng biểu thức logic (predicate) được chỉ định.

Chỉ những dòng nào trong VT1VT1​ mà biểu thức trong ON trả về giá trị TRUE mới được giữ lại để đưa vào bảng ảo VT2VT2​. Các dòng trả về FALSE hoặc UNKNOWN (do dính giá trị NULL trong phép so sánh ba giá trị theo chuẩn Three-Valued Logic của SQL) đều bị loại bỏ.

Ví dụ:

FROM Customers C
JOIN Orders O 
    ON C.CustomerID = O.CustomerID

Nếu một khách hàng trong Customers có CustomerID bằng 10, và có 3 đơn hàng trong Orders mang CustomerID bằng 10, thì trong VT2VT2​ sẽ có đúng 3 bản ghi tương ứng. Những khách hàng chưa từng mua hàng (không tìm thấy CustomerID bên bảng Orders) sẽ có giá trị kiểm tra là FALSE và bị gạt ra khỏi VT2VT2​.

Bước 3: Cơ Chế OUTER JOIN – Bù Đắp Các Hàng Bị Mất

Nếu câu truy vấn sử dụng phép kết nối INNER JOIN, bảng ảo VT2VT2​ sẽ được chuyển thẳng sang Bước 4.

Tuy nhiên, nếu câu truy vấn sử dụng LEFT OUTER JOIN, RIGHT OUTER JOIN, hoặc FULL OUTER JOIN, một cơ chế đặc biệt gọi là "Bảo lưu hàng ngoài" (Outer Row Preservation) sẽ được kích hoạt:

  1. Engine kiểm tra lại bảng gốc được chỉ định là "bảo lưu" (Bảng bên trái trong LEFT JOIN, bên phải trong RIGHT JOIN, hoặc cả hai trong FULL JOIN).
  2. Những dòng nào từ bảng bảo lưu bị loại bỏ ở Bước 2 (do không thỏa mãn mệnh đề ON) sẽ được thêm ngược trở lại vào bảng ảo VT2VT2​.
  3. Tất cả các cột đến từ bảng còn lại (bảng không được bảo lưu) của những dòng mới được thêm này sẽ được gán giá trị NULL.

Kết quả của quá trình này tạo nên bảng ảo VT3VT3​.


[Bảng Khách Hàng (3 dòng)] LEFT JOIN [Bảng Đơn Hàng (2 dòng)]
             │
             ▼
[Bước 1: Tích Đề-các] ──> 6 dòng kết hợp
             │
             ▼
[Bước 2: Lọc theo ON] ──> Chỉ còn 2 dòng khớp CustomerID
             │
             ▼
[Bước 3: Thêm Outer Rows] ──> Khách hàng thứ 3 (chưa mua gì) được kéo lại vào VT3, 
                               các cột của Đơn Hàng được điền NULL.

Bước 4: WHERE – Bộ Lọc Dòng Đơn Lẻ (Row-level Filtering)

Sau khi toàn bộ dữ liệu từ các bảng nguồn và các phép kết nối đã được định hình tại VT3VT3​, mệnh đề WHERE vào cuộc.

Mệnh đề WHERE đóng vai trò là một bộ lọc nghiêm ngặt ở cấp độ từng dòng đơn lẻ (Row-level). Nó áp dụng các điều kiện lọc lên từng hàng của VT3VT3​. Chỉ những hàng nào thỏa mãn điều kiện logic trả về TRUE mới được giữ lại và chuyển vào bảng ảo tiếp theo (VT4VT4​).

Những Điểm Then Chốt Cần Khắc Cốt Ghi Tâm Về WHERE:

Không thể sử dụng bí danh (Alias) được định nghĩa trong SELECT: Tại thời điểm mệnh đề WHERE đang chạy, mệnh đề SELECT hoàn toàn chưa được thực thi! Bảng ảo VT3VT3​ chỉ chứa tên các cột gốc từ các bảng vật lý. Do đó, nếu bạn viết WHERE total_price > 1000 (với total_price là alias của quantity * unit_price), hệ thống sẽ báo lỗi ngay lập tức vì nó không hề biết total_price là cái gì.

Không thể sử dụng các hàm tổng hợp (Aggregate Functions): Bạn không thể viết WHERE COUNT(order_id) > 5. Lý do: Tại bước này, dữ liệu chưa hề được phân nhóm (GROUP BY). Các dòng dữ liệu vẫn đang đứng độc lập. Hàm tổng hợp chỉ có ý nghĩa khi đã có khái niệm "nhóm" (group).

Hiện tượng Tam Luận Trị (Three-Valued Logic): Trong SQL, một biểu thức logic có thể trả về một trong ba giá trị: TRUE, FALSE, hoặc UNKNOWN. Mệnh đề WHERE chỉ chấp nhận những dòng có kết quả trả về là TRUE. Nếu biểu thức trả về UNKNOWN (ví dụ: so sánh với giá trị NULL bằng toán tử =, ví dụ status = NULL), dòng đó sẽ bị loại bỏ không thương tiếc. Đó là lý do tại sao bạn luôn phải dùng IS NULL hoặc IS NOT NULL.

Bước 5: GROUP BY – Gom Cụm Dữ Liệu Vào Các Bucket

Khi dữ liệu đã vượt qua được bộ lọc WHERE để vào VT4VT4​, nếu câu truy vấn có chứa mệnh đề GROUP BY, một sự biến đổi cấu trúc căn bản sẽ diễn ra.

Tất cả các dòng trong VT4VT4​ có cùng giá trị trên các cột được chỉ định trong danh sách GROUP BY sẽ được gom lại và ném vào chung một "thùng chứa" duy nhất, gọi là một Bucket (Nhóm dữ liệu).

Dữ liệu thô trong VT4:
[HN, Nam, 100$]
[HN, Nữ,  150$]  ─── GROUP BY City ───> Bucket "HN" (Chứa 3 dòng dữ liệu)
[HN, Nam, 200$]
[HCM, Nam, 300$] ─── GROUP BY City ───> Bucket "HCM" (Chứa 1 dòng dữ liệu)

Kết quả của bước này là bảng ảo VT5VT5​, trong đó:

  • Mỗi nhóm bây giờ chỉ đại diện cho ĐÚNG MỘT DÒNG duy nhất.
  • Bản sắc của từng dòng dữ liệu cá lẻ ban đầu đã bị "nuốt chửng" vào trong nhóm.
  • Hệ quả chết người: Kể từ bước này trở đi, bạn không còn quyền truy cập trực tiếp vào các cột dữ liệu đơn lẻ nằm ngoài danh sách GROUP BY, trừ khi bạn bọc cột đó vào trong một Hàm tổng hợp (Aggregate Function) như SUM(), AVG(), MIN(), MAX(), hay COUNT().
  • Bởi vì nếu trong nhóm "HN" có cả "Nam" và "Nữ", nếu bạn yêu cầu xuất ra cột Gender mà không gom nhóm theo nó, Database Engine sẽ không thể biết phải hiển thị giá trị nào cho đại diện của nhóm "HN"!

Bước 6: HAVING – Bộ Lọc Nhóm Đã Gom Cụm

Nếu mệnh đề WHERE là bộ lọc dành cho từng dòng đơn lẻ trước khi gom nhóm, thì HAVING chính là bộ lọc dành cho các nhóm (Buckets) sau khi GROUP BY đã hoàn thành.

Mệnh đề HAVING nhận đầu vào là bảng ảo VT5VT5​ gồm các nhóm, tính toán điều kiện logic cho từng nhóm, và chỉ giữ lại những nhóm trả về TRUE để tạo nên bảng ảo VT6VT6​.


GROUP BY customer_id
HAVING COUNT(order_id) >= 3 AND SUM(amount) > 1000

Ở đây:

  • Engine sẽ tính toán COUNT(order_id) và SUM(amount) cho từng nhóm customer_id.
  • Nhóm nào không thỏa mãn cả hai tiêu chí trên sẽ bị gạt bỏ toàn bộ.

Trực giác quan trọng: HAVING là nơi DUY NHẤT trong chu trình lọc dữ liệu mà các hàm tổng hợp được phép hiện diện một cách tự nhiên mà không cần đến truy vấn con (Subquery).

Bước 7: SELECT & Window Functions – Phép Chiếu Và Tạo Cột

Sau khi trải qua các bộ lọc khắt khe từ bảng, kết nối, lọc dòng, gom nhóm và lọc nhóm, chúng ta mới chính thức bước vào mệnh đề xuất hiện ở đầu tiên của câu lệnh: SELECT.

Mệnh đề SELECT thực hiện hai nhiệm vụ cốt lõi:

  1. Phép chiếu (Projection): Xác định chính xác những cột dữ liệu nào sẽ xuất hiện trong kết quả trả về, loại bỏ những cột không cần thiết.
  2. Tính toán biểu thức và gán bí danh: Thực thi các phép toán số học, chuỗi, hàm logic (CASE WHEN), và khởi tạo bí danh (Aliases).

Bảng ảo VT7VT7​ được tạo ra ở bước này.

Thứ Tự Của Window Functions (Hàm Cửa Sổ):

Nếu trong SELECT có chứa các Window Functions như:

ROW_NUMBER() OVER(PARTITION BY department_id ORDER BY salary DESC) AS rank_in_dept

Chúng sẽ được thực thi ngay tại bước này, sau khi các hàm tổng hợp thông thường của GROUP BY và bộ lọc HAVING đã xong xuôi. Window function có thể nhìn thấy toàn bộ các nhóm và các giá trị tổng hợp, nhưng nó không làm gộp các dòng lại như GROUP BY mà giữ nguyên cấu trúc dòng của VT6VT6​.

Tại Sao Alias Chỉ Bắt Đầu Tồn Tại Từ Đây?

Bởi vì bảng ảo VT7VT7​ là nơi đầu tiên mà các alias như AS order_year hay AS total_amount được Engine gán nhãn vào cấu trúc siêu dữ liệu (metadata) của bảng ảo. Đó là lý do tại sao các bước trước nó (FROM, ON, WHERE, GROUP BY, HAVING) hoàn toàn mù tịt về những bí danh này.

Bước 8: DISTINCT – Khử Trùng Lặp Dữ Liệu

Nếu trong câu lệnh có từ khóa DISTINCT:

SELECT DISTINCT city, country FROM Customers

Database Engine sẽ nhận bảng ảo VT7VT7​ làm đầu vào và thực hiện quét toàn bộ các bản ghi để tìm các dòng có giá trị trùng lặp trên tất cả các cột được chọn.

Để làm được điều này, Engine thường phải áp dụng một trong hai thuật toán vật lý tốn kém:

  1. Sort Aggregate: Sắp xếp toàn bộ dữ liệu theo các cột chọn rồi duyệt tuần tự để loại bỏ các phần tử liền kề trùng nhau.
  2. Hash Aggregate: Đẩy dữ liệu vào một bảng băm (Hash Table) trong bộ nhớ, các bản ghi có cùng mã băm (hash collision) sẽ được kiểm tra và loại bỏ.

Dữ liệu duy nhất sau khi khử trùng lặp sẽ tạo nên bảng ảo VT8VT8​.

Bước 9: UNION / INTERSECT / EXCEPT – Phép Toán Tập Hợp

Nếu câu lệnh của bạn bao gồm nhiều truy vấn con được nối với nhau bởi các toán tử đại số tập hợp:

  • UNION / UNION ALL
  • INTERSECT
  • EXCEPT (hoặc MINUS trong Oracle)

Mỗi truy vấn thành phần sẽ độc lập trải qua từ Bước 1 đến Bước 8 để sản sinh ra bảng ảo của riêng nó. Sau đó, phép toán tập hợp mới được thực thi trên các bảng ảo này để gộp, lấy giao hoặc lấy hiệu tập hợp.

Lưu ý: UNION mặc định sẽ thực hiện khử trùng lặp tương đương với một bước DISTINCT ngầm, trong khi UNION ALL giữ nguyên toàn bộ và có chi phí tài nguyên rẻ hơn rất nhiều.

Bước 10: ORDER BY – Đưa Trật Tự Vào Thế Giới Vô Hướng

Theo lý thuyết tập hợp quan hệ của Edgar F. Codd, một quan hệ (Relation/Table) là một tập hợp các phần tử không có thứ tự (Unordered Set). Dữ liệu nằm trong bảng hay các bảng ảo từ Bước 1 đến Bước 8 đều không hề có bất kỳ cam kết nào về thứ tự trước sau, cho dù bạn thấy nó hiển thị tuần tự trên màn hình.

Mệnh đề ORDER BY chính là lệnh duy nhất có quyền năng biến một tập hợp quan hệ vô hướng thành một danh sách có thứ tự (Cursor/Ordered List).

Bảng ảo VT9VT9​ được hình thành.

Tại Sao ORDER BY Sử Dụng Được Alias Của SELECT?

Hãy nhìn lại luồng chạy: VT7(SELECT đa˜ gaˊn Alias)⟶VT8(DISTINCT)⟶VT9(ORDER BY)VT7​(SELECT đa˜ gaˊn Alias)⟶VT8​(DISTINCT)⟶VT9​(ORDER BY)

Vì bước ORDER BY chạy sau bước SELECT, nó hoàn toàn có thể nhìn thấy và sử dụng các bí danh được định nghĩa trong SELECT:

 

SELECT first_name, salary * 12 AS annual_salary
FROM employees
ORDER BY annual_salary DESC; -- Chạy hoàn toàn hợp lệ!

Bước 11: TOP / LIMIT / OFFSET – Chặn Đỉnh Và Phân Trang

Cuối cùng, khi dữ liệu đã được sắp xếp trật tự hoàn chỉnh trong VT9VT9​, mệnh đề cắt lát kết quả sẽ được gọi:

  • TOP (N) trong SQL Server.
  • LIMIT N OFFSET M trong PostgreSQL, MySQL, SQLite.
  • FETCH FIRST N ROWS ONLY trong chuẩn ANSI SQL hiện đại và Oracle.

Engine chỉ việc quét qua danh sách VT9VT9​, bỏ qua MM dòng đầu tiên (OFFSET), và lấy đúng NN dòng tiếp theo (LIMIT) để trả về cho Client Application.

Đây là bước cuối cùng, chấm dứt chu trình xử lý logic của một câu lệnh SQL.

Bài tiếp mình sẽ viết vài ví dụ thực tế sử dụng case này! Chúc các bạn thành công !
 

Cường không bd