CTE đệ quy: nó là gì và dùng để làm gì
Hôm nay tụi mình sẽ tiến thêm một bước nữa và nghịch phép thuật đệ quy. Nếu bạn từng code bằng ngôn ngữ hỗ trợ đệ quy (ví dụ như Python chẳng hạn), chắc cũng hiểu sơ sơ rồi. Nhưng đừng lo nếu nghe hơi bí ẩn — tụi mình sẽ giải thích siêu chi tiết luôn.
CTE đệ quy là công cụ cực mạnh để xử lý các cấu trúc dữ liệu phân cấp, dạng cây như sơ đồ tổ chức công ty, cây gia đình hoặc thư mục file.
Nói dễ hiểu, đây là những biểu thức có thể "gọi lại chính mình", để lần lượt duyệt và xử lý tất cả các tầng dữ liệu.
Các đặc điểm chính của CTE đệ quy:
- Nó dùng từ khóa
WITH RECURSIVE. - CTE đệ quy gồm hai phần:
- Truy vấn cơ bản: xác định điểm bắt đầu (hay "gốc") của đệ quy.
- Truy vấn đệ quy: xử lý phần dữ liệu còn lại, dựa trên kết quả bước trước đó.
Thuật toán của CTE đệ quy giống như bạn leo cầu thang vậy:
- Đầu tiên bạn đứng ở bậc đầu tiên (đó là truy vấn cơ bản).
- Sau đó bạn bước lên bậc tiếp theo, dùng kết quả của bậc trước (truy vấn đệ quy).
- Quá trình này lặp lại cho đến khi hết bậc (đạt điều kiện dừng).
Cú pháp của CTE đệ quy
Xem luôn ví dụ mẫu cho dễ hình dung nhé:
WITH RECURSIVE cte_name AS (
-- Truy vấn cơ bản
SELECT column1, column2
FROM table_name
WHERE condition_for_base_case
UNION ALL
-- Truy vấn đệ quy
SELECT column1, column2
FROM table_name
JOIN cte_name ON some_condition
WHERE stop_condition
)
SELECT * FROM cte_name;
Vai trò của UNION và UNION ALL trong CTE đệ quy
Mỗi CTE đệ quy bắt buộc phải dùng toán tử UNION hoặc UNION ALL giữa phần cơ bản và phần đệ quy.
| Toán tử | Làm gì |
|---|---|
UNION |
Nối kết quả hai truy vấn và loại bỏ các dòng trùng lặp |
UNION ALL |
Nối và giữ lại tất cả các dòng, kể cả trùng lặp |
Nên chọn toán tử nào: UNION hay UNION ALL?
Nếu bạn không chắc nên dùng cái nào — gần như luôn luôn chọn UNION ALL. Vì sao? Vì nó chạy nhanh hơn: chỉ đơn giản gộp kết quả lại, không kiểm tra trùng lặp. Nghĩa là — ít tính toán hơn, ít tốn tài nguyên hơn và kết quả ra nhanh hơn.
Đặc biệt quan trọng trong CTE đệ quy. Khi bạn xây dựng các phân cấp — ví dụ cây bình luận hoặc cấu trúc nhân viên trong công ty — UNION ALL gần như luôn cần thiết. Nếu dùng UNION thôi, database có thể nghĩ một số bước đã có rồi và “cắt” mất một phần kết quả. Như vậy sẽ làm hỏng logic duyệt cây.
Chỉ nên dùng UNION nếu bạn chắc chắn trùng lặp là có hại và cần loại bỏ. Nhưng nhớ: luôn là đánh đổi giữa sạch sẽ và tốc độ.
Ví dụ hai cách tiếp cận
-- UNION: loại bỏ trùng lặp
SELECT 'A'
UNION
SELECT 'A'; -- Kết quả: một dòng 'A'
-- UNION ALL: giữ lại trùng lặp
SELECT 'A'
UNION ALL
SELECT 'A'; -- Kết quả: hai dòng 'A'
Trong truy vấn đệ quy, an toàn nhất là luôn dùng UNION ALL để không mất các bước quan trọng khi duyệt cấu trúc.
Xét một bài toán điển hình: bạn có bảng nhân viên với các cột employee_id, manager_id và name. Cần xây dựng phân cấp, bắt đầu từ giám đốc — người không có sếp (tức là manager_id = NULL).
Giả sử bạn có bảng nhân viên: employees
| employee_id | name | manager_id |
|---|---|---|
| 1 | Eva Lang | NULL |
| 2 | Alex Lin | 1 |
| 3 | Maria Chi | 1 |
| 4 | Otto Mart | 2 |
| 5 | Anna Song | 2 |
| 6 | Eva Lang | 3 |
Bạn cần biết ai là cấp dưới của ai, và xác định cấp bậc của từng nhân viên trong cấu trúc. Rất tiện nếu bạn muốn hiển thị cây nhân viên trên giao diện hoặc chuẩn bị báo cáo về cấu trúc team.
WITH RECURSIVE employee_hierarchy AS (
-- Bắt đầu từ những người không có sếp
SELECT
employee_id,
name,
manager_id,
1 AS level
FROM employees
WHERE manager_id IS NULL
UNION ALL
-- Thêm cấp dưới và tăng level
SELECT
e.employee_id,
e.name,
e.manager_id,
eh.level + 1
FROM employees e
INNER JOIN employee_hierarchy eh
ON e.manager_id = eh.employee_id
)
SELECT * FROM employee_hierarchy;
Kết quả sẽ như sau:
| employee_id | name | manager_id | level |
|---|---|---|---|
| 1 | Eva Lang | NULL | 1 |
| 2 | Alex Lin | 1 | 2 |
| 3 | Maria Chi | 1 | 2 |
| 4 | Otto Mart | 2 | 3 |
| 5 | Anna Song | 2 | 3 |
| 6 | Eva Lang | 3 | 3 |
Truy vấn này cho thấy rõ cách "đi qua" cây nhân viên — từ giám đốc đến các cấp thấp nhất. Cột level rất tiện để format hoặc visualize cây.
Ví dụ: danh mục sản phẩm
Giờ tưởng tượng bạn làm việc với bảng danh mục sản phẩm, mỗi danh mục có thể có danh mục con, và danh mục con lại có danh mục con nữa. Làm sao dựng được cây danh mục?
Bảng categories
| category_id | name | parent_id |
|---|---|---|
| 1 | Điện tử | NULL |
| 2 | Máy tính | 1 |
| 3 | Điện thoại thông minh | 1 |
| 4 | Laptop | 2 |
| 5 | Thiết bị ngoại vi | 2 |
Truy vấn đệ quy:
WITH RECURSIVE category_tree AS (
-- Trường hợp cơ bản: tìm danh mục gốc
SELECT
category_id,
name,
parent_id,
1 AS depth
FROM categories
WHERE parent_id IS NULL
UNION ALL
-- Phần đệ quy: tìm danh mục con của các danh mục hiện tại
SELECT
c.category_id,
c.name,
c.parent_id,
ct.depth + 1
FROM categories c
INNER JOIN category_tree ct
ON c.parent_id = ct.category_id
)
SELECT * FROM category_tree;
Kết quả:
| category_id | name | parent_id | depth |
|---|---|---|---|
| 1 | Điện tử | NULL | 1 |
| 2 | Máy tính | 1 | 2 |
| 3 | Điện thoại thông minh | 1 | 2 |
| 4 | Laptop | 2 | 3 |
| 5 | Thiết bị ngoại vi | 2 | 3 |
Bây giờ bạn đã thấy cây danh mục với các cấp độ lồng nhau.
Tại sao CTE đệ quy lại xịn?
CTE đệ quy là một trong những công cụ mạnh mẽ và biểu đạt nhất của SQL. Thay vì logic lồng phức tạp, bạn chỉ cần mô tả bắt đầu từ đâu (trường hợp cơ bản), và cách đi tiếp (phần đệ quy) — còn lại PostgreSQL lo hết.
Thường thì các truy vấn này dùng để duyệt phân cấp: nhân viên, danh mục sản phẩm, thư mục trên ổ đĩa, đồ thị mạng xã hội. Rất dễ mở rộng: nếu bảng có thêm dữ liệu mới, truy vấn tự động lấy luôn. Rất tiện và scale tốt.
Nhưng cũng có vài điểm cần chú ý. Nhớ kiểm tra điều kiện dừng — nếu không truy vấn có thể chạy vòng lặp vô tận. Đừng quên index: với bảng lớn, truy vấn đệ quy không có index sẽ rất chậm. Và UNION ALL — gần như luôn là lựa chọn tốt nhất, đặc biệt với bài toán phân cấp, nếu không bạn có thể mất các bước đệ quy do loại trùng lặp.
Một CTE đệ quy được tối ưu tốt cho phép bạn diễn đạt logic nghiệp vụ phức tạp chỉ trong vài dòng — không cần thủ tục, vòng lặp hay code thêm. Đây là lúc SQL vừa đúng vừa đẹp.
Lỗi thường gặp khi dùng CTE đệ quy
- Đệ quy vô tận: nếu bạn không đặt điều kiện dừng đúng (
WHERE), truy vấn sẽ lặp mãi không dừng. - Dữ liệu dư thừa: dùng
UNION ALLkhông đúng sẽ sinh ra dòng trùng lặp. - Hiệu năng: truy vấn đệ quy có thể rất nặng với dữ liệu lớn. Index trên các cột chính (ví dụ
manager_id) sẽ giúp chạy nhanh hơn.
Khi nào không thể thiếu truy vấn đệ quy
Đôi khi bạn nghĩ truy vấn đệ quy chỉ là lý thuyết, nhưng thực tế nó xuất hiện rất nhiều trong dev hàng ngày. Ví dụ:
- để tạo báo cáo về cấu trúc công ty hoặc phân loại sản phẩm;
- để duyệt cây thư mục và lấy danh sách tất cả thư mục con;
- để phân tích đồ thị — quan hệ xã hội, tuyến đường, phụ thuộc giữa các task;
- để đơn giản hóa các mối quan hệ phức tạp giữa các đối tượng thành dạng dễ đọc.
Nếu bạn cần duyệt qua một cấu trúc mà cái này phụ thuộc cái kia — gần như chắc chắn sẽ cần WITH RECURSIVE.
GO TO FULL VERSION