本文目录导读:

- 方案一:邻接表模型(Adjacency List)—— 最常用、最简单
- 方案二:路径枚举模型(Path Enumeration)—— 查询友好
- 方案三:嵌套集模型(Nested Set)—— 读密集场景最优
- 总结对比与选择建议
- 最佳实践(实战建议)
在PHP中设计无限分类(无限级分类)的数据库表,主要有三种主流方案,每种方案都有其优缺点,适用于不同的业务场景。
以下是详细的表结构设计和PHP实现思路:
邻接表模型(Adjacency List)—— 最常用、最简单
这是最常见的做法,通过 parent_id 字段关联父级ID。
表结构设计
CREATE TABLE `category` ( `id` int(11) unsigned NOT NULL AUTO_INCREMENT, `name` varchar(50) NOT NULL COMMENT '分类名称', `parent_id` int(11) unsigned NOT NULL DEFAULT '0' COMMENT '父级ID,0为顶级', `sort` int(11) NOT NULL DEFAULT '0' COMMENT '排序权重', `status` tinyint(1) NOT NULL DEFAULT '1' COMMENT '状态:1启用,0禁用', `created_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP, `updated_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_parent_id` (`parent_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='无限级分类表';
核心操作(PHP实现)
(1)递归获取所有子分类(推荐使用引用算法,避免递归查询数据库)
<?php
/**
* 获取无限级分类树(一次查询+内存递归)
* @param array $data 数据库查出的所有记录
* @param int $parentId 父ID
* @return array 树形结构
*/
function buildTree(array $data, int $parentId = 0): array
{
$tree = [];
foreach ($data as $item) {
if ($item['parent_id'] == $parentId) {
// 先在内存中找到该节点的子节点
$children = buildTree($data, $item['id']);
if ($children) {
$item['children'] = $children;
}
$tree[] = $item;
}
}
return $tree;
}
// 使用方法:
$list = $db->query("SELECT * FROM category ORDER BY sort ASC")->fetchAll();
$tree = buildTree($list);
// print_r($tree);
(2)直接使用 MySQL WITH RECURSIVE(MySQL 8.0+)
-- 查询某个分类下的所有子分类(递归向下)
WITH RECURSIVE cte AS (
SELECT * FROM category WHERE id = 1 -- 从ID=1开始
UNION ALL
SELECT c.* FROM category c
INNER JOIN cte ON c.parent_id = cte.id
)
SELECT * FROM cte;
路径枚举模型(Path Enumeration)—— 查询友好
表结构设计
在基础表上增加一个 path 字段,存储从根节点到当前节点的完整路径。
CREATE TABLE `category` ( `id` int(11) unsigned NOT NULL AUTO_INCREMENT, `name` varchar(50) NOT NULL, `parent_id` int(11) unsigned NOT NULL DEFAULT '0', `path` varchar(500) NOT NULL DEFAULT '' COMMENT '节点路径,0,1,4,9', `sort` int(11) NOT NULL DEFAULT '0', PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
插入数据时的维护
插入时,父级的路径 + 父级ID = 当前节点的路径。
// 插入一个子分类
function insertCategory($name, $parentId) {
// 先查出父级的path
$parent_info = $db->query("SELECT path FROM category WHERE id = $parentId")->fetch();
if ($parent_info) {
$path = $parent_info['path'] . ',' . $parentId;
} else {
// 顶级分类
$path = '0';
}
$sql = "INSERT INTO category (name, parent_id, path) VALUES ('$name', $parentId, '$path')";
$db->exec($sql);
return $db->lastInsertId();
}
查询优势(速度极快)
查询某分类的所有子孙分类(不需要递归,单条SQL解决):
-- 查询ID为4的节点的所有子孙 SELECT * FROM category WHERE path LIKE '0,1,4,%' OR id = 4;
查询某分类的所有上级分类(面包屑导航):
-- 查询ID为9的所有上级 SELECT * FROM category WHERE id IN (SELECT REPLACE(path, ',', ' ') FROM category WHERE id = 9);
嵌套集模型(Nested Set)—— 读密集场景最优
适合“查询极其频繁,插入/删除较少”的场景(例如无限级菜单、地区选择),核心是给每个节点赋予 left 和 right 值。
表结构设计
CREATE TABLE `category` ( `id` int(11) unsigned NOT NULL AUTO_INCREMENT, `name` varchar(50) NOT NULL, `lft` int(11) NOT NULL COMMENT '左边界', `rgt` int(11) NOT NULL COMMENT '右边界', `sort` int(11) NOT NULL DEFAULT '0', PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
数据存储原理
- 根节点的
lft= 1,rgt= 节点总数 * 2。 - 子节点必须满足
lft> 父节点的lft且rgt< 父节点的rgt。
强大的查询能力(无需递归)
查询所有叶子节点:
SELECT * FROM category WHERE rgt = lft + 1;
查询某个节点的所有子孙:
-- 查询ID=4节点的所有子孙(假设4的lft=3, rgt=8) SELECT * FROM category WHERE lft BETWEEN 3 AND 8;
查询节点的深度(层级):
SELECT node.name, (COUNT(parent.id) - 1) AS depth FROM category AS node CROSS JOIN category AS parent WHERE node.lft BETWEEN parent.lft AND parent.rgt GROUP BY node.id ORDER BY node.lft;
总结对比与选择建议
| 方案 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| 邻接表 | 简单直观、增删改容易 | 查询子树需要递归(PHP递归或SQL递归),大数据量时性能差 | 分类数量较少(<1000),结构简单,开发效率优先 |
| 路径枚举 | 查询子树简单(LIKE),不用递归 | 插入时需要维护path;移动节点麻烦;path长度受限于字段长度 | 分类深度较浅且固定,对查询性能有要求 |
| 嵌套集 | 查询极其高效、无递归、无LIKE模糊 | 增删改复杂(需要重算lft/rgt);并发写入会锁表 | 分类结构极度稳定,只读查询为主(如地区/导航菜单) |
最佳实践(实战建议)
对于99%的通用PHP业务(如CMS后台、电商分类),推荐方案一(邻接表)+ 内存递归或MySQL递归CTE。
- 索引优化:确保
parent_id有索引。 - 数据缓存:将整个分类表(通常只有几百条)存入 Redis 或 PHP静态变量,用内存数组构建树,避免每次请求都查询数据库。
- 避免PHP递归查库:永远不要在一个递归函数里写
SELECT ... WHERE parent_id = $id,这会导致 N+1 次查询数据库,一定是 一次查出全表,在内存中构建树。
// 高效写法:一次查全表,内存构建(伪代码)
$data = $db->query("SELECT * FROM category")->fetchAll();
$tree = buildTree($data, 0); // 调用上面定义的内存递归函数