Vấn đề: Truy vấn Dữ liệu Tự Tham chiếu
Đến một lúc nào đó, mọi backend developer đều gặp phải dữ liệu phân cấp. Bảng nhân viên mà mỗi dòng có manager_id trỏ đến một nhân viên khác. Bảng danh mục mà mỗi danh mục có parent_id. Luồng bình luận với các reply lồng nhau. Cây thư mục. Danh sách nguyên vật liệu.
Sai lầm kinh điển — và bản thân tôi cũng từng mắc — là cố xử lý việc này ở tầng ứng dụng. Lấy root, lấy con của nó, lấy con của con, lặp lại cho đến hết. Cách đó vẫn chạy được. Nhưng nó tra tấn database với N+1 truy vấn và trở thành ác mộng bảo trì ngay khi cấu trúc phân cấp vượt quá ba tầng.
Đó là lúc recursive Common Table Expression (CTE) phát huy tác dụng. Pattern này chỉ mất khoảng một tiếng để quen. Sau đó, bạn sẽ nghĩ đến nó mỗi khi gặp bài toán tương tự.
Khái niệm Cốt lõi: Recursive CTE Hoạt động như thế nào
CTE thông thường là một subquery được đặt tên — cách viết SQL gọn hơn bằng cách đặt nhãn cho subquery để tham chiếu lại sau. Recursive CTE tiến xa hơn: nó cho phép truy vấn tham chiếu chính nó, mở rộng từng tầng một cho đến khi không còn gì để mở rộng nữa.
Mọi recursive CTE đều có ba phần:
- Anchor member — truy vấn cơ sở trả về các dòng khởi đầu (node gốc)
- Recursive member — truy vấn join ngược lại với chính CTE, thêm một tầng mỗi lần lặp
- Điều kiện dừng — ngầm định: khi recursive member trả về không dòng nào, vòng lặp dừng lại
Cú pháp trông như sau:
WITH RECURSIVE cte_name AS (
-- Anchor: điểm bắt đầu
SELECT ...
UNION ALL
-- Recursive: join CTE với bảng nguồn
SELECT ... FROM source_table JOIN cte_name ON ...
)
SELECT * FROM cte_name;
RECURSIVE là bắt buộc trong PostgreSQL. MySQL 8.0+ dùng cú pháp tương tự — hỗ trợ recursive CTE có từ MySQL 8.0, nên các phiên bản cũ hơn sẽ không chạy được.
Một Điều Cần Lưu ý
Dữ liệu bẩn có thể tạo ra vòng lặp: A là cha của B, B là cha của A. Recursive CTE sẽ lặp vô tận trong trường hợp đó. PostgreSQL cho phép thêm cột đếm độ sâu và giới hạn tường minh. MySQL có cte_max_recursion_depth (mặc định: 1000 lần lặp) như một lưới an toàn ở tầng hệ thống.
Thực hành
Tạo Cấu trúc Phân cấp Mẫu
Điểm khởi đầu kinh điển: bảng nhân viên tự tham chiếu qua manager_id. Schema này hoạt động giống nhau trong PostgreSQL và MySQL 8.0+.
CREATE TABLE employees (
id INT PRIMARY KEY,
name VARCHAR(100),
role VARCHAR(100),
manager_id INT REFERENCES employees(id)
);
INSERT INTO employees VALUES
(1, 'Alice', 'CEO', NULL),
(2, 'Bob', 'VP Eng', 1),
(3, 'Carol', 'VP Sales', 1),
(4, 'Dave', 'Lead Dev', 2),
(5, 'Eve', 'Developer', 4),
(6, 'Frank', 'Developer', 4),
(7, 'Grace', 'Sales Rep', 3);
Truy vấn 1: Sơ đồ Tổ chức Đầy đủ, từ Trên xuống Dưới
Truy vấn này duyệt toàn bộ cây bắt đầu từ CEO và trả về mọi nhân viên kèm theo tầng độ sâu của họ:
WITH RECURSIVE org_chart AS (
-- Anchor: node gốc (không có manager)
SELECT
id,
name,
role,
manager_id,
0 AS depth,
name::TEXT AS path
FROM employees
WHERE manager_id IS NULL
UNION ALL
-- Recursive: join từng nhân viên với manager của họ
SELECT
e.id,
e.name,
e.role,
e.manager_id,
oc.depth + 1,
oc.path || ' > ' || e.name
FROM employees e
JOIN org_chart oc ON e.manager_id = oc.id
)
SELECT
repeat(' ', depth) || name AS indented_name,
role,
depth,
path
FROM org_chart
ORDER BY path;
Cột path tạo ra chuỗi breadcrumb dạng Alice > Bob > Dave > Eve. Hữu ích cho cả việc debug lẫn hiển thị — dán thẳng vào UI là xong.
Truy vấn 2: Tìm Tất cả Cấp dưới của Một Người Cụ thể
Giả sử bạn cần tất cả những người báo cáo cho Bob — trực tiếp hoặc gián tiếp — để áp dụng thay đổi quyền hoặc tính số đầu người trong nhóm.
WITH RECURSIVE subordinates AS (
-- Anchor: bắt đầu từ Bob (id = 2)
SELECT id, name, role, manager_id
FROM employees
WHERE id = 2
UNION ALL
SELECT e.id, e.name, e.role, e.manager_id
FROM employees e
JOIN subordinates s ON e.manager_id = s.id
)
SELECT * FROM subordinates;
Kết quả: Bob, Dave, Eve, Frank. Bốn dòng. Không cần vòng lặp ở tầng ứng dụng, không N+1.
Truy vấn 3: Đi Ngược Lên Cây (Tìm Chuỗi Quản lý)
Đôi khi bạn cần chiều ngược lại. Cho trước Eve, tìm toàn bộ chuỗi quản lý của cô ấy lên đến CEO — hữu ích cho kiểm tra phân quyền hoặc audit trail.
WITH RECURSIVE chain_of_command AS (
-- Anchor: bắt đầu từ Eve (id = 5)
SELECT id, name, role, manager_id
FROM employees
WHERE id = 5
UNION ALL
SELECT e.id, e.name, e.role, e.manager_id
FROM employees e
JOIN chain_of_command c ON e.id = c.manager_id
)
SELECT * FROM chain_of_command;
Trả về Eve → Dave → Bob → Alice. Lưu ý join đệ quy bị đảo ngược: thay vì khớp con (e.manager_id = oc.id), ta khớp cha (e.id = c.manager_id). Chỉ một sự đảo ngược đó là toàn bộ thủ thuật.
Mẹo Thực tế: Ngăn Vòng Lặp Vô hạn
Thêm bộ đếm độ sâu và giới hạn tường minh nếu dữ liệu của bạn có thể có vòng lặp:
WITH RECURSIVE safe_tree AS (
SELECT id, name, manager_id, 0 AS depth
FROM employees
WHERE manager_id IS NULL
UNION ALL
SELECT e.id, e.name, e.manager_id, st.depth + 1
FROM employees e
JOIN safe_tree st ON e.manager_id = st.id
WHERE st.depth < 10 -- Giới hạn an toàn: dừng ở độ sâu 10
)
SELECT * FROM safe_tree;
Pattern Thực tế: Cây Danh mục
Cây danh mục thương mại điện tử liên tục gặp bài toán này. Bảng categories với parent_id có cấu trúc giống hệt ví dụ nhân viên. Lấy đường dẫn đầy đủ cho breadcrumb SEO chỉ mất khoảng hai phút với recursive CTE:
WITH RECURSIVE category_path AS (
SELECT id, name, parent_id, name::TEXT AS full_path
FROM categories
WHERE parent_id IS NULL
UNION ALL
SELECT c.id, c.name, c.parent_id,
cp.full_path || ' / ' || c.name
FROM categories c
JOIN category_path cp ON c.parent_id = cp.id
)
SELECT id, full_path FROM category_path ORDER BY full_path;
Ghi chú về Chuẩn bị Dữ liệu
Khi tôi đang làm prototype các truy vấn này với dữ liệu mẫu từ file CSV xuất ra — chẳng hạn, danh mục xuất từ một nền tảng thương mại điện tử — tôi thường cần chuyển đổi CSV đó thành câu lệnh SQL INSERT hoặc JSON có cấu trúc trước khi nạp vào database test. Tôi dùng toolcraft.app/vi/tools/data/csv-to-json cho bước chuyển CSV sang JSON. Công cụ này chạy hoàn toàn trên trình duyệt, nên dữ liệu không rời khỏi máy bạn — rất tiện khi file xuất chứa dữ liệu khách hàng hoặc sản phẩm mà bạn không muốn gửi lên dịch vụ bên thứ ba.
Mẹo Tối ưu Hiệu năng
Recursive CTE có thể tốn kém trên các bảng lớn. Một số điều giúp ích trong thực tế:
- Đánh index cột khóa ngoại. Trong ví dụ này,
manager_idcần có index. PostgreSQL không tự động đánh index khóa ngoại — bạn phải thêm thủ công bằngCREATE INDEX. Với bảng hơn 100 nghìn dòng, chỉ riêng việc này có thể giảm thời gian truy vấn từ vài giây xuống còn vài mili giây. - Chỉ SELECT những gì cần thiết bên trong CTE. Recursive member chạy một lần mỗi tầng. Kéo vào các cột văn bản rộng mà bạn không dùng đồng nghĩa với việc đọc lại dữ liệu đó qua mỗi lần lặp. Chỉ SELECT tối thiểu bên trong CTE; join thêm cột ở truy vấn ngoài.
- Dùng
UNION ALL, không dùngUNION.UNIONloại trùng lặp, thêm bước sort. Trừ khi bạn thực sự cần loại trùng lặp, luôn dùngUNION ALLbên trong recursive CTE. - Kiểm tra
EXPLAIN ANALYZE. Query planner của PostgreSQL không phải lúc nào cũng tối ưu recursive CTE tích cực như các truy vấn thông thường. ChạyEXPLAIN ANALYZEtrên toàn bộ câu lệnh — nó chỉ rõ thời gian đang tốn ở đâu.
Khi nào Nên Dùng — và Khi nào Không Nên
Recursive CTE phù hợp khi:
- Độ sâu phân cấp chưa biết trước hoặc thay đổi theo từng dòng
- Bạn cần toàn bộ cây hoặc phần lớn cây
- Bạn muốn một lần round-trip đến database thay vì N+1 truy vấn
Phân cấp cố định độ sâu — luôn luôn đúng hai hoặc ba tầng theo thiết kế — thì dùng self-join thông thường đơn giản hơn. Recursive CTE tỏa sáng ngay khi độ sâu trở nên động.
Một lựa chọn thay thế đáng biết: extension ltree của PostgreSQL lưu trữ materialized path dưới dạng kiểu dữ liệu native với các toán tử chuyên biệt nhanh. Trên các cây rất sâu hoặc được truy vấn rất thường xuyên, ltree có thể vượt trội đáng kể so với recursive CTE. Đánh đổi là logic INSERT và UPDATE trở nên phức tạp hơn. Recursive CTE không cần thay đổi schema, điều đó khiến nó là điểm khởi đầu đúng đắn cho hầu hết mọi dự án — chỉ chuyển sang ltree khi bạn đã đo được vấn đề hiệu năng thực sự.
Tóm tắt Pattern
Anchor member, recursive member, điều kiện dừng. Chỉ vậy thôi. Pattern này cảm giác lạ lẫm lần đầu tiên bạn viết. Đến truy vấn thứ ba, nó đọc tự nhiên như thở.
Bốn thói quen cần xây dựng ngay: đánh index cột cha/khóa ngoại, dùng UNION ALL thay vì UNION, thêm giới hạn độ sâu với dữ liệu bạn không kiểm soát hoàn toàn, và chạy EXPLAIN ANALYZE khi hiệu năng là vấn đề. Với những thứ đó, bạn sẽ xử lý được hầu hết bài toán dữ liệu phân cấp trong một câu lệnh SQL duy nhất — không cần vòng lặp ở tầng ứng dụng.

