(拿起麦克风电麦克风,深吸一口气)
大家好,欢迎来到今天的“PHP 架构师进阶讲座”。
今天我们聊点硬核的,但绝对实用。大家平时写 PHP,做 CMS,做论坛,或者做电商后台,肯定都绕不开一个坎儿:无限级分类。
这玩意儿就像是你那个永远还不上信用卡的室友,或者说,是那个明明只需要一杯水,却非要你倒了一游泳池的水来满足的甲方。你的数据库里有一条记录,它的 parent_id 指向它妈,它妈的 parent_id 指向它奶奶,它奶奶的 parent_id 指向……无限循环,直到地老天荒。
我们要解决什么问题?
- 怎么存? 数据结构怎么设计才合理?
- 怎么查? 递归查询(Recursion)看起来很美,但实际上是个“渣男”,性能极差,动不动就爆栈。
- 怎么优化? 怎么让你的无限分类在几万条数据下依然飞快?
准备好了吗?系好安全带,我们要开始解剖这些树了。
第一部分:邻接表 —— 数据库里的“家族谱系”
首先,我们得承认,关系型数据库(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 个汉堡,胃受不了,程序也受不了。
更糟糕的是性能问题。你看这段代码,它不仅仅是慢,它是极其浪费。
如果你想查“电脑”下面的所有内容:
- 查一次数据库,找到“电脑”。
- 找到“手机”。
- 查一次数据库,找“手机”下的子类。
- 找到“苹果”。
- 再查一次数据库……
- 重复 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% 的初级开发者了。
迭代法比递归法好在哪里?
- 内存安全: 它只消耗当前函数执行时的内存,不会像递归那样层层嵌套。
- 可控性: 如果你想限制深度,加个
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 负责构建树。这里可以用刚才说的迭代法,利用 explode 和 implode 来组装。
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: 祖先 IDdescendant_id: 后代 IDdepth: 距离(几代)
数据示例:
| 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 |
| … | … | … |
为什么这玩意儿这么神?
-
查子节点:
SELECT * FROM closure WHERE ancestor_id = ? AND depth > 0
这比递归快一万倍! -
查所有子孙:
直接SELECT descendant_id FROM closure WHERE ancestor_id = ? -
查父节点:
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 界摸爬滚打多年的老兵,我要给你们几点建议,这比代码更重要。
-
别过度设计:
如果你的分类只有几百条,别上闭包表。邻接表配合递归(或者简单的迭代),再配合 Redis 缓存,足够用了。闭包表的操作虽然 O(1) 的插入,但是写入开销大,对于写多读少的场景简直是灾难。 -
永远加索引:
看看你的 SQL:
SELECT * FROM categories WHERE parent_id = 5;
必须给parent_id加索引!
CREATE INDEX idx_parent ON categories(parent_id);
如果不加索引,数据库就是拿着扫把在几千页的电话簿里找名字,慢得你想砸键盘。 -
前端渲染 vs 后端渲染:
如果你用的是 Vue 或 React,你完全没必要在 PHP 里把树构建好发过去。你可以在 PHP 里只返回一个平铺的数组(或者使用 Closure Table),然后在 JS 里利用递归或者lodash的groupBy来构建树。这样前端更灵活,PHP 更轻量。 -
关于递归的恐惧:
我看到很多新手看到递归就跑,其实 PHP 的递归深度限制是可以调的。在php.ini里改xdebug.max_nesting_level。
但是,性能优化不是靠改配置参数堆上去的,而是靠减少查询次数。 -
懒惰是程序员的美德:
如果你的分类数据只有 500 条,哪怕用最笨的递归,用户也感觉不到卡顿。只有当数据量达到 10 万级,且频繁查询时,你才有必要动用闭包表或者重构数据结构。
第六部分:总结与升华
好了,我们回顾一下今天讲的内容:
- 邻接表 (
parent_id):这是基础,也是相亲对象,最简单,但查起来最费劲。 - 递归:看起来很美,实际上是个“脆皮”,容易爆栈,而且导致 N+1 查询。
- 迭代:把递归翻译成人话(循环),内存安全,但依然需要查数据库。
- 路径字符串 (
path):SQL 查询变成简单的LIKE,适合构建菜单,但更新节点时很痛苦。 - 闭包表:全面记录所有关系,查询无敌,写入繁琐,适合作为数据的“百科全书”。
- 缓存:无论你用什么算法,加上 APCu 或 Redis,你的系统就能起飞。
最后,我想说,数据库设计没有银弹。没有一种方案是完美的。
- 如果你做电商的分类,层级不深(最多 5 层),且偶尔变动,用 邻接表 + Redis 缓存。
- 如果你做论坛板块,层级很深,且查询频繁,闭包表 是你的不二之选。
- 如果你做后台管理系统,追求开发效率,路径字符串 配合 PHP 的数组函数非常香。
所以,下次当你面对那个让你头疼的无限分类时,别慌。深呼吸,想一想这是哪一种树,然后敲出那行漂亮的 SQL。
今天的讲座就到这里。希望大家回去都能写出像风吹麦浪一样顺滑的代码!如果有问题,下课之后来找我,别在代码里找我,因为我有“递归恐惧症”。
(鞠躬,下台)
(附注:如果觉得上面的代码太长看不懂,别担心,只要记住核心思想:要么让数据库一次性把活干完,要么就把活干完存起来别再干。这就是优化的本质。)