
做PHP开发这几年无限分类几乎每个项目都会遇到商品类目、后台菜单、城市地区、权限节点、文档栏目……凡是能“父子挂靠”的数据本质上都在问同一个问题层次不固定怎么存怎么查表设计直接决定了后面所有代码的走向这也是为什么有人用一句递归就能交差有人却要改三轮表结构才能跑通。这篇文章我会从表设计讲起把四种常见方案掰开揉碎再给出我实际项目里验证过的PHP实现和踩坑记录适合正在写分类功能的同学也适合想优化老项目里那套递归查询的人。1. 无限分类的难点不在递归在表设计1.1 先搞清楚“无限”到底指什么无限分类里的“无限”指的是层级深度不固定顶级分类下面可以有二级、三级、甚至二十级。你用循环只能处理固定层级所以绕不开“递归”或者“树形遍历”。但很多人一上来就写递归结果发现新增一层、删除一个分支、移动节点时递归代码越来越难维护。根子在于递归只是读取阶段的手段而数据的存储结构如果用错了后面所有读取都要跟着费劲。我在某电商后台见过一个很典型的例子分类表只有id、name、parent_id三个字段前端要展示“分类路径”比如手机 手机配件 充电器开发就只能逐级查父级每次渲染一个列表要循环几十次SQL。问题不是不能做而是做完之后每一次页面打开都在燃烧数据库。所以先别想代码先想清楚表结构。1.2 一张表决定产品的灵活度和性能分类数据有几个天然特点写入少、读取多、需要排序、需要整树遍历。基于这些特征表设计不仅要满足“存得下”还要满足“查得快”。设计得好后面所有功能——增删改查、面包屑导航、子分类统计——都会顺理成章设计得不好业务方哪天突然说“我要在三级分类下面再挂一级”你就得去改表结构甚至重写接口。常见的方案有四种邻接表模型、路径枚举模型、嵌套集合模型、闭包表模型。我建议先花五分钟把这四者横向看一遍再根据你的真实场景选型。2. 四种主流分类表设计方案对比方案核心字段查子孙节点查祖先路径增删改复杂度典型场景邻接表parent_id递归逐层查递归逐层查简单后台菜单、小型栏目路径枚举parent_id path字符串前缀匹配path直接拆中等电商类目、文章分类嵌套集合lft rgt一条SQL一条SQL高极度偏重查询的场景闭包表单独关系表一条SQL一条SQL高大规模动态层级数据2.1 邻接表模型最直观但别裸奔邻接表只在每行记录一个parent_id也就是当前节点的父级ID。顶级节点parent_id设为0结构简单理解成本最低。CREATE TABLE category_adjacency ( id INT UNSIGNED NOT NULL AUTO_INCREMENT, parent_id INT UNSIGNED NOT NULL DEFAULT 0, name VARCHAR(50) NOT NULL, PRIMARY KEY (id), KEY idx_parent_id (parent_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;这种方案的优点是插入、删除、移动都非常直接改一条记录的parent_id即可。缺点是查询“某个节点下所有后代”或者“某个节点到根节点的路径”时需要递归。数据量小还好比如后台菜单就几十行全查出来在内存里组装树都行。但如果分类有几万条每次都从库里递归查性能会很难看。所以纯邻接表不能算“完整的无限分类方案”只算一个最基础的存储结构。2.2 路径枚举模型一张表解决90%的查询痛点路径枚举的做法是在邻接表基础上增加一个path字段记录从根节点到当前节点的完整路径。我习惯用两边都带斜杠的方式比如顶级分类“手机”的path是/1/子分类“手机配件”的path是/1/5/“充电器”就是/1/5/12/。这样查“某个分类下所有子孙”就很简单SELECT * FROM category WHERE path LIKE /1/5/%;注意路径两边的斜杠不能省否则/1/LIKE/1/%会把/1/55/这种不相干的节点也带出来这是新手最容易踩的边界问题。路径枚举的优点是查询代价极低面包屑也能通过path直接拆出来缺点是path有冗余一旦“移动某个父级分类”它所有后代的path都要批量更新。好在移动分类是低频操作配合事务可以接受。2.3 嵌套集合模型查询神速维护受罪嵌套集合通过给每个节点分配左右值lft、rgt来编码树结构。规则是先序遍历整棵树每进入一个节点赋lft遍历完它的所有子节点后赋rgt。例如根节点lft1、rgt8它有两个子节点分别占(2,3)和(4,7)。查任意子树只需要一条SQLSELECT * FROM category WHERE lft BETWEEN 1 AND 8;但代价是增删改非常痛苦新增一个节点它右侧所有节点的左右值都要重算。一旦业务方频繁调整分类层级或者运营直接手工改数据这套方案很容易把树弄乱。所以我个人不太推荐在新项目里用它除非分类结构几乎固定不变且查询压力大到你愿意接受维护成本。2.4 闭包表模型最“正规军”的解决方案闭包表不直接在分类表里存层级关系而是单独一张关系表记录任意两个有祖先-后代关系的节点对并带上depth表示相隔几层。CREATE TABLE category_tree ( ancestor_id INT UNSIGNED NOT NULL, descendant_id INT UNSIGNED NOT NULL, depth INT UNSIGNED NOT NULL DEFAULT 0, PRIMARY KEY (ancestor_id, descendant_id), KEY idx_descendant (descendant_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;比如分类2挂在分类1下面分类3挂在分类2下面那么关系表里会有(1,1,0)、(2,2,0)、(3,3,0)、(1,2,1)、(2,3,1)、(1,3,2)。查“分类1的所有子孙”只需SELECT descendant_id FROM category_tree WHERE ancestor_id 1;查“分类3的所有祖先”也只需一句SELECT ancestor_id FROM category_tree WHERE descendant_id 3;闭包表查询能力极强增删改则需要维护关系表中的一批记录数据量会比分类表本身膨胀很多。它适合大型电商类目这种层级深、查询复杂、写入相对低频的场景。2.5 我的选型建议别把简单问题复杂化回到实际开发我见过八成项目用邻接表就够了因为分类数据通常不会超过几千条一次全量取出、内存里组装树完全没压力。但当类目达到数万条、且需要频繁查“某分类下所有商品”时邻接表就不够了。我的做法是邻接表 path字段作为默认方案不搞花活真要遇到体量特别大的场景再上闭包表。嵌套集合直接不推荐除非你明确知道自己为什么需要它。3. 邻接表 path 字段的完整PHP实现3.1 数据表设计一次性到位以某商城项目的商品分类为例我通常这样建表CREATE TABLE category ( id INT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT 分类ID, parent_id INT UNSIGNED NOT NULL DEFAULT 0 COMMENT 父分类ID0表示顶级, name VARCHAR(50) NOT NULL DEFAULT COMMENT 分类名称, sort INT UNSIGNED NOT NULL DEFAULT 0 COMMENT 同级排序权重越小越靠前, status TINYINT UNSIGNED NOT NULL DEFAULT 1 COMMENT 1启用 0禁用, path VARCHAR(255) NOT NULL DEFAULT / COMMENT 祖先路径例如 /1/5/12/, PRIMARY KEY (id), KEY idx_parent_id (parent_id), KEY idx_path (path), KEY idx_sort (sort) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT商品分类表;path字段是关键。它的规则是顶级分类path为/1/二级分类为/1/5/三级分类为/1/5/12/也就是父级path拼接自身id再补一个右斜杠。保存path的时机是新增和移动分类时不是在读取时临时拼。3.2 一次遍历构建树别再循环查数据库了网上最常见的错误写法是每个分类都查一次数据库找它的子分类。几万个分类就查几万次SQL页面直接卡死。正确做法是先从表里把全部分类一次性读出来然后在PHP内存里组装树。推荐用引用法只需要一次foreach循环/** * 把扁平分类列表组装成树形结构 * param array $list 数据库查出的分类列表 * return array */ function buildCategoryTree(array $list): array { $tree []; $hash []; // 第一遍建立“id 节点”的哈希索引 foreach ($list as $row) { $row[children] []; $hash[$row[id]] $row; } // 第二遍通过引用把子节点挂到父节点下 foreach ($hash as $id $node) { $parentId (int)$node[parent_id]; if ($parentId 0 isset($hash[$parentId])) { $hash[$parentId][children][] $node; } else { $tree[] $node; } } unset($node); // 这里必须断开引用否则后续操作可能污染数据 return $tree; }这个函数不查库、不递归时间复杂度只有O(n)。它利用了PHP数组的引用机制先把所有节点放进哈希表再把每个子节点“引用”挂到父节点的children里。注意最后那个unset($node)非常重要否则之后对哈希数组的修改可能会作用到非预期的节点上。3.3 用递归组装树代码更短但要注意限制有些人更喜欢递归写法因为它顺着“找子节点”的思路走可读性更高function getChildTree(array $list, int $parentId 0, int $level 0): array { $tree []; foreach ($list as $row) { if ((int)$row[parent_id] $parentId) { $row[level] $level; $children getChildTree($list, (int)$row[id], $level 1); if (!empty($children)) { $row[children] $children; } $tree[] $row; } } return $tree; }递归写法在分类不多几百条时完全够用但如果层数非常深要小心PHP函数调用栈溢出。通常层级超过几十层实际产品里已经很少见了可一旦出现就会很尴尬。而且递归写法每次都要对全表数据做一次完整循环复杂度是O(n×depth)数据量大时不如引用法。所以我自己的代码习惯是展示用引用法构建树遍历输出时再用递归各司其职。3.4 新增、删除、移动分类的实操细节新增分类时核心是“插入后立即回写path”。比如在“手机配件”下新增“充电器”先插入记录拿到自增ID再根据父级path拼接自身pathpublic function addCategory(int $parentId, string $name): int { $parent $this-find($parentId); if ($parentId 0 !$parent) { throw new RuntimeException(父分类不存在); } $parentPath $parentId 0 ? $parent[path] : /; $sort $this-getNextSort($parentId); $this-db-beginTransaction(); $id $this-db-insert(category, [ parent_id $parentId, name $name, sort $sort, status 1, path $parentPath . $id . /, // 这里先占位待会回写 ]); // 实际写法先插入不含path记录或者直接用0占位再update $this-db-update(category, [ path $parentPath . $id . /, ], [id $id]); $this-db-commit(); return $id; }更稳妥的做法是先插入path为空串拿到自增ID后用UPDATE category SET path CONCAT(parentPath, id, /) WHERE id ?回写。要注意path回写必须和插入放在同一个事务里否则插入一半失败path为空会留下脏数据。删除分类有三种策略务必根据业务需求提前定好策略做法适合场景禁删有子节点分类检查子节点数0则抛异常数据敏感不允许误删子节点上移删除父节点子节点parent_id改成父级的父级分类层级相对简单级联删除先收集所有后代ID再批量删除分类与商品联动确定要清理级联删除需要先拿到后代ID。有了path字段这一步很轻松public function deleteCategory(int $id): void { $node $this-find($id); if (!$node) return; // 收集所有后代ID $sql SELECT id FROM category WHERE path LIKE :path; $rows $this-db-query($sql, [path $node[path] . %]); $ids array_column($rows, id); // 先处理关联表再删除分类 $this-db-beginTransaction(); $this-db-delete(product_category_rel, [category_id $ids]); $this-db-delete(category, [id $ids]); $this-db-commit(); }移动分类比较麻烦因为不仅当前节点的parent_id要变它整棵子树的path前缀都要跟着变。假设把分类Apath/1/5/移动到分类Bpath/10/20/下面那么A子树所有节点的path都要把开头的/1/5/替换成/10/20/5/。实际操作时先收集A子树所有节点然后逐条重算path放进一个事务里执行。移动的同时还要防止把节点移动到自己的后代下面导致循环这个校验必须在移动前做如果目标父节点的path以当前节点的path开头直接拒绝。3.5 面包屑导航与子分类统计有了path字段面包屑导航非常简单把path两边的斜杠去掉再按/拆开就行public function getBreadcrumb(int $id): array { $node $this-find($id); if (!$node) return []; $ids array_values(array_filter(explode(/, $node[path]))); $rows $this-db-select(category, [id $ids]); // 按ids顺序重新排列 return $rows; }子分类统计商品数时以前用邻接表要一层层递归找所有子分类现在这样写SELECT COUNT(*) FROM product WHERE category_id IN ( SELECT id FROM category WHERE path LIKE /1/5/% );需要注意的是LIKE /1/5/%会用到path字段上的索引吗MySQL对VARCHAR的like前缀匹配通常能走索引但字段长度255、数据量大之后索引效果会打折扣。如果商品分类有数十万条我更推荐按闭包表来设计或者干脆在商品表冗余一个分类path字段查询时直接like商品表的path省去子查询和大表关联。4. 无限分类编码中容易栽的五个坑4.1 N1查询递归写爽了数据库哭了最典型的反面教材就是在查询子节点时每层都执行一次数据库查询function getChildren(int $pid): array { $rows $db-query(SELECT * FROM category WHERE parent_id $pid); foreach ($rows as $row) { $row[children] getChildren($row[id]); // 每一层都查库 } return $rows; }分类有几千条页面就可能执行几千条SQL。解法就是我在3.2节写的一次装载、内存构建树。只要树的总节点数在几万以内这个方法都够快。如果几万以上就要考虑缓存整棵树或者部分子树。4.2 孤儿数据删了父级子级成了野节点纯邻接表删除父级后子级的parent_id还指着已经不存在的数据前端树形结构就断了。解决方案有三层数据库外键约束自引用外键应用层删除前检查子节点数量或者使用软删除status字段标记禁用不物理删除。我个人的建议是业务允许的情况下用软删除最安全误操作还能恢复。如果要物理删一定按3.4里的级联删除流程处理。4.3 循环引用A挂在B下面B挂在A下面这种情况通常来自移动分类或编辑parent_id时没有校验。一旦出现循环引用构建树时就会出现无限循环甚至死循环。防范手段有两个移动节点前检查“目标父节点是否是自己或自己的后代”以及构建树时加一个深度上限超过50层直接中止。if (strpos($targetParentPath, $currentNodePath) 0) { throw new RuntimeException(不能移动到自己的子节点下面); }4.4 层级过深时的SQL与PHP双重压力层级太深除了递归栈问题SQL查询也会变长。比如用path字段时一条path可能非常长存储空间浪费倒在其次关键是LIKE匹配的索引效率下降。如果单条path超过250字符建议把字段类型改成VARCHAR(500)并控制业务上的最大层级深度。真遇到过很深树结构且对查询要求极高的那就直接上闭包表用关系表的索引换取查询效率。4.5 同级排序调整sort字段不能随便排分类列表通常要求同级之间可以拖动排序。我的习惯是sort用一个整数同级内按sort升序带来两个小问题。第一新增分类时排序值要取“同级最大值1”别直接用当前时间戳否则容易溢出。第二同级内调整顺序时不要只改当前节点要把同级别的所有节点统一重排否则会出现“两个节点sort相同”的怪象public function sortCategory(int $parentId, array $orderedIds): void { $sort 10; $this-db-beginTransaction(); foreach ($orderedIds as $id) { $this-db-update(category, [sort $sort], [id $id]); $sort 10; } $this-db-commit(); }我习惯sort每次步进10而不是1给自己留出插队的空档不会频繁触发重排。5. 什么时候该升级分类方案按场景选型不硬扛5.1 后台菜单、文章栏目邻接表path就够了后台管理端的菜单、文章栏目、权限节点一般就几百到几千条修改频率不高。使用邻接表path配合3.2的构建树函数已经能覆盖所有需求。不要为了炫技去上闭包表增加维护成本不说收益也不明显。5.2 电商前台类目闭包表或“邻接表缓存”二选一大型电商前台类目的特点是分类总量大、查询频率极高、层级可能超过十几层且用户频繁需要查看“某个分类下所有商品”。这时最稳的方案是闭包表查询任意子树的代价恒定且能通过关系表快速算商品数。闭包表的维护脚本要写好尤其是移动分类时关系表需要重新计算整棵子树的关系建议用定时任务或者异步队列来做避免在HTTP请求里同步处理大数据量。如果项目不想引入闭包表的复杂度另一个折中方案是“邻接表path缓存”。把构建好的整棵树用缓存Redis或本地内存存起来设置过期时间。分类改动的接口里主动删缓存下一次请求再重建。这样前者考虑的是“查询复杂”后者考虑的是“改动成本低”各有取舍。5.3 大数据量下的一个优化思路商品表冗余path很多项目卡在“查某个分类下的所有商品”而不是“查分类树”。如果分类和商品的关系是商品只属于一个叶子节点那么最简单有效的方法是在商品表加一个category_path字段保存商品所属分类path。查询“分类1下所有商品”就可以直接SELECT * FROM product WHERE category_path LIKE /1/%;这样连子查询都不需要了商品入库时写入分类path分类移动时用UPDATE语句批量修改“属于该子树的商品path”。这个方案是我在实际项目中验证过很实用的优化手段尤其适合数据量上百万且查询性能吃紧的场景。个人建议永远不要在商品表里存parent_id一定要存完整的path因为商品的过滤条件基本都是“某个分类及其所有子分类”。走到这里整个无限分类从表设计到PHP实现已经串起来了。我自己的体会是不要试图用一个万能的模型解决所有问题先看数据量再看读写比例最后才选方案。分类功能看起来小但它是很多系统的骨架表结构一旦定下来后面改动的成本远比你想象中大。如果手头的老项目还在用纯递归查库先加上path字段再把取树改成一次装载往往能立竿见影。最后再分享一个小习惯分类表里的path字段永远两边都带斜杠这是我用一次误匹配换来的一次教训。