PHP如何实现无限分类树结构并优化递归查询性能问题

(拿起麦克风电麦克风,深吸一口气)

大家好,欢迎来到今天的“PHP 架构师进阶讲座”。

今天我们聊点硬核的,但绝对实用。大家平时写 PHP,做 CMS,做论坛,或者做电商后台,肯定都绕不开一个坎儿:无限级分类

这玩意儿就像是你那个永远还不上信用卡的室友,或者说,是那个明明只需要一杯水,却非要你倒了一游泳池的水来满足的甲方。你的数据库里有一条记录,它的 parent_id 指向它妈,它妈的 parent_id 指向它奶奶,它奶奶的 parent_id 指向……无限循环,直到地老天荒。

我们要解决什么问题?

  1. 怎么存? 数据结构怎么设计才合理?
  2. 怎么查? 递归查询(Recursion)看起来很美,但实际上是个“渣男”,性能极差,动不动就爆栈。
  3. 怎么优化? 怎么让你的无限分类在几万条数据下依然飞快?

准备好了吗?系好安全带,我们要开始解剖这些树了。


第一部分:邻接表 —— 数据库里的“家族谱系”

首先,我们得承认,关系型数据库(MySQL, PostgreSQL 等)最擅长的就是记录“关系”。最最基础、最最经典的实现方式叫邻接表(Adjacency List)

概念很简单:
我们给每个节点一个 ID,一个名字,再来一个 parent_id。如果它是根节点,parent_id 就是 0(或者 NULL)。

CREATE TABLE categories (
    id INT AUTO_INCREMENT PRIMARY KEY,
    name VARCHAR(50),
    parent_id INT DEFAULT 0,
    KEY idx_parent (parent_id)
);

你看,这就像你家的家谱。你是儿子,你爸是你,你爷爷是你爸。这就完了。数据结构简单得像白开水,查询也简单得像呼吸。

但是,麻烦来了。当你需要获取“电子产品”下的“手机”下的“苹果手机”这三者组成的树形结构时,数据库有点懵。它没有“爷爷”这个字段,它只知道“爸爸是谁”。

1. 递归的陷阱:你以为你是递归,其实你是“堆栈溢出”

在 PHP 里,我们通常会用递归函数来解决这个问题。比如:

function getTree($parentId = 0, &$result = []) {
    // 查找所有父级是 $parentId 的记录
    $children = $this->db->query("SELECT * FROM categories WHERE parent_id = ?", [$parentId]);

    foreach ($children as $node) {
        // 这就是递归!这就是“渣男”行为
        $result[] = $node;
        $this->getTree($node['id'], $result); 
    }

    return $result;
}

听着是不是很耳熟?很优雅?看起来逻辑闭环了?

Stop! 快停下!

这段代码在 1000 条数据以内没问题,数据量大一点?服务器直接给你来个 500 Internal Server Error。

为什么?
因为 PHP 的递归是基于堆栈的。 每一次函数调用,都会在内存里开辟一块新的区域。如果你的树有 1000 层深(比如某些变态的部门架构),PHP 的默认配置可能就直接崩了。就像你一口气吃了 100 个汉堡,胃受不了,程序也受不了。

更糟糕的是性能问题。你看这段代码,它不仅仅是慢,它是极其浪费
如果你想查“电脑”下面的所有内容:

  1. 查一次数据库,找到“电脑”。
  2. 找到“手机”。
  3. 查一次数据库,找“手机”下的子类。
  4. 找到“苹果”。
  5. 再查一次数据库……
  6. 重复 N 次……

这就是所谓的 N+1 查询问题。每一次递归都在敲数据库的门,数据库累得气喘吁吁,你的 CPU 也在疯狂旋转。这就像是你去菜市场买一颗白菜,老板让你去问卖葱的有没有蒜,卖蒜的让你去问卖姜的有没有辣椒,问了一圈下来,白菜都蔫了。

2. 迭代法:把“递归”变成“死循环”的温柔版

既然递归容易爆栈,那我们就把递归改成循环。这就是迭代法

我们不需要函数调用自己,我们自己维护一个“栈”(Stack)。

function getTreeIterative($parentId = 0) {
    $result = [];
    $stack = [$parentId];

    while (!empty($stack)) {
        // 弹出一个 ID
        $currentId = array_pop($stack);

        // 查询当前 ID 的子节点
        $children = $this->db->query("SELECT * FROM categories WHERE parent_id = ?", [$currentId]);

        foreach ($children as $node) {
            $result[] = $node;
            // 把子节点压入栈,下次循环处理
            $stack[] = $node['id'];
        }
    }

    return $result;
}

这时候,你已经赢了 90% 的初级开发者了。

迭代法比递归法好在哪里?

  1. 内存安全: 它只消耗当前函数执行时的内存,不会像递归那样层层嵌套。
  2. 可控性: 如果你想限制深度,加个 if (count($stack) > 50) break; 就行,不用担心爆栈。

但是,兄弟们,它依然很慢
为什么?因为它还是“傻乎乎”地查数据库。每往下查一层,就要敲一次数据库的门。如果是万级数据,那就是万次查询,数据库会瞬间挂掉,前端页面直接白屏。

所以,我们需要更高级的“黑科技”。


第二部分:路径字符串 —— 也就是“查杀”利器

既然递归太慢,我们能不能换一种思路?别让数据库“一层一层往下找”了,我们直接告诉数据库:“嘿,我要找所有 1/3/5/8 开头的记录!”

这就是路径字符串法

核心原理

我们在数据库里加一列叫 path。存的时候,把所有祖先的 ID 用 / 拼起来。

举个例子:

  • ID 1 (计算机): 1
  • ID 3 (电子产品): 1/3
  • ID 5 (手机): 1/3/5
  • ID 8 (苹果手机): 1/3/5/8

优势

查询子节点变得超级简单:

SELECT * FROM categories WHERE path LIKE '1/3/%';

怎么样?是不是比递归舒服多了?一个 SQL 搞定,走索引,快得飞起。

PHP 代码实战:

插入(写数据):
这是最头疼的部分。因为路径是动态的,你得先查出父节点的路径,然后拼一下。

function addCategory($name, $parentId) {
    // 1. 获取父节点路径
    $parent = $this->db->queryOne("SELECT path FROM categories WHERE id = ?", [$parentId]);
    $newPath = $parent['path'] . $parentId . '/'; 

    // 2. 插入
    $this->db->execute("INSERT INTO categories (name, parent_id, path) VALUES (?, ?, ?)", 
        [$name, $parentId, $newPath]);
}

获取树(读数据):
拿到所有数据后,PHP 负责构建树。这里可以用刚才说的迭代法,利用 explodeimplode 来组装。

function buildTree($categories) {
    $tree = [];
    $flat = [];

    // 先把所有数据按 path 长度排序,防止子节点先被处理
    // 注意:path 字段应该是字符串,为了性能,建议用数字拼接用 | 连接,或者在 SQL 里做个长度计算

    foreach ($categories as $item) {
        $flat[$item['id']] = $item;
    }

    foreach ($flat as $id => $node) {
        // 看 path 的最后一段是不是自己的 ID
        $parents = explode('/', $node['path']);
        $parentPath = implode('/', array_slice($parents, 0, -1));

        if (isset($flat[$parentPath])) {
            $flat[$parentPath]['children'][] = &$flat[$id];
        } else {
            $tree[] = &$flat[$id];
        }
    }

    return $tree;
}

性能分析:

  • 优点: 查询极快,没有递归开销。
  • 缺点: 更改父节点时,该节点下面所有子孙的 path 都要更新,这是 O(N) 操作。如果数据量巨大且需要频繁移动节点,这会很痛苦。

第三部分:闭包表 —— 也就是“朋友圈”大法

这是很多资深 DBA 和架构师推崇的终极方案。它解决了很多问题,尤其是当你需要查找“祖先”和“子孙”双向关系时。

核心原理

我们不再只存 parent_id。我们新建一个表,叫 closure(闭合表),专门存所有的父子关系。

表结构:

  • ancestor_id: 祖先 ID
  • descendant_id: 后代 ID
  • depth: 距离(几代)

数据示例:

ancestor_id descendant_id depth
1 1 0 (自己)
1 3 1 (1的子节点)
1 5 2 (1的孙节点)
1 8 3 (1的重孙)
3 3 0
3 5 1
3 8 2

为什么这玩意儿这么神?

  1. 查子节点:
    SELECT * FROM closure WHERE ancestor_id = ? AND depth > 0
    这比递归快一万倍!

  2. 查所有子孙:
    直接 SELECT descendant_id FROM closure WHERE ancestor_id = ?

  3. 查父节点:
    SELECT ancestor_id FROM closure WHERE descendant_id = ?

PHP 实现与插入逻辑

插入新节点(最难的部分):
如果你在 ID 为 5 的节点下插入 ID 为 9 的节点,你不能只写 (5, 9)。你必须把 5 的所有祖先都连上。

function insertWithClosure($descendantId, $parentId) {
    // 1. 获取父节点及其所有祖先
    // SELECT ancestor_id, depth FROM closure WHERE descendant_id = ?
    $ancestors = $this->db->query("SELECT ancestor_id, depth FROM closure WHERE descendant_id = ?", [$parentId]);

    // 2. 为新节点生成关系
    $sql = "INSERT INTO closure (ancestor_id, descendant_id, depth) VALUES ";
    $values = [];

    foreach ($ancestors as $row) {
        $values[] = "({$row['ancestor_id']}, $descendantId, " . ($row['depth'] + 1) . ")";
    }

    // 3. 加上自己 (depth = 0)
    $values[] = "($descendantId, $descendantId, 0)";

    // 4. 执行批量插入
    $this->db->execute($sql . implode(',', $values));
}

这看起来代码多,但逻辑清晰。它维护了一个完整的图。

查询树:
查询逻辑和邻接表一样,你需要把平铺的数据组装成树。

function getTreeClosure() {
    // 获取所有关系数据,或者只取 depth = 0 的作为根节点
    $allNodes = $this->db->query("SELECT * FROM categories c JOIN closure cl ON c.id = cl.descendant_id");

    // 这里用简单的数组组装法,也可以优化
    $tree = [];
    $map = [];

    foreach ($allNodes as $node) {
        $map[$node['id']] = $node;
    }

    foreach ($map as $id => $node) {
        // 查找谁是它的父节点(即 depth=1 的祖先)
        $parents = $this->db->query("SELECT ancestor_id FROM closure WHERE descendant_id = ? AND depth = 1", [$id]);

        foreach ($parents as $p) {
            if (isset($map[$p['ancestor_id']])) {
                $map[$p['ancestor_id']]['children'][] = &$map[$id];
            } else {
                $tree[] = &$map[$id];
            }
        }
    }

    return $tree;
}

第四部分:性能优化的“灵魂”

光有算法还不够。如果你在每次加载页面时都去跑 SQL 查询闭包表或者递归查询,那你还是会被老板骂的。因为数据变了,树就变了。

我们要引入缓存

1. APCu / OPcache —— 内存里的“神灯”

PHP 有一个叫做 APCu 的扩展,它是把数据存到服务器的内存里的(不是硬盘)。这比查数据库快了几个数量级。

策略:
如果你是一个静态内容(比如后台的分类设置),数据极少变化,你完全可以在代码加载的第一行就缓存好整个树。

function getCategoryTree() {
    // 1. 先去缓存里拿
    $tree = apcu_fetch('category_tree_cache');

    if ($tree !== false) {
        return $tree;
    }

    // 2. 拿不到?去数据库查(使用我们上面优好的算法,比如闭包表)
    $tree = $this->buildTreeFromDB();

    // 3. 放回缓存里,有效期 1 小时
    apcu_store('category_tree_cache', $tree, 3600);

    return $tree;
}

但是! 现在的 Web 架构大多是多进程、多机器的。Apcu 只在当前机器有效。如果机器 A 更新了分类,机器 B 看到的还是旧的树。

2. Redis —— 分布式缓存

这时候我们就得请出 Redis 了。

策略:
把生成的树序列化后存入 Redis。

function getCategoryTree() {
    // 从 Redis 拿
    $redis = new Redis();
    $redis->connect('127.0.0.1', 6379);
    $tree = $redis->get('category_tree:full');

    if (!$tree) {
        $tree = $this->buildTreeFromDB();
        // 存入 Redis,设置过期时间
        $redis->setex('category_tree:full', 3600, serialize($tree));
    }

    return unserialize($tree);
}

如何解决缓存一致性问题?
这是个大问题。当有新分类插入时,我们直接删除缓存:
$redis->delete('category_tree:full');
下次请求进来时,发现缓存没了,就重新去数据库查。这叫“懒加载”+“延时双删”的变种。


第五部分:实战中的“避坑指南”

作为一个在 PHP 界摸爬滚打多年的老兵,我要给你们几点建议,这比代码更重要。

  1. 别过度设计:
    如果你的分类只有几百条,别上闭包表。邻接表配合递归(或者简单的迭代),再配合 Redis 缓存,足够用了。闭包表的操作虽然 O(1) 的插入,但是写入开销大,对于写多读少的场景简直是灾难。

  2. 永远加索引:
    看看你的 SQL:
    SELECT * FROM categories WHERE parent_id = 5;
    必须给 parent_id 加索引!
    CREATE INDEX idx_parent ON categories(parent_id);
    如果不加索引,数据库就是拿着扫把在几千页的电话簿里找名字,慢得你想砸键盘。

  3. 前端渲染 vs 后端渲染:
    如果你用的是 Vue 或 React,你完全没必要在 PHP 里把树构建好发过去。你可以在 PHP 里只返回一个平铺的数组(或者使用 Closure Table),然后在 JS 里利用递归或者 lodashgroupBy 来构建树。这样前端更灵活,PHP 更轻量。

  4. 关于递归的恐惧:
    我看到很多新手看到递归就跑,其实 PHP 的递归深度限制是可以调的。在 php.ini 里改 xdebug.max_nesting_level
    但是,性能优化不是靠改配置参数堆上去的,而是靠减少查询次数。

  5. 懒惰是程序员的美德:
    如果你的分类数据只有 500 条,哪怕用最笨的递归,用户也感觉不到卡顿。只有当数据量达到 10 万级,且频繁查询时,你才有必要动用闭包表或者重构数据结构。


第六部分:总结与升华

好了,我们回顾一下今天讲的内容:

  1. 邻接表 (parent_id):这是基础,也是相亲对象,最简单,但查起来最费劲。
  2. 递归:看起来很美,实际上是个“脆皮”,容易爆栈,而且导致 N+1 查询。
  3. 迭代:把递归翻译成人话(循环),内存安全,但依然需要查数据库。
  4. 路径字符串 (path):SQL 查询变成简单的 LIKE,适合构建菜单,但更新节点时很痛苦。
  5. 闭包表:全面记录所有关系,查询无敌,写入繁琐,适合作为数据的“百科全书”。
  6. 缓存:无论你用什么算法,加上 APCu 或 Redis,你的系统就能起飞。

最后,我想说,数据库设计没有银弹。没有一种方案是完美的。

  • 如果你做电商的分类,层级不深(最多 5 层),且偶尔变动,用 邻接表 + Redis 缓存
  • 如果你做论坛板块,层级很深,且查询频繁,闭包表 是你的不二之选。
  • 如果你做后台管理系统,追求开发效率,路径字符串 配合 PHP 的数组函数非常香。

所以,下次当你面对那个让你头疼的无限分类时,别慌。深呼吸,想一想这是哪一种树,然后敲出那行漂亮的 SQL。

今天的讲座就到这里。希望大家回去都能写出像风吹麦浪一样顺滑的代码!如果有问题,下课之后来找我,别在代码里找我,因为我有“递归恐惧症”。

(鞠躬,下台)


(附注:如果觉得上面的代码太长看不懂,别担心,只要记住核心思想:要么让数据库一次性把活干完,要么就把活干完存起来别再干。这就是优化的本质。)

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注