無限分類是電商、CMS、知識庫等系統的核心需求。本文深度剖析兩種主流實現方案——遞歸樹(Parent-ID)與Path 路徑法(Materialized Path),從資料結構、查詢效能、寫入複雜度、適用場景全維度對比,助你選型決策。
CREATE TABLE `category` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
`name` VARCHAR(100) NOT NULL,
`pid` INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '父分類 ID,0 為頂級',
`sort` INT NOT NULL DEFAULT 0,
INDEX `idx_pid` (`pid`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
// 1. 取得所有分類(按 sort, id 排序)
$rows = $pdo->query("SELECT id, name, pid FROM category ORDER BY sort, id")->fetchAll(PDO::FETCH_ASSOC);
// 2. 建立 ID 索引映射 + 初始化 children
$tree = [];
foreach ($rows as $row) {
$tree[$row['id']] = $row;
$tree[$row['id']]['children'] = [];
}
// 3. 利用引用一次遍歷組裝樹(O(n))
foreach ($tree as $id => &$node) {
if ($node['pid'] != 0 && isset($tree[$node['pid']])) {
$tree[$node['pid']]['children'][] = &$node;
}
}
unset($node); // 釋放引用
// 4. 取得頂級分類樹
$roots = array_filter($tree, fn($n) => $n['pid'] == 0);
Array
(
[1] => Array
(
[id] => 1
[name] => 電腦
[pid] => 0
[children] => Array
(
[0] => Array
(
[id] => 3
[name] => 筆記本
[pid] => 1
[children] => Array
(
[0] => [id=>7, name=>超級本, ...]
[1] => [id=>8, name=>遊戲本, ...]
)
)
[1] => [id=>4, name=>台式機, ...]
)
)
[2] => [id=>2, name=>手機, children=>[...]]
)
| 優點 | 缺點 |
|---|---|
| 結構直觀,符合直覺 | 分類量大(>5萬)時 PHP 組裝耗記憶體/時間 |
| 移動節點簡單:只需更新 pid | 查詢某節點所有後代需遞歸查詢或全表掃描 |
| 支援任意深度,無路徑長度限制 | 難以在 SQL 層面高效實現「查詢所有子孫」 |
| 外鍵約束易於維護資料完整性 |
CREATE TABLE `category` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
`name` VARCHAR(100) NOT NULL,
`pid` INT UNSIGNED NOT NULL DEFAULT 0,
`path` VARCHAR(500) NOT NULL DEFAULT '' COMMENT '從根到父節點的 ID 路徑,如 0-1-3',
`depth` TINYINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '層級深度',
INDEX `idx_path` (`path`),
INDEX `idx_pid` (`pid`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
id | name | pid | path | depth
---+---------+-----+---------+------
1 | 電腦 | 0 | 0 | 0
2 | 手機 | 0 | 0 | 0
3 | 筆記本 | 1 | 0-1 | 1
4 | 超級本 | 3 | 0-1-3 | 2
5 | 遊戲本 | 3 | 0-1-3 | 2
-- 獲取 id=1 (電腦) 的所有後代分類
SELECT id, name, path, depth
FROM category
WHERE path LIKE (SELECT CONCAT(path, '-', id, '%') FROM category WHERE id = 1)
ORDER BY path;
結果:
id | name | path | depth
---+--------+--------+------
3 | 筆記本 | 0-1 | 1
4 | 超級本 | 0-1-3 | 2
5 | 遊戲本 | 0-1-3 | 2
-- 獲取祖先鏈(麵包屑導航)
SELECT * FROM category WHERE id IN (0,1,3) ORDER BY depth; -- 需應用層解析 path
-- 獲取直系子分類
SELECT * FROM category WHERE pid = 1;
-- 判斷 A 是否為 B 的後代
SELECT 1 FROM category WHERE id = B_id AND path LIKE (SELECT CONCAT(path, '-', id, '%') FROM category WHERE id = A_id);
-- 將節點 3 (及其子樹) 移動到節點 2 下
-- 1. 計算新 path 前綴
SELECT CONCAT(path, '-', id) FROM category WHERE id = 2; -- 返回 '0-2'
-- 2. 批次更新子樹 path
UPDATE category
SET path = REPLACE(path, '0-1', '0-2')
WHERE path LIKE '0-1-%' OR path = '0-1';
| 優點 | 缺點 |
|---|---|
| 查詢後代/祖先極快,單條 SQL,可用索引 | 移動節點需更新整棵子樹 path(寫入放大) |
| 適合「讀多寫少」場景(電商分類、知識庫) | path 長度有限制(VARCHAR 500 約支援 50+ 層) |
| 易於分頁、排序、深度過濾 | 資料完整性依賴應用層/觸發器,外鍵難約束 path |
| 維度 | 遞歸樹 | Path 路徑法 |
|---|---|---|
| 讀取後代效能 | O(n) PHP 組裝 / 遞歸 SQL | O(log n) 索引範圍掃描 |
| 移動節點成本 | O(1) 單行更新 | O(subtree_size) 批次更新 |
| 資料完整性 | 外鍵友善 | 需應用層保證 |
| 最大深度 | 無限制 | 受限於 VARCHAR 長度 |
| 適用場景 | 寫入頻繁、深度較淺、分類數 < 1萬 | 讀取極頻、深度較深、分類數 > 1萬 |
| 實現複雜度 | 低 | 中(需維護 path/depth) |
若兩者都不滿足(如需同時高效支援「移動子樹」與「查詢後代」),可引入 Closure Table:
CREATE TABLE category_closure (
ancestor INT UNSIGNED NOT NULL,
descendant INT UNSIGNED NOT NULL,
depth INT UNSIGNED NOT NULL,
PRIMARY KEY (ancestor, descendant)
);
SELECT descendant FROM category_closure WHERE ancestor = 1 AND depth > 0depth 欄位,便於層級過濾與排序nesting_set(巢狀集合)作為備選,適合靜態化程度高的目錄無限分類無銀彈,核心在於讀寫比例、資料規模、變動頻率三要素的權衡。建議先從遞歸樹起步,隨業務增長平滑遷移至 Path 或 Closure Table。