SQL & Database — Thực chiến PostgreSQL/Recursive CTE — duyệt cây comment, breadcrumb, và hierarchy trong SQL
~22 phútAdvanced query patterns lượt xem

Recursive CTE — duyệt cây comment, breadcrumb, và hierarchy trong SQL

WITH RECURSIVE trong PostgreSQL: anchor, recursive part, termination, cycle prevention với path array và CYCLE clause, pitfall infinite loop, và khi nào nên dùng ltree hay closure table.

TaskFlow lưu comment của mỗi task trong bảng comments. Một comment có thể reply vào comment khác — tạo ra thread lồng nhau nhiều cấp. Để lưu quan hệ parent–child đó, bảng comments được mở rộng với một cột parent_id tự tham chiếu:

-- Schema goc cua comments (TaskFlow canonical)
-- id BIGSERIAL, task_id, user_id, body, created_at

-- Extend them parent_id de ho tro thread (giai thich trong bai)
ALTER TABLE comments ADD COLUMN parent_id BIGINT REFERENCES comments(id);
-- NULL = root comment (khong phai reply)
-- parent_id = X = reply cua comment X

Bây giờ yêu cầu thực tế xuất hiện: "Lấy toàn bộ replies của comment #100, bao gồm replies của replies." Hay ngược lại: "Trace từ comment #250 lên root để render breadcrumb." Đây là bài toán traversal trên cây — không thể giải bằng JOIN đơn hay subquery tĩnh vì độ sâu không biết trước. SQL có câu trả lời: WITH RECURSIVE.

1. Analogy — Cây gia đình

Hình dung bạn đang vẽ cây gia phả. Bạn là điểm xuất phát (anchor). Để tìm mọi tổ tiên, bạn làm từng bước:

BướcCây gia phảWITH RECURSIVE
Khởi đầuBắt đầu từ chính bạnAnchor: SELECT row gốc, không tham chiếu CTE
Mở rộngTìm cha/mẹ của những người vòng trướcRecursive part: JOIN CTE với bảng gốc
DừngĐến tổ tiên không có cha/mẹ ghi chépTermination: recursive part trả 0 row
Phòng lỗiTránh vòng "anh là cha của em, em là cha của anh" do typo dữ liệuCycle prevention: track đường đã đi

Mỗi vòng lặp chỉ nhìn thấy kết quả của vòng trước — không phải toàn bộ CTE tích lũy. PostgreSQL UNION ALL toàn bộ lại sau.

💡 Cách nhớ

Anchor = base case (bản thân bạn). Recursive part = "cha mẹ của những người tôi đã biết". Termination xảy ra tự nhiên khi không còn ai mới để thêm. Không cần STOP hay BREAK — SQL tự dừng khi recursive part trả về 0 row.

2. Cú pháp WITH RECURSIVE — anatomy

WITH RECURSIVE cte_name AS (
  -- Anchor: base case, khong duoc reference cte_name
  SELECT col1, col2, ...
  FROM source_table
  WHERE <dieu_kien_bat_dau>

  UNION ALL

  -- Recursive part: reference cte_name (ket qua vong truoc)
  SELECT s.col1, s.col2, ...
  FROM source_table s
  JOIN cte_name c ON <dieu_kien_lien_ket>
  -- WHERE <termination condition nham tranh infinite loop>
)
SELECT * FROM cte_name;

Cơ chế thực thi (PostgreSQL):

  1. Eval anchor → kết quả ban đầu gọi là working table (cte_name = anchor result).
  2. Eval recursive part, thay cte_name bằng working table hiện tại → new rows.
  3. UNION ALL append new rows vào result set tích lũy; working table = new rows.
  4. Lặp lại bước 2–3 đến khi recursive part trả về 0 row → dừng.
  5. Trả toàn bộ result set tích lũy về SELECT * FROM cte_name.

Lưu ý quan trọng:

  • Phải dùng UNION ALL, không phải UNION. UNION dedup mỗi vòng — tốn kém và có thể kết thúc sớm sai logic.
  • Cột trong recursive part phải khớp kiểu với anchor (số lượng và thứ tự cột).
  • WITH RECURSIVE khai báo intention — PostgreSQL sẽ không báo lỗi nếu CTE thực sự không recursive; chỉ cần có từ khóa khi bạn định dùng self-reference.

3. Demo 1 — comment thread tree

Lấy toàn bộ comment trong thread của comment #100, kèm độ sâu để render indent:

-- Recursive: lay full thread cua root comment id = 100
WITH RECURSIVE thread AS (
  -- Anchor: comment goc
  SELECT id, parent_id, body, user_id, created_at, 0 AS depth
  FROM comments
  WHERE id = 100

  UNION ALL

  -- Recursive: con cua comment vong truoc
  SELECT c.id, c.parent_id, c.body, c.user_id, c.created_at, t.depth + 1
  FROM comments c
  JOIN thread t ON c.parent_id = t.id
)
SELECT
  depth,
  repeat('  ', depth) || body AS indented_body,
  created_at
FROM thread
ORDER BY depth, created_at;

-- depth | indented_body                              | created_at
--   0   | Root comment: "Deploy API thi bi loi 500?" | 2024-01-10 09:00
--   1   |   "Loi o middleware auth, toi se fix"      | 2024-01-10 09:15
--   1   |   "Co log khong, cho toi xem"              | 2024-01-10 09:20
--   2   |     "Day: [stack trace...]"                 | 2024-01-10 09:22
--   2   |     "Da fix, deploy lai di"                 | 2024-01-10 09:45

Vòng 1 (anchor): lấy comment id=100, depth=0. Vòng 2 (recursive): tìm mọi comment có parent_id = 100, gán depth=1. Vòng 3: tìm mọi comment có parent_id trong tập id của depth=1, gán depth=2. Tiếp tục đến khi không còn reply nào mới.

4. Demo 2 — breadcrumb path từ leaf lên root

Hướng ngược lại: từ một comment lá, trace lên root để render breadcrumb Root > Level1 > Level2 > Leaf:

-- Tu comment id = 250, trace nguoc len root
WITH RECURSIVE breadcrumb AS (
  -- Anchor: comment la (bat dau tu day)
  SELECT id, parent_id, body, 0 AS level
  FROM comments
  WHERE id = 250

  UNION ALL

  -- Recursive: cha cua comment vong truoc
  SELECT c.id, c.parent_id, c.body, b.level + 1
  FROM comments c
  JOIN breadcrumb b ON c.id = b.parent_id
  -- Dieu kien noi: c.id = b.parent_id (nguoc chieu voi demo 1)
)
SELECT level, body
FROM breadcrumb
ORDER BY level DESC;
-- level=N la root (to tiên xa nhat tim duoc)
-- level=0 la comment ban dau (id=250)

-- Render breadcrumb: dao nguoc thu tu (ORDER BY level DESC)
-- "Deploy discussion" > "Auth bug" > "Log request" > "Here is the trace"

Demo 1 đi xuống (tìm con): JOIN thread t ON c.parent_id = t.id. Demo 2 đi lên (tìm cha): JOIN breadcrumb b ON c.id = b.parent_id. Chỉ thay đổi điều kiện JOIN — cùng một pattern.

5. Cycle prevention — path array và CYCLE clause

Dữ liệu sai (typo, bug import) có thể tạo cycle: comment A là cha của B, B là cha của A. Không có cycle prevention, recursive CTE sẽ chạy mãi cho đến khi hết memory (PostgreSQL không có depth cap mặc định).

Cách 1: Track path array (mọi version PG):

WITH RECURSIVE thread AS (
  SELECT id, parent_id, body,
         ARRAY[id]  AS path,    -- path = danh sach id da di qua
         false      AS is_cycle
  FROM comments
  WHERE id = 100

  UNION ALL

  SELECT c.id, c.parent_id, c.body,
         t.path || c.id,              -- them id moi vao path
         c.id = ANY(t.path)           -- TRUE neu id nay da xuat hien
  FROM comments c
  JOIN thread t ON c.parent_id = t.id
  WHERE NOT t.is_cycle  -- dung lai khi gap cycle, khong expand tiep
)
SELECT id, body, path, is_cycle
FROM thread
WHERE NOT is_cycle;  -- loc bo row cycle neu can

Cách 2: CYCLE clause — SQL standard, PG 14+:

WITH RECURSIVE thread AS (
  SELECT id, parent_id, body
  FROM comments
  WHERE id = 100

  UNION ALL

  SELECT c.id, c.parent_id, c.body
  FROM comments c
  JOIN thread t ON c.parent_id = t.id
)
CYCLE id SET is_cycle USING path
-- is_cycle: boolean, TRUE khi id da xuat hien trong path
-- path: array tracking duong di (tu dong quan ly boi PG)
SELECT * FROM thread WHERE NOT is_cycle;

CYCLE id SET is_cycle USING path yêu cầu PostgreSQL tự động track cột id để phát hiện cycle — gọn hơn cách 1 và đúng SQL standard. Nếu đang dùng PG 14+, ưu tiên cách này.

6. Termination — depth limit để tránh infinite loop

Ngay cả không có cycle trong dữ liệu, tree rất sâu (hoặc dữ liệu bất ngờ) có thể khiến recursive CTE chạy lâu. Thêm điều kiện depth limit là safety cap nên có ở production:

WITH RECURSIVE thread AS (
  SELECT id, parent_id, body, 0 AS depth
  FROM comments
  WHERE id = 100

  UNION ALL

  SELECT c.id, c.parent_id, c.body, t.depth + 1
  FROM comments c
  JOIN thread t ON c.parent_id = t.id
  WHERE t.depth < 50  -- safety cap: khong di sau qua 50 cap
  -- Khi depth = 50, dieu kien WHERE fail, recursive part tra 0 row -> dung
)
SELECT * FROM thread;

WHERE t.depth < 50 nằm trong recursive part — khi depth của vòng trước đã là 50, điều kiện fail, không có row mới, CTE tự dừng. Nếu đặt ở query cuối (SELECT * FROM thread WHERE depth < 50) thì vẫn chạy vô tận — chỉ filter kết quả, không dừng recursion.

7. Performance — khi nào nên dùng alternative

Recursive CTE hoạt động tốt với cây nông (dưới 10–15 cấp). Với tree sâu hơn, mỗi vòng là một JOIN giữa recursive part và toàn bộ CTE tích lũy — chi phí tăng tuyến tính theo độ sâu.

PatternTốc độ đọcTốc độ ghiĐộ phức tạp
Recursive CTE (adjacency list)OK cho tree nông (dưới 10 cấp)Nhanh, không schema thêmThấp — chỉ cần parent_id
Materialized path (lưu /1/5/23/ trong cột)Nhanh (LIKE prefix query)Phải update path khi move nodeTrung bình
Closure table (pre-compute mọi cặp ancestor–descendant)Nhanh nhất cho readChậm (INSERT N row per ancestor khi thêm node)Cao
ltree extension (PG built-in label tree)Nhanh, GIST/GiST indexỔnTrung bình, PG-specific

Nguyên tắc thực tế: recursive CTE đủ dùng cho 95% trường hợp trong TaskFlow (comment thread thường không quá 10 cấp, query không cực kỳ hot). Cân nhắc chuyển sang ltree hoặc closure table khi:

  • Tree vượt 50 cấp thường xuyên.
  • Endpoint query tree chạy hơn 1000 lần/giây.
  • Cần query tất cả descendant của nhiều node cùng lúc hiệu quả.

Với graph workload thực sự (shortest path, transitive closure trên đồ thị tổng quát), recursive SQL có thể làm được nhưng không tối ưu — đây là domain của graph database (Neo4j, Memgraph) hoặc PG extension age (Apache AGE).

8. Pitfall

Pitfall 1 — Quên termination condition → infinite recursion. PostgreSQL không có depth cap mặc định. Nếu recursive part luôn trả về ít nhất 1 row (ví dụ: cycle trong data hoặc join condition sai), CTE chạy đến khi hết memory và bị kill. Luôn kết hợp WHERE t.depth < N hoặc cycle prevention trong production.

Pitfall 2 — Dùng UNION thay UNION ALL. UNION dedup kết quả mỗi vòng — chi phí sort/hash rất cao với tree lớn. Tệ hơn, nó có thể terminate sớm sai nếu các row hợp lệ bị coi là "trùng lặp". Luôn dùng UNION ALL trong recursive CTE, trừ khi bạn cần dedup có chủ ý và hiểu hệ quả.

Pitfall 3 — Performance tệ với deep tree (50+ cấp). Mỗi vòng recursive = 1 JOIN giữa source table và toàn bộ CTE tích lũy. Với tree 100 cấp và mỗi cấp nhiều node, đây là 100 JOIN đắt tiền. Alternative: ltree extension (GIST index, query lquery) hoặc closure table pre-computed. Xem mục 7 để chọn đúng pattern.

-- WRONG: WHERE o cuoi chi filter ket qua, khong dung recursion
WITH RECURSIVE thread AS (
  SELECT id, parent_id, body, 0 AS depth FROM comments WHERE id = 100
  UNION ALL
  SELECT c.id, c.parent_id, c.body, t.depth + 1
  FROM comments c JOIN thread t ON c.parent_id = t.id
  -- Khong co WHERE depth < N trong recursive part -> chay vo tan neu co cycle
)
SELECT * FROM thread WHERE depth < 50;  -- chi filter output, khong dung recursion!

-- CORRECT: WHERE trong recursive part
WITH RECURSIVE thread AS (
  SELECT id, parent_id, body, 0 AS depth FROM comments WHERE id = 100
  UNION ALL
  SELECT c.id, c.parent_id, c.body, t.depth + 1
  FROM comments c JOIN thread t ON c.parent_id = t.id
  WHERE t.depth < 50  -- nam TRONG recursive part -> thuc su dung
)
SELECT * FROM thread;

9. Applied — TaskFlow comment thread API

Endpoint GET /threads/:id cần trả toàn bộ comment trong một thread, kèm tên tác giả, sắp xếp theo thứ tự render tự nhiên (depth-first, chronological trong mỗi cấp):

-- Endpoint: GET /threads/100
-- $1 = root comment id (bind parameter)
WITH RECURSIVE thread AS (
  -- Anchor: root comment
  SELECT id, parent_id, user_id, body, created_at, 0 AS depth
  FROM comments
  WHERE id = $1

  UNION ALL

  -- Recursive: lay con cua comment vong truoc
  SELECT c.id, c.parent_id, c.user_id, c.body, c.created_at, t.depth + 1
  FROM comments c
  JOIN thread t ON c.parent_id = t.id
  WHERE t.depth < 50  -- safety cap cho production
)
SELECT
  t.id,
  t.parent_id,
  t.body,
  u.name  AS author,
  t.depth,
  t.created_at
FROM thread t
LEFT JOIN users u ON u.id = t.user_id
ORDER BY t.depth, t.created_at;

Truy vấn này:

  • Lấy đúng 1 thread (anchor lọc theo root id = $1).
  • Kết hợp với users bên ngoài CTE — không cần JOIN users bên trong recursive part (tốn kém hơn).
  • LEFT JOIN để không mất comment nếu user đã bị xoá.
  • ORDER BY depth, created_at giữ thứ tự đọc tự nhiên.
  • depth < 50 bảo vệ production khỏi data bất thường.

Render breadcrumb (đường dẫn từ comment lá lên root) dùng Demo 2 ở mục 4, thay $1 bằng id của comment lá cần trace.

10. Deep Dive

📚 Deep Dive — tài liệu gốc

11. Tóm tắt

  • WITH RECURSIVE = anchor + recursive part + implicit termination: anchor cung cấp dữ liệu ban đầu, recursive part mở rộng từ kết quả vòng trước, tự dừng khi recursive part trả 0 row.
  • parent_id self-reference là pattern đơn giản nhất để lưu tree — recursive CTE là cách tự nhiên để traversal nó mà không cần schema thêm.
  • Đi xuống (subtree): JOIN thread t ON c.parent_id = t.id. Đi lên (breadcrumb): JOIN breadcrumb b ON c.id = b.parent_id. Chỉ đổi chiều JOIN.
  • Cycle prevention: path array (ARRAY[id] + NOT (id = ANY(path))) cho mọi version PG; CYCLE id SET is_cycle USING path cho PG 14+ gọn hơn.
  • Safety cap bắt buộc trong production: WHERE t.depth < N phải nằm trong recursive part, không phải ở query ngoài — chỉ trong recursive part mới thực sự dừng CTE.
  • UNION ALL không phải UNION: UNION dedup mỗi vòng, chi phí cao và có thể terminate sai. Luôn dùng UNION ALL trừ khi cố ý dedup.
  • Hiệu năng: ổn với tree dưới 10–15 cấp. Tree sâu hơn hoặc query rất hot → cân nhắc ltree (GIST index) hoặc closure table. Graph workload nặng → graph DB hoặc PG extension age.

12. Tự kiểm tra

Tự kiểm tra
Q1
Giải thích cơ chế thực thi của `WITH RECURSIVE`: anchor, working table, recursive part, và điều kiện termination tự nhiên. Tại sao phải dùng `UNION ALL` thay vì `UNION`?

PostgreSQL thực thi recursive CTE theo vòng lặp: (1) Eval anchor, kết quả thành working table ban đầu. (2) Eval recursive part với CTE name = working table hiện tại → new rows. (3) UNION ALL append new rows vào result set tích lũy; working table = new rows. (4) Lặp lại bước 2–3 đến khi new rows = 0 row → dừng.

Tại sao UNION ALL: UNION dedup kết quả mỗi vòng bằng sort hoặc hash — chi phí O(n log n) hoặc O(n) mỗi vòng, tốn kém với tree lớn. Tệ hơn, nếu dữ liệu hợp lệ có duplicate value trên cột nào đó (ví dụ hai comment cùng body), UNION loại bỏ nhầm → terminate sớm, mất dữ liệu. Recursive CTE cần UNION ALL để giữ nguyên mọi row từ mỗi vòng.

Q2
Viết skeleton của recursive CTE để lấy toàn bộ sub-project của project id=5, biết bảng `projects` có cột `parent_project_id`. Hướng đi nào của JOIN (xuống/lên)?

Đi xuống (tìm con/cháu) — tương tự Demo 1:

WITH RECURSIVE sub_projects AS (
SELECT id, parent_project_id, name, 0 AS depth
FROM projects WHERE id = 5
UNION ALL
SELECT p.id, p.parent_project_id, p.name, s.depth + 1
FROM projects p
JOIN sub_projects s ON p.parent_project_id = s.id
WHERE s.depth < 20
)
SELECT * FROM sub_projects ORDER BY depth, name;

Điều kiện JOIN: p.parent_project_id = s.id — lấy những project có parent là node vừa tìm được (đi xuống). Nếu muốn tìm ancestor (đi lên): JOIN sub_projects s ON p.id = s.parent_project_id.

Q3
Tại sao `WHERE t.depth < 50` phải nằm trong recursive part, không phải trong `SELECT * FROM thread WHERE depth < 50` ở cuối? Phân biệt tác dụng của hai vị trí này.

Trong recursive part (WHERE t.depth < 50 bên trong CTE): điều kiện này được áp dụng khi PostgreSQL đang xây dựng working table mỗi vòng. Khi depth của vòng trước đạt 50, điều kiện fail → recursive part trả 0 row → CTE dừng. Đây là termination condition thực sự.

Ở query ngoài (SELECT * FROM thread WHERE depth < 50): CTE đã chạy xong toàn bộ (có thể vô tận nếu có cycle), query ngoài chỉ filter kết quả cuối. Nếu CTE chạy mãi, query này không bao giờ được thực thi. Đây chỉ là output filter, không dừng recursion.

Q4
So sánh hai cách cycle prevention: path array thủ công và `CYCLE` clause (PG 14+). Khi nào dùng cái nào?

Path array thủ công: thêm cột ARRAY[id] AS path ở anchor, mỗi vòng append t.path || c.id, check c.id = ANY(t.path). Hoạt động với mọi version PostgreSQL nhưng verbose hơn và phải tự quản lý cột path và is_cycle.

CYCLE id SET is_cycle USING path (PG 14+): PostgreSQL tự động track, cú pháp ngắn gọn, đúng SQL standard (ISO/IEC 9075). Ít lỗi hơn vì không phải tự viết logic detect.

Khi nào dùng: nếu deploy trên PG 14+, dùng CYCLE clause — ngắn hơn, chuẩn hơn, ít lỗi hơn. Nếu cần tương thích PG 13 trở xuống hoặc cần inspect đường đi (path array) vì lý do debug/audit, dùng path array thủ công.

Q5
Recursive CTE trên cây 100 cấp có vấn đề hiệu năng gì? Liệt kê 3 alternative và nêu trade-off của từng cái so với recursive CTE.

Vấn đề recursive CTE với tree sâu: mỗi vòng = 1 JOIN giữa source table và working table (CTE tích lũy). Với 100 cấp và N node mỗi cấp, tổng công việc ~ O(depth × N²) trong trường hợp xấu. Chi phí tăng tuyến tính theo depth.

1. Materialized path (lưu /1/5/23/250/ trong cột): đọc nhanh bằng LIKE '/1/5/%', có thể index. Trade-off: phải cập nhật path của toàn bộ subtree khi move node — write đắt hơn.

2. Closure table (bảng riêng lưu mọi cặp ancestor–descendant): đọc nhanh nhất, 1 query đơn giản. Trade-off: khi INSERT node mới phải INSERT N row (1 per ancestor) — write overhead tăng theo depth. Schema phức tạp hơn.

3. ltree extension (PG): kiểu dữ liệu label path native, GIST index cho operators @>, <@, ~. Đọc rất nhanh. Trade-off: PG-specific (không portable), phải học cú pháp lquery/ltxtquery mới.

Q6
Trong TaskFlow, comment thread thường không quá 5–7 cấp. Vậy có cần lo về hiệu năng recursive CTE không? Điều kiện nào mới cần cân nhắc chuyển sang ltree hoặc closure table?

Với 5–7 cấp: recursive CTE chạy tối đa 7 vòng, mỗi vòng JOIN nhỏ. Hiệu năng hoàn toàn ổn — đây là 95% trường hợp thực tế của ứng dụng comment. Không cần tối ưu sớm.

Khi nào cân nhắc alternative:

  • Tree thường xuyên vượt 50 cấp (ví dụ: org chart doanh nghiệp lớn, taxonomy nhiều cấp).
  • Endpoint query thread chạy hơn 1000 lần/giây và P95 latency tăng (đo bằng profiling thực tế, không đoán).
  • Cần query "tất cả descendant của nhiều node cùng lúc" hiệu quả — closure table hoặc ltree phù hợp hơn.
  • Workload là graph thực sự (shortest path, PageRank) — cần graph DB hoặc extension age.

Nguyên tắc: measure first, optimize second. Recursive CTE với depth limit là điểm xuất phát đúng cho TaskFlow comment thread.

Bài tiếp theo: Window functions nâng cao — frame clause, NTILE, và gap-and-island

Bài này có giúp bạn hiểu bản chất không?