專案中常遇到分類層級不固定的需求(如商品分類、文章分類、知識庫目錄)。傳統 pid 遞迴查詢雖然直觀,但在深層級、大數據量下效能不佳。本文介紹「Rank 路徑編碼法」,以單次查詢 + 字串排序替代遞迴,大幅提升讀取效能,並簡化移動、刪除等維護操作。
CREATE TABLE `w_faqclass` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`pid` INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '父分類 ID',
`xid` INT NOT NULL DEFAULT 0 COMMENT '同級排序',
`classname` VARCHAR(100) NOT NULL COMMENT '分類名稱',
`rank` VARCHAR(255) NOT NULL DEFAULT '/' COMMENT '層級路徑,如 /1/3/7/',
PRIMARY KEY (`id`),
KEY `idx_pid` (`pid`),
KEY `idx_rank` (`rank`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
pid=0,rank='/'pid=一級ID,rank='/一級ID/'pid=二級ID,rank='/一級ID/二級ID/'rank 記錄從根到父節點的完整路徑,首尾皆為 /,便於 LIKE 比對。/**
* 遞迴取得已排序的無限級分類陣列
* @param string $table 資料表名
* @param int $pid 起始父 ID
* @param string $order 排序方式 ASC/DESC
* @param array $result 累積結果陣列(引用傳遞)
* @return array
*/
function ReadClass($table, $pid, $order, &$result)
{
$sql = "SELECT * FROM `$table` WHERE `pid` = ? ORDER BY `xid` $order";
$stmt = $GLOBALS['pdo']->prepare($sql);
$stmt->execute([$pid]);
while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) {
$result[] = $row;
ReadClass($table, $row['id'], $order, $result);
}
return $result;
}
備註:原文使用已棄用的 mysql_* 函式,此處改用 PDO 預處理語句,防止 SQL 注入。
function SelectValue($table, $id, $selectCol, $whereCol = 'id')
{
$sql = "SELECT `$selectCol` FROM `$table` WHERE `$whereCol` = ? LIMIT 1";
$stmt = $GLOBALS['pdo']->prepare($sql);
$stmt->execute([$id]);
return $stmt->fetchColumn();
}
<?php
// 1. 取得父分類的 rank
if ($pid != 0) {
$parentRank = SelectValue('w_faqclass', $pid, 'rank');
$rank = $parentRank . $pid . '/';
} else {
$rank = '/';
}
// 2. 寫入資料庫
$sql = "INSERT INTO `w_faqclass` (`pid`, `xid`, `classname`, `rank`) VALUES (?, ?, ?, ?)";
$stmt = $pdo->prepare($sql);
$stmt->execute([$pid, $xid, $classname, $rank]);
?>
<select name="pid">
<option value="0">主分類</option>
<?php
$list = ReadClass('w_faqclass', 0, 'ASC', []);
foreach ($list as $item) {
// 計算層級深度:rank 以 '/' 分割,陣列長度 - 2(首尾空元素)
$depth = count(explode('/', $item['rank'])) - 2;
$indent = str_repeat('├──', $depth);
$selected = ($pid == $item['id']) ? ' selected' : '';
echo "<option value=\"{$item['id']}\"$selected>$indent {$item['classname']}</option>";
}
?>
</select>
修改父分類時,該節點及其所有子孫的 rank 皆需更新。利用 MySQL REPLACE() 函式可一次完成:
<?php
// 1. 計算新的 rank(同新增邏輯)
if ($pid != 0) {
$parentRank = SelectValue('w_faqclass', $pid, 'rank');
$newRank = $parentRank . $pid . '/';
} else {
$newRank = '/';
}
// 2. 取得舊的完整路徑前綴(含自身 ID)
$oldRankPrefix = SelectValue('w_faqclass', $id, 'rank') . $id . '/';
$newRankPrefix = $newRank . $id . '/';
// 3. 批次更新自己與所有子孫
$sql = "UPDATE `w_faqclass` SET `rank` = REPLACE(`rank`, ?, ?) WHERE `rank` LIKE ?";
$stmt = $pdo->prepare($sql);
$stmt->execute([$oldRankPrefix, $newRankPrefix, $oldRankPrefix . '%']);
// 4. 更新基本欄位
$sql = "UPDATE `w_faqclass` SET `classname`=?, `xid`=?, `pid`=?, `rank`=? WHERE `id`=?";
$stmt = $pdo->prepare($sql);
$stmt->execute([$classname, $xid, $pid, $newRankPrefix, $id]);
?>
原理:WHERE rank LIKE '/1/3/%' 精確鎖定該分支,REPLACE(rank, '/1/3/7/', '/1/5/7/') 僅替換路徑前綴,保留後綴(子孫相對路徑不變)。
<?php
// 檢查是否有子分類
$childCount = SelectValue('w_faqclass', $id, 'COUNT(*)', 'pid');
if ($childCount > 0) {
die('該分類下存在子分類,請先遷移或刪除子分類。');
}
// 執行刪除
$pdo->exec("DELETE FROM `w_faqclass` WHERE `id` = $id");
?>
SELECT * FROM w_faqclass WHERE rank LIKE '/1/3/%'rank 字串中的 ID,IN (1,3) 查詢。ORDER BY xid ASC 即可。| 優點 | 缺點 |
|---|---|
| 讀取極快(單查/範圍查),無遞迴開銷 | 修改父節點需更新整個子樹(寫入放大) |
| SQL 簡單,易於建立索引(rank 前綴索引) | rank 欄位長度隨深度增長,極深層級可能超過 VARCHAR 限制 |
| 天然支援 LIKE 前綴查詢(子樹、祖先、路徑) | 移動子樹時需小心處理 rank 衝突 |
適用場景:讀多寫少、層級通常 ≤ 10、需頻繁輸出樹狀結構(選單、導航、報表)。
不適用場景:極深層級(>20)、高頻移動節點、需嚴格 ACID 事務的複雜遷移操作。
category_closure(ancestor, descendant, depth),查詢/移動皆高效,寫入稍繁瑣,適合複雜樹結構。WITH RECURSIVE cte AS (...) SELECT * FROM cte,標準 SQL 寫法,無需額外欄位,效能中等。Rank 路徑法以「空間換時間」,用冗餘的路徑字串換取極致的讀取效能與查詢靈活性。在大多數 CMS、電商後台、知識庫等「讀多寫少、層級固定」的場景下,是極具性價比的選擇。配合 PDO 預處理與索引優化,可構建穩健高效的無限級分類系統。