SQL 標準缺乏原生遞迴查詢(直到 SQL:1999 引入 WITH RECURSIVE,MySQL 8.0、PostgreSQL、SQL Server 才支援),因此歷史上衍生出多種「以空間換時間」或「以寫入換讀取」的設計模式。本文對比兩大主流流派:
並補充現代資料庫的 CTE 遞迴 與 Closure Table (關係閉包表) 方案。
CREATE TABLE `category_adj` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`parent_id` INT UNSIGNED NOT NULL DEFAULT 0, -- 0 表根節點
`name` VARCHAR(100) NOT NULL,
`sort_order` INT NOT NULL DEFAULT 0,
PRIMARY KEY (`id`),
KEY `idx_parent` (`parent_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
xdebug.max_nesting_level)會失敗-- 取得節點 5 的所有後代(含自身)
WITH RECURSIVE `tree` AS (
SELECT `id`, `parent_id`, `name`, 0 AS `depth`
FROM `category_adj` WHERE `id` = 5
UNION ALL
SELECT c.`id`, c.`parent_id`, c.`name`, t.`depth` + 1
FROM `category_adj` c
JOIN `tree` t ON c.`parent_id` = t.`id`
WHERE t.`depth` < 20 -- 防無限遞迴
)
SELECT * FROM `tree` ORDER BY `depth`, `sort_order`;
單次查詢完成,效能優於應用層遞迴,且可輕鬆取得 depth 用於縮排顯示。
<?php
function renderTree(PDO $pdo, int $parentId = 0, int $depth = 0): string {
$stmt = $pdo->prepare("SELECT id, name FROM category_adj WHERE parent_id = ? ORDER BY sort_order");
$stmt->execute([$parentId]);
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
if (!$rows) return '';
$html = str_repeat(' ', $depth) . "<ul>\n";
foreach ($rows as $row) {
$html .= str_repeat(' ', $depth + 1) . "<li>{$row['name']}\n";
$html .= renderTree($pdo, $row['id'], $depth + 1);
$html .= str_repeat(' ', $depth + 1) . "</li>\n";
}
$html .= str_repeat(' ', $depth) . "</ul>\n";
return $html;
}
echo renderTree($pdo);
CREATE TABLE `category_path` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`parent_id` INT UNSIGNED NOT NULL DEFAULT 0,
`name` VARCHAR(100) NOT NULL,
`path` VARCHAR(1000) NOT NULL, -- 如 '/1/5/12/' 根到自身含首尾分隔符
`depth` TINYINT UNSIGNED NOT NULL DEFAULT 0,
`sort_order` INT NOT NULL DEFAULT 0,
PRIMARY KEY (`id`),
KEY `idx_path` (`path`),
KEY `idx_parent` (`parent_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
| 操作 | SQL 範例 | 複雜度 |
|---|---|---|
| 插入子節點 | INSERT ... SET path = CONCAT((SELECT path FROM t WHERE id=5), '12/'), depth = (SELECT depth+1 FROM t WHERE id=5) | O(1) |
| 查詢所有後代 | SELECT * FROM t WHERE path LIKE '/1/5/12/%' | O(log n) 索引範圍掃描 |
| 查詢所有祖先 | SELECT * FROM t WHERE '/1/5/12/34/' LIKE CONCAT(path, '%') | O(depth) |
| 移動子樹 | UPDATE t SET path = REPLACE(path, '/old/', '/new/') WHERE path LIKE '/old/%' | O(subtree size) |
| 刪除子樹 | DELETE FROM t WHERE path LIKE '/1/5/12/%' | O(subtree size) |
ORDER BY path 即得前序遍歷順序,天然適合輸出縮排path 長度受限(VARCHAR 1000 約支援 200 層),極深樹需改用 TEXT 或 Closure Tablepath 一致性CREATE TABLE `category` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
`name` VARCHAR(100) NOT NULL
) ENGINE=InnoDB;
CREATE TABLE `category_closure` (
`ancestor` INT UNSIGNED NOT NULL,
`descendant` INT UNSIGNED NOT NULL,
`depth` TINYINT UNSIGNED NOT NULL DEFAULT 0,
PRIMARY KEY (`ancestor`, `descendant`),
KEY `idx_descendant` (`descendant`)
) ENGINE=InnoDB;
-- 插入節點 5 為 2 的子節點
INSERT INTO `category` (`name`) VALUES ('New Node'); -- id=5
INSERT INTO `category_closure` (`ancestor`, `descendant`, `depth`)
SELECT `ancestor`, 5, `depth`+1 FROM `category_closure` WHERE `descendant` = 2
UNION ALL SELECT 5, 5, 0;
-- 刪除節點 5 及其整個子樹
DELETE c FROM `category_closure` c
JOIN `category_closure` d ON c.`descendant` = d.`descendant`
WHERE d.`ancestor` = 5;
DELETE FROM `category` WHERE `id` = 5;
-- 查詢 5 的所有後代 (depth > 0 表排除自身)
SELECT c.* FROM `category` c
JOIN `category_closure` cl ON c.`id` = cl.`descendant`
WHERE cl.`ancestor` = 5 AND cl.`depth` > 0
ORDER BY cl.`depth`;
closure 表關係,無需遞迴更新路徑字串| 場景特徵 | 推薦模式 | 理由 |
|---|---|---|
| 讀多寫少、深度固定 ≤ 10、MySQL 5.7 或無 CTE 支援 | 路徑枚舉 | 讀取單查詢,實作簡單 |
| 頻繁移動/刪除大子樹、需多父節點、PostgreSQL/MySQL 8.0+ | Closure Table | 寫入效能穩定,查詢最強,支援 DAG |
| 極簡需求、僅需父子關係、寫入頻繁、深度淺 | 鄰接表 + 應用層遞迴 | 最簡單,零冗余 |
| 需完整路徑顯示 (麵包屑)、靜態內容多 | 路徑枚舉 + 觸發器同步 | path 直接給前端用 |
| 資料量 < 1 萬、團隊熟悉遞迴 CTE | 鄰接表 + WITH RECURSIVE | 現代標準,無冗余欄位 |
沒有銀彈。**「讀多寫少、深度固定」選路徑枚舉;「頻繁重組、需多父、追求極致查詢效能」選 Closure Table;「簡單應用、團隊小、資料量小」直接鄰接表 + CTE/應用層遞迴。** 實測你的實際資料分佈與查詢模式才是最終依據。