首頁
編程語言
C語言|JAVA編程
Python編程
網頁編程
ASP編程|PHP編程
JSP編程
數據庫
MYSQL數據庫|SqlServer數據庫
Oracle數據庫|DB2數據庫
您现在的位置: 程式碼之家 >> 編程語言 >  >> 網頁程式設計 >> PHP 程式設計 >> PHP 綜合知識

PHP + MySQL 無限分類完整實作:遞歸樹形結構與 Path 路徑法雙方案比較

     編輯:PHP 綜合知識

無限分類 (Infinite Category) 是電商、CMS、OA 系統的核心需求。本文詳細比較兩大主流實作:

  1. 遞歸 + 陣列組裝 (Adjacency List):結構簡單、修改關係容易,但大量資料需 PHP 端組裝樹。
  2. Path 路徑枚舉 (Materialized Path):單一 SQL 查詢子樹、效能極佳,但移動節點需更新所有後代 path。

並提供現代化 PDO 程式碼、效能測試建議、以及 Closure Table (關係閉包表) 進階方案簡介。

資料表結構

-- 方案一:Adjacency List (父子關係)
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 COMMENT '同層排序',
    `status`   TINYINT NOT NULL DEFAULT 1,
    INDEX `idx_pid` (`pid`),
    INDEX `idx_status_sort` (`status`, `sort`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

-- 方案二:Materialized Path (路徑枚舉)
CREATE TABLE `category_path` (
    `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 '0' COMMENT '如 0-1-3-7',
    `depth`    TINYINT NOT NULL DEFAULT 0 COMMENT '層級深度',
    `sort`     INT NOT NULL DEFAULT 0,
    `status`   TINYINT NOT NULL DEFAULT 1,
    INDEX `idx_path` (`path`),
    INDEX `idx_pid` (`pid`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

方案一:PHP 端遞歸組裝樹 (Adjacency List)

<?php
/**
 * 取得完整分類樹 (遞歸組裝)
 * @param PDO $pdo
 * @return array 樹狀陣列
 */
function getCategoryTree(PDO $pdo): array
{
    // 1. 一次撈取所有啟用分類
    $stmt = $pdo->query('SELECT id, name, pid, sort FROM category WHERE status=1 ORDER BY sort, id');
    $list = $stmt->fetchAll(PDO::FETCH_ASSOC);

    // 2. 建立 id => node 映射,並初始化 children
    $map = [];
    foreach ($list as $node) {
        $map[$node['id']] = $node + ['children' => []];
    }

    // 3. 利用引用 (&) 一次遍歷掛載子節點
    $tree = [];
    foreach ($map as $id => &$node) {
        if ($node['pid'] === 0) {
            $tree[$id] = &$node; // 頂層節點
        } elseif (isset($map[$node['pid']])) {
            $map[$node['pid']]['children'][] = &$node; // 掛載到父節點
        }
        // 孤兒節點 (pid 不存在) 可選擇丟棄或掛在虛擬根
    }
    unset($node); // 解除引用

    return $tree;
}

/**
 * 取得指定分類的所有後代 ID (含自己) - 遞歸查詢
 * @param PDO $pdo
 * @param int $rootId
 * @return array
 */
function getDescendantIds(PDO $pdo, int $rootId): array
{
    $ids = [$rootId];
    $stmt = $pdo->prepare('SELECT id FROM category WHERE pid = ? AND status=1');
    $stmt->execute([$rootId]);
    while ($row = $stmt->fetch()) {
        $ids = array_merge($ids, getDescendantIds($pdo, $row['id']));
    }
    return $ids;
}

// --- 使用 ---
$tree = getCategoryTree($pdo);
// 輸出 HTML 選單
function renderSelect(array $tree, int $level = 0): string
{
    $html = '';
    foreach ($tree as $node) {
        $html .= '<option value="' . $node['id'] . '">' . str_repeat('—', $level) . ' ' . htmlspecialchars($node['name']) . '</option>';
        if ($node['children']) {
            $html .= renderSelect($node['children'], $level + 1);
        }
    }
    return $html;
}
echo '<select>' . renderSelect($tree) . '</select>';

方案二:Path 路徑枚舉 (Materialized Path)

<?php
/**
 * 新增分類 (自動計算 path, depth)
 * @param PDO $pdo
 * @param string $name
 * @param int $pid
 * @return int 新分類 ID
 */
function addCategory(PDO $pdo, string $name, int $pid = 0): int
{
    $pdo->beginTransaction();
    try {
        if ($pid === 0) {
            $path = '0';
            $depth = 0;
        } else {
            $stmt = $pdo->prepare('SELECT path, depth FROM category_path WHERE id = ?');
            $stmt->execute([$pid]);
            $parent = $stmt->fetch();
            if (!$parent) throw new Exception('父分類不存在');
            $path = $parent['path'] . '-' . $pid;
            $depth = $parent['depth'] + 1;
        }

        $stmt = $pdo->prepare('INSERT INTO category_path (name, pid, path, depth) VALUES (?, ?, ?, ?)');
        $stmt->execute([$name, $pid, $path, $depth]);
        $newId = (int) $pdo->lastInsertId();

        // 更新自己的 path 包含自己 (方便 LIKE 查詢)
        $pdo->prepare('UPDATE category_path SET path = CONCAT(path, "-", ?) WHERE id = ?')->execute([$newId, $newId]);

        $pdo->commit();
        return $newId;
    } catch (Throwable $e) {
        $pdo->rollBack();
        throw $e;
    }
}

/**
 * 查詢某分類的所有後代 (單一 SQL,極快)
 * @param PDO $pdo
 * @param int $rootId
 * @return array
 */
function getDescendantsByPath(PDO $pdo, int $rootId): array
{
    // 先取得 root 的 path
    $stmt = $pdo->prepare('SELECT path FROM category_path WHERE id = ?');
    $stmt->execute([$rootId]);
    $root = $stmt->fetch();
    if (!$root) return [];

    $pattern = $root['path'] . '-' . $rootId . '%'; // 如 0-1-3%
    $stmt = $pdo->prepare('SELECT * FROM category_path WHERE path LIKE ? AND status=1 ORDER BY path');
    $stmt->execute([$pattern]);
    return $stmt->fetchAll(PDO::FETCH_ASSOC);
}

/**
 * 移動分類 (更新自己及所有後代 path, depth) - 較複雜
 * @param PDO $pdo
 * @param int $moveId 要移動的分類
 * @param int $newPid 新父分類
 */
function moveCategory(PDO $pdo, int $moveId, int $newPid): void
{
    $pdo->beginTransaction();
    try {
        // 1. 取得舊資訊
        $stmt = $pdo->prepare('SELECT path, depth FROM category_path WHERE id = ?');
        $stmt->execute([$moveId]);
        $old = $stmt->fetch();
        if (!$old) throw new Exception('分類不存在');

        // 2. 取得新父資訊
        $newPath = '0';
        $newDepth = 0;
        if ($newPid !== 0) {
            $stmt = $pdo->prepare('SELECT path, depth FROM category_path WHERE id = ?');
            $stmt->execute([$newPid]);
            $parent = $stmt->fetch();
            if (!$parent) throw new Exception('新父分類不存在');
            $newPath = $parent['path'] . '-' . $newPid;
            $newDepth = $parent['depth'] + 1;
        }

        // 3. 計算 path 差異
        $oldPrefix = $old['path'] . '-' . $moveId;
        $newPrefix = $newPath . '-' . $moveId;
        $depthDiff = $newDepth - $old['depth'];

        // 4. 批次更新自己及後代
        $pdo->prepare('UPDATE category_path SET path = REPLACE(path, ?, ?), depth = depth + ? WHERE path LIKE ? OR id = ?')
            ->execute([$oldPrefix, $newPrefix, $depthDiff, $oldPrefix . '%', $moveId]);

        $pdo->commit();
    } catch (Throwable $e) {
        $pdo->rollBack();
        throw $e;
    }
}

方案比較與選型建議

維度Adjacency List (遞歸組裝)Materialized PathClosure Table (關係閉包)
查詢子樹效能需 PHP 組裝 / 多次查詢單一 SQL (LIKE 索引)單一 SQL (JOIN 索引)
查詢祖先路徑遞歸查詢 / 多次 SQL解析 path 字串單一 SQL
移動節點成本極低 (改 pid)高 (更新所有後代 path)中 (刪/增關係行)
資料完整性依賴 FK路徑可能不一致最強 (FK 约束)
儲存空間最小中 (path 字串)最大 (N² 關係行)
適用場景分類 < 5000、頻繁移動分類 < 10萬、查詢多改少嚴格一致性、複雜權限

進階:Closure Table (關係閉包表) 核心邏輯

CREATE TABLE `category_closure` (
    `ancestor`   INT UNSIGNED NOT NULL,
    `descendant` INT UNSIGNED NOT NULL,
    `depth`      TINYINT NOT NULL DEFAULT 0,
    PRIMARY KEY (`ancestor`, `descendant`),
    INDEX `idx_descendant` (`descendant`)
) ENGINE=InnoDB;

-- 新增節點:插入自己 (depth=0) + 所有祖先關係 (depth+1)
INSERT INTO category_closure (ancestor, descendant, depth)
SELECT ancestor, NEW_ID, depth+1 FROM category_closure WHERE descendant = PARENT_ID
UNION ALL SELECT NEW_ID, NEW_ID, 0;

-- 查詢所有後代:SELECT descendant FROM category_closure WHERE ancestor = ROOT_ID AND depth > 0;
-- 查詢所有祖先:SELECT ancestor FROM category_closure WHERE descendant = NODE_ID AND depth > 0;
-- 移動節點:刪除舊關係 + 插入新關係 (需交易鎖表)

效能測試建議

# 生成 10 萬筆測試資料
php artisan db:seed --class=CategorySeeder --count=100000

# 測試查詢時間
time php benchmark.php --method=adjacency --root=1
time php benchmark.php --method=path --root=1
time php benchmark.php --method=closure --root=1

# 監控記憶體
php -d memory_limit=256M benchmark.php

總結

  • 中小型專案 (分類 < 5000):**Adjacency List + PHP 組裝** 最簡單、維護成本最低。
  • 大型電商/內容站 (分類 1萬+、頻繁查詢子樹):**Materialized Path** 或 **Closure Table**。
  • 需嚴格 ACID、複雜權限繼承:**Closure Table** 配合觸發器/應用層交易。
  • 無論選哪種,**務必加上 `status`、`sort` 欄位**,並建立對應複合索引。
  1. 上一篇文章:
  2. 下一篇文章:
Copyright © 程式碼之家 All Rights Reserved