ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

PHP实现无限级产品分类管理:树构建、递归查询与缓存优化全解

2026/9/28 11:13:50 拓冰建站 浏览量
PHP实现无限级产品分类管理:树构建、递归查询与缓存优化全解 接手过一个已经跑了两三年的电商项目产品表、订单表都好好的唯独产品分类这块越用越别扭后台加三级分类要改代码面包屑导航慢到能喝完一杯水最要命的是删除一个父分类下面挂着的几百个商品瞬间变成“无家可归”。说实话分类管理这种功能看起来就是个增删改查但真正把它拆开看里面全是树结构、递归、数据一致性这些硬骨头。这次我借着重构老项目的机会把“产品分类管理”从表结构设计到 PHP 侧的核心类实现完整过了一遍包括无限级树的构建、面包屑查询、防死循环校验、删除策略和缓存优化。这篇文章就用“庖丁解牛”的方式把每个关键部位都剖开讲清楚给马上要动手写分类模块的 PHP 同行一个可以直接参考的完整思路。1. 表结构设计parent_id、level、path 三个字段各解哪块骨头1.1 先想清楚分类到底要支持哪些操作很多新手一上来就建表第一版往往是id、name、parent_id三个字段等到真做功能才发现处处受制。我建议动手写 SQL 之前先列一下产品分类这个模块必须支持的操作清单后台新增顶级分类也可以在任意分类下新增子分类层级不限制同级分类之间可以拖拽排序前台展示完整的面包屑路径比如“家用电器 厨房电器 电饭煲”商品列表按分类筛选时勾选父分类能带上所有子孙分类的商品删除父分类时不能把商品搞丢也不能留着一堆悬空的子分类后台菜单树要能一次请求渲染出完整层级如果你确定业务永远只有三级分类那确实可以直接设计成category1_id、category2_id、category3_id三个字段查询和展示都极其简单。但绝大多数业务都会从“固定三级”演变成“不固定层级”等你上线半年后再改表迁移数据的成本就高了。所以我的做法是直接上一张标准的无限级分类表成本很低但扩展空间大得多。1.2 category 表的完整设计我重构后用的表结构是这样的CREATE TABLE product_category ( id int unsigned NOT NULL AUTO_INCREMENT, parent_id int unsigned NOT NULL DEFAULT 0 COMMENT 父分类ID0表示顶级, name varchar(100) NOT NULL DEFAULT COMMENT 分类名称, level tinyint unsigned NOT NULL DEFAULT 1 COMMENT 层级深度顶级为1, path varchar(500) NOT NULL DEFAULT COMMENT 祖先路径格式 /1/3/7/, sort_order int NOT NULL DEFAULT 0 COMMENT 同级排序越小越靠前, is_visible tinyint NOT NULL DEFAULT 1 COMMENT 是否前台可见, created_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_parent_id (parent_id), KEY idx_sort_order (sort_order) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT产品分类表;parent_id和name不用多说重点说说另外几个字段为什么必须有。level字段存的是当前分类的层级深度顶级分类是 1子分类是 2以此类推。这个字段在渲染后台列表时可以直接用来决定缩进宽度不用每次现算。更重要的是前端下拉框展示分类时level能帮你快速决定选项前要拼接几个缩进符。path字段存的是从根到当前节点的完整路径格式约定为/父id/子id/孙id/比如 id7 的分类父链是 1 - 3 - 7那 path 就是/1/3/7/。顶级分类的 path 是/7/。这个字段的主要用途是快速查询某个分类的祖先链以及所有子孙节点后面讲面包屑和子树聚合时会详细展开。很多教材级的分类表设计只聊parent_id但实际项目里path带来的查询效率提升非常明显。sort_order是同级排序字段后台拖拽排序时只需要update这一列。这里有个隐藏细节sort_order只需要保证同 parent 下有序不需要全局唯一所以索引我建的是idx_parent_id和idx_sort_order分开建查询时用WHERE parent_id ? ORDER BY sort_order ASC, id ASC就够了。1.3 level 和 path 不是冗余是典型的空间换时间我见过不少开发者对level和path持保留态度理由是“这俩字段都要在移动节点时同步维护太麻烦”。但如果你不做冗余每次查询面包屑都得从当前节点一层一层向上查数据库查一个三级分类就要执行三次查询100 个商品详情页就是 300 条 SQL这个代价远高于增量维护两个字段。正确的姿势是把level和path当作可以容忍的冗余在新增、移动、删除分类时统一维护。后面第 3 章和第 4 章会给出具体的维护逻辑。数据库字段冗余不可怕只要能保证写入时同步更新换来的查询效率提升是绝对划算的。1.4 商品表与分类表的关联方式商品表我建议只保留一个category_id不要做多对多。原因是实际业务中商品虽然可能同时属于多个分类比如“电饭煲”既属于“厨房电器”也属于“促销专区”但做主分类的产品分类管理时一个商品的主归属路径应该是唯一的否则统计报表和面包屑都会混乱。CREATE TABLE product ( id int unsigned NOT NULL AUTO_INCREMENT, category_id int unsigned NOT NULL DEFAULT 0 COMMENT 主分类ID, name varchar(200) NOT NULL DEFAULT , -- 其他商品字段省略 PRIMARY KEY (id), KEY idx_category_id (category_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;这里故意不建物理外键只用普通索引加category_id字段。原因是电商项目里商品表通常数据量很大物理外键在批量导入、分库分表场景下会造成很多约束麻烦而且删除分类时我们希望用应用层逻辑来控制“商品怎么办”而不是让数据库帮你级联删除。这个取舍在第四章展开。2. 核心算法一次查询把分类表构建成无限级树2.1 最朴素的递归查库写法以及它为什么是性能黑洞很多人在没有系统性整理之前写分类树都是这种写法function getChildren($parentId) { $list DB::table(product_category) -where(parent_id, $parentId) -orderBy(sort_order) -get(); foreach ($list as $item) { $item-children getChildren($item-id); } return $list; } $tree getChildren(0);这段代码逻辑上没错但性能上有大问题。每次递归都会执行一次数据库查询一个有 100 个节点的分类树如果平均每个节点有一个子节点可能需要执行 100 次以上的 SQL。这还没算上商品列表页同时还要查商品表。分类数据量少的时候你感觉不到等分类涨到几百上千个后台展开一次分类树就能把数据库连接池打满。这就是典型的 N1 查询问题。解决方案不是去优化单条 SQL而是改变查询策略一次把整张表捞出来在 PHP 内存里构建树结构。2.2 一次取出整表用 PHP 数组引用构建树这是我最终采用的方案核心是利用 PHP 数组的引用特性把父子关系在内存里直接挂接好public function buildTree(): array { $items DB::table(product_category) -orderBy(sort_order) -orderBy(id) -get() -keyBy(id); $nodes []; foreach ($items as $id $item) { $node $item-toArray(); $node[children] []; $nodes[$id] $node; } $tree []; foreach ($nodes as $id $node) { $parentId $node[parent_id]; if ($parentId 0 isset($nodes[$parentId])) { $nodes[$parentId][children][] $node; } else { $tree[] $node; } } unset($node); return $tree; }关键点有两个第一keyBy(id)之后$items以分类 ID 为键这样在挂接父子关系时可以直接通过$nodes[$parentId]命中父节点时间复杂度是 O(1)。如果不用keyBy每一步都要遍历数组找父节点总复杂度变成 O(n²)数据量大时一样卡。第二foreach中$node是引用遍历往$nodes[$parentId][children]里塞进去的是子节点的引用而不是一份拷贝。这样最终$tree里任意节点和$nodes里的节点是同一份数据后续修改不会出现“改了一个副本另一个没变”的诡异问题。unset($node)这行很多人会漏掉。foreach 里用了引用后循环结束$node仍然指向数组中最后一个元素后续如果再对$node赋值会意外修改原数组PHP 官方文档里专门有警示。我习惯上每次用引用遍历完都立刻 unset避免后面的代码踩坑。2.3 childrenMap 迭代遍历的替代方案引用构建法虽然简洁但对初学者来说引用的语义容易绕晕。如果项目里对代码可读性要求比较高也可以换成两层childrenMap的写法public function buildTree(): array { $items DB::table(product_category) -orderBy(sort_order) -orderBy(id) -get() -toArray(); $childrenMap []; foreach ($items as $item) { $childrenMap[$item-parent_id][] $item; } $tree $this-buildTreeNodes($childrenMap, 0); return $tree; } private function buildTreeNodes(array $childrenMap, int $parentId): array { $branch []; foreach ($childrenMap[$parentId] ?? [] as $item) { $item-children $this-buildTreeNodes($childrenMap, $item-id); $branch[] $item; } return $branch; }这种方案先扫描一遍列表把每个 parent_id 下的节点分组到$childrenMap然后递归拼装。这里虽然也有递归但每次递归不再访问数据库只是遍历内存数组性能没有问题。childrenMap方案还有个额外好处你能非常方便地获取某个父分类的直接子节点列表这在后台做“下钻式”分类管理时特别好用。比如只展开一级分类点击某个分类再加载它的子分类只需要$childrenMap[$id] ?? []一次内存取值。2.4 从树到实际展示下拉框缩进列表和后台树列表构建好的$tree是嵌套数组直接用于后台树形列表和前端递归菜单都合适。但如果要做“选择父分类”的下拉框需要把树拍平成带缩进等级的线性列表public function getFlatOptions(): array { $tree $this-buildTree(); $options []; $walk function ($nodes, $depth 0) use ($walk, $options) { foreach ($nodes as $node) { $options[] [ id $node[id], name str_repeat( , $depth) . $node[name], level $node[level], ]; $walk($node[children], $depth 1); } }; $walk($tree); return $options; }这里用闭包写递归拍平str_repeat( , $depth)用来生成全角空格缩进比直接用-前缀在表单里更整齐。需要注意如果分类层级很深递归拍平可能会导致 PHP 栈溢出一般设置最大层级限制是更稳妥的做法。我在后台新增分类时会把 level 限制在 10 层以内超过的提示“分类层级过深”既保护代码也保护业务数据的可控性。3. 挂载与展示面包屑、子树聚合和商品归属的细节处理3.1 面包屑导航用 path 字段一次算出祖先链商品详情页的面包屑是最容易被忽视性能的地方。我见过最慢的写法先查商品拿到 category_id然后循环往上查父分类每查一次执行一条 SQL三级分类就是三次查询遇到五级分类就是五次。重构之后直接用path字段解决public function getBreadcrumb(int $categoryId): array { $category DB::table(product_category)-find($categoryId); if (!$category) { return []; } $ids array_values(array_filter(explode(/, $category-path))); // 示例path /1/3/7/explode 得到 [, 1, 3, 7, ]过滤后是 [1, 3, 7] $map DB::table(product_category) -whereIn(id, $ids) -pluck(name, id); $crumb []; foreach ($ids as $id) { $crumb[] [ id $id, name $map[$id] ?? 未知分类, ]; } return $crumb; }通过explode把 path 拆成 ID 数组再用一条whereIn查询把所有层级的分类名称取回来pluck之后直接映射成 key-value最后按 path 顺序拼接。整个面包屑计算只执行一次查询。这里有个约定要特别注意path必须以根节点到当前节点的完整顺序存储顶级分类的 path 就是/7/这种格式不能漏掉自身 id。否则面包屑最后一级会丢。我在处理新建分类时先用父级 path 拼接当前 id再回写两个字段public function createCategory(array $data): int { $parentId (int)$data[parent_id]; $parent DB::table(product_category)-find($parentId); if ($parent) { $level $parent-level 1; $path $parent-path . $data[id] . /; } else { $level 1; $path / . $data[id] . /; } // 注意这里需要先插入拿到自增 id再补 path $id DB::table(product_category)-insertGetId([ parent_id $parentId, name $data[name], level $level, path $path, sort_order $data[sort_order] ?? 0, ]); DB::table(product_category)-where(id, $id)-update([path / . $id . /]); return $id; }这里我先用insertGetId拿到自增 id再补 path因为 path 里必须包含自身 id。还有一种做法是提前生成一个自定义 id但会让主键策略复杂化所以最终还是选择了“先插入、再补 path”这种两段式。下单并发不高的情况下完全够用。3.2 分类迁移时最容易被忽视的防死循环校验后台拖拽分类、或者编辑分类时更换父级最危险的操作是把一个分类挂到它的子孙分类下面。举例A 是顶级分类B 是 A 的子分类C 是 B 的子分类现在把 A 的父级改成 C树结构直接变成环递归查询会死循环整个后台分类菜单直接雪崩。判断逻辑其实很简单目标父分类的 path 里如果已经包含了当前分类的 id就不能挂过去。用path一次就能判断public function assertCanMove(int $categoryId, int $newParentId): bool { if ($newParentId 0) { return true; // 移到顶级永远安全 } $current DB::table(product_category)-find($categoryId); $newParent DB::table(product_category)-find($newParentId); if (!$current || !$newParent) { return false; } if ($categoryId $newParentId) { return false; } // 新父级的祖先链里不能出现自己 $ancestorIds array_values(array_filter(explode(/, $newParent-path))); if (in_array($categoryId, $ancestorIds)) { return false; } return true; }注意这个判断还有一个隐藏条件$current本身也可能带着一整棵子树所以光看新父级的祖先链还不够还得防止另一个方向——把当前分类移动到它自己的父亲原来的位置这类操作不会有环但把当前分类移动到它的子分类上会出现环。上面这段代码已经覆盖了这个场景因为子孙节点的 path 一定包含当前分类 id只要新父级的 path 里出现当前 id 就拦截。我在后台接口里加了这层校验之后顺手还加了一条兜底策略每次移动操作前重新校验整张表的path是否和parent_id关系一致防止历史脏数据进入循环。后面第 5 章会讲我写的自查 SQL。3.3 按父分类筛选商品要不要包含子分类的商品这是产品分类管理里最容易引起产品经理和开发争论的需求用户在列表页勾选“家用电器”到底是只看挂在“家用电器”这个分类下的商品还是把“厨房电器”“电视”这些子分类下的商品全部算进来两类需求的实现方式完全不同。如果是“仅当前分类”查询是最简单的WHERE category_id ?。如果是“当前分类 所有子分类”最稳妥的实现是先把子分类 id 列表取出来再拼whereInpublic function getSubtreeIds(int $categoryId): array { $category DB::table(product_category)-find($categoryId); if (!$category) { return []; } // 用 path 前缀匹配 $pathPrefix $category-path; $rows DB::table(product_category) -where(path, like, $pathPrefix . %) -orWhere(id, $categoryId) -get([id]); return $rows-pluck(id)-toArray(); }利用 path 的字符串前缀特性一条 SQL 就能拿到整棵子树的 id 列表。但要注意like pathPrefix%的模糊查询在数据量大时可能走全表扫描所以这个方案适合分类表在几千条以内的业务。真到了几万条分类的规模建议给 path 字段加前缀索引或者退化到缓存方案这点第 5 章会展开。还有个更激进的做法在商品表冗余一个category_path字段每次商品入库或分类移动时把完整树路径写进去查询时直接WHERE category_path LIKE /1/3/%可以完全避免先查子树再查商品的两次查询。代价是商品表会多一列冗余数据而且分类移动时要批量更新所有挂在子树下的商品事务范围变大。我的经验是中小项目先不用商品表冗余 path等真的出现“按大分类聚合统计商品”的慢查询时再上不迟。3.4 移动分类后level 和 path 的同步刷新移动分类的核心动作改的是parent_id但level和path也必须跟着更新。最直接的做法是先移动目标节点自身再递归更新其所有子孙节点。我用一个迭代队列代替递归避免深树时递归栈过深public function moveCategory(int $categoryId, int $newParentId): void { DB::beginTransaction(); try { $current DB::table(product_category)-find($categoryId); $newParent DB::table(product_category)-find($newParentId); $newLevel $newParent ? $newParent-level 1 : 1; $newPathPrefix $newParent ? $newParent-path : /; // 更新当前节点 DB::table(product_category) -where(id, $categoryId) -update([ parent_id $newParentId, level $newLevel, path $newPathPrefix . $categoryId . /, ]); // BFS 更新所有子孙节点 $queue [$categoryId]; while ($queue) { $pid array_shift($queue); $parent DB::table(product_category)-find($pid); $children DB::table(product_category) -where(parent_id, $pid) -get([id, path, level]); foreach ($children as $child) { $childNewPath $parent-path . $child-id . /; DB::table(product_category) -where(id, $child-id) -update([ path $childNewPath, level $parent-level 1, ]); $queue[] $child-id; } } DB::commit(); } catch (\Throwable $e) { DB::rollBack(); throw $e; } }这里面有个重要的顺序问题必须先从当前节点开始更新再一层一层往下走因为子节点的 path 依赖父节点更新后的 path。用 BFS 队列而不是递归好处是每一层都能及时拿到上层更新后的 path且不会因为 PHP 递归深度限制而中断。数据量大时可以把“查询子节点 更新子节点”改成批量处理但 99% 的场景下这个队列版本已经够快。4. 删分类和批量移动比新增更难的两道坎4.1 删父分类的三种策略级联、上移、禁止删除分类是所有操作里最容易出事故的。同样是“删除‘家用电器’”不同业务预期不同有的希望子分类一起删掉有的希望子分类变成顶级分类有的希望有商品的分类根本删不掉。下面这张表是我做过的项目里最常见策略对比策略行为优点缺点适用场景级联删除子分类、孙分类全部删除实现简单可能误删大量商品分类数据分类结构可随时重建的内部工具子级上移子分类的 parent_id 改为被删分类的 parent_id保留子分类层级自动提升多级深度会逐渐变平失去树形语义后台误操作频繁、分类层数不敏感禁止删除有子分类或商品时拦截删除必须先清空数据最安全操作成本高电商前台分类、有商品结算的场景我最终给电商主站选的是混合策略有商品挂载的分类禁止删除没有商品但有子分类的允许删除但子分类全部上移既没有商品也没有子分类的直接物理删除。这套规则在业务上最合理代码实现也用不到太复杂的逻辑public function deleteCategory(int $categoryId): void { $productCount DB::table(product)-where(category_id, $categoryId)-count(); if ($productCount 0) { throw new \RuntimeException(该分类下还有商品不能删除); } $childIds DB::table(product_category) -where(parent_id, $categoryId) -pluck(id); DB::beginTransaction(); try { if ($childIds-isNotEmpty()) { $parentRow DB::table(product_category)-find($categoryId); DB::table(product_category) -whereIn(id, $childIds) -update([ parent_id $parentRow-parent_id, // 注意子分类的 path 和 level 必须重新计算 ]); // 对每个上移的子分类递归刷新 path 和 level foreach ($childIds as $childId) { $this-moveCategory($childId, $parentRow-parent_id); } } DB::table(product_category)-where(id, $categoryId)-delete(); DB::commit(); } catch (\Throwable $e) { DB::rollBack(); throw $e; } }这里我复用了第三章的moveCategory来刷新上移子分类的 path 和 level避免两套维护逻辑。有一点容易踩坑检查商品数量时只查了直接挂在该分类下的商品如果子分类上移后带着商品那这些商品也会随之变成更上层分类的一部分这是符合业务预期的。但如果你只想删掉这个分类本身、不想让子分类的商品被“带到上级”那策略要改成“有子分类就不允许删”别混着来。4.2 外键到底建不建RESTRICT 与 SET NULL 的取舍我特意在商品表里没有建物理外键因为物理外键在删除分类时会对数据库加锁而且不同的删除策略对应不同的外键行为直接用数据库约束反而写死了。如果你倾向于建物理外键两种选择ON DELETE RESTRICT与“禁止删除有商品的分类”对应有商品挂载时数据库层面直接阻止删除ON DELETE SET NULL允许删除分类商品表的category_id自动置 NULL对应“商品允许无分类”的业务RESTRICT 在代码里还要再兜一层因为如果你先删了分类再插入商品应用层拿到的分类已经不存在RESTRICT 约束只是防止删除没保护的记录。SET NULL 有个麻烦商品列表页对category_id为 NULL 的记录要额外处理很多查询会漏掉这些“无分类商品”。我的实践经验是物理外键在中小项目里用 RESTRICT 加代码校验没问题但真到高并发、分库分表场景物理外键往往是第一个被去掉的东西所以我还是推荐用普通索引 应用层规则管理。4.3 批量移动分类时的事务与顺序问题后台管理界面经常出现“勾选多个分类移动到某个新父级下”的批量操作。批量移动比单条移动多两个坑一是每个节点都要做防环校验二是处理顺序必须是从浅层到深层否则父子关系会乱。public function batchMove(array $categoryIds, int $newParentId): void { DB::beginTransaction(); try { foreach ($categoryIds as $categoryId) { if (!$this-assertCanMove((int)$categoryId, $newParentId)) { throw new \RuntimeException(分类 {$categoryId} 不能移动到目标父级下); } } // 先按 level 升序排保证浅层先移动 $rows DB::table(product_category) -whereIn(id, $categoryIds) -orderBy(level, asc) -get(); foreach ($rows as $row) { $this-moveCategory((int)$row-id, $newParentId); } DB::commit(); } catch (\Throwable $e) { DB::rollBack(); throw $e; } }为什么必须按 level 升序假设你要把 A、B 两个分类都移动到 C 下面而 B 是 A 的子分类。如果先移动 BB 的 path 会变成 C 的 path 的子路径接着移动 AA 的 path 又依据 C 的 path 重新生成。最终 A 和 B 的关系就断了因为移动 B 时它的 path 是按旧结构算的。先移动浅层的 A再处理深层 BB 才能正确挂到 A 下面。这个顺序问题用代码注释标出来都容易被后人忽略我在这个坑上debug过整整一个下午。另外批量移动时同一个moveCategory会反复执行 BFS如果一次移动几百个节点SQL 数量会爆炸。我的优化做法是一次性取出所有受影响节点基于内存里的$childrenMap统一重算 path 和 level再批量 update。但为了代码可读性项目里如果批量数量不大几十个以内直接循环moveCategory配合事务就够用了没必要为极端情况牺牲维护性。5. 数据量增长后的优化缓存整树与快速检索路径的取舍5.1 缓存整棵分类树Redis 缓存与失效策略分类数据最典型的特点是“读多写少”。整个后台和前台都在反复读分类树但真正写入分类的只有管理员偶尔操作一下。这种情况下最有效的优化就是缓存整棵树的构建结果。我把 buildTree 的结果序列化后放进 RedisKey 设计成product_category_tree_v1缓存过期时间设成一天。每次后台对分类进行新增、修改、移动、删除操作后主动删除这个缓存 Key下一次请求再重新构建public function getCachedTree(): array { $key product_category_tree_v1; $cached Redis::get($key); if ($cached) { return json_decode($cached, true); } $tree $this-buildTree(); Redis::setex($key, 86400, json_encode($tree)); return $tree; } public function clearCategoryCache(): void { Redis::del(product_category_tree_v1); }这里有个细节缓存失效的时机必须在事务提交成功之后否则事务回滚了缓存却删了下一次请求会把旧数据重新构建到新缓存里和数据库就不一致了。我的控制器里会在 catch 到异常后再判断是否需要清理缓存。如果项目没有 Redis也可以用 PHP 的静态变量做请求级缓存。比如同一个请求里后台列表和下拉框都要用分类树第二次调用 buildTree 直接复用第一次的结果private static $treeCache null; public function getCachedTree(): array { if (self::$treeCache null) { self::$treeCache $this-buildTree(); } return self::$treeCache; }这个方案不能跨请求共享但能显著减少单个请求内的重复查询。5.2 path 字段做前缀检索时的索引陷阱用 path 做LIKE /1/3/%查询虽然方便但 MySQL 对%开头的模糊查询无法走普通索引而对不以 % 开头的字符串前缀是可以走索引的。/1/3/%你的条件本身就是确定的前缀只要给 path 字段建索引MySQL 是可以利用索引范围扫描的。但问题在于如果你在商品表冗余了category_path字段并且查询写成了WHERE category_path LIKE %/3/%那百分号在两边索引必废。所以规则是冗余字段的存储格式必须严格保持根到自身的顺序查询时前缀必须是确定的值不要写%开头的条件。另外商品表的category_path冗余字段更新逻辑比分类表复杂得多。一次分类移动可能需要批量 update 成千上万行商品记录。我的建议是如果商品量已经超过百万不要在业务表里做这种批量更新而是把“按子树 id 查商品”拆成两步——先查子树 id 列表这个走 Redis 缓存再用whereIn(category_id, $ids)限制查询范围配合分页。实际性能在百万级商品表上也是可接受的没必要为了省一次查询引入那么重的冗余维护成本。5.3 什么时候值得升级到闭包表或嵌套集邻接表 path 冗余的组合已经能覆盖绝大多数产品分类管理需求。但当业务复杂到一定程度比如分类层级经常变动、频繁需要查询任意两个节点的祖先/后代关系、分类本身也是强大的权限维度时可以考虑升级方案嵌套集模型左右值查询子树极快一条 SQL 就能拿到整棵子树但插入、移动节点的代价非常高需要更新大量节点的左右值。适合“分类结构基本固定、查询极重”的场景典型例子是静态菜单树。闭包表单独建一张表记录所有祖先-后代关系查询任意层级的子树和祖先链都是简单的主键关联代价是空间占用大插入和移动节点时需要批量插入关系记录。适合需要频繁做“可达性”判断的复杂规则场景。我的建议是如果你的项目分类数量在几千条以内先别折腾这些进阶模型把邻接表 path 缓存做到位已经非常能打。真到了分类几万条、树深几十层、后台每次拖拽都要秒级完成的规模再考虑闭包表。那时你大概率还需要引入专门的数据结构团队或中间件单靠业务库硬扛并不是最佳解法。分类管理这个模块我重构过很多次最后的体会是代码量其实不大真正的难点全在边界条件——移动节点时会不会成环、删除父级后子分类怎么办、刷新 path 时顺序对不对。如果你也是半路接手这种模块我建议先别急着改代码执行三条 SQL 自查-- 1. 查有没有成环正常情况下 path 里不该出现当前 id 之外的祖先和自身 id 交替 SELECT id, parent_id, level, path FROM product_category WHERE path NOT LIKE CONCAT(/%/, id, /%); -- 2. 查有没有悬空父级 SELECT c.id, c.name FROM product_category c LEFT JOIN product_category p ON c.parent_id p.id WHERE c.parent_id 0 AND p.id IS NULL; -- 3. 查 level 与实际 path 深度是否一致 SELECT id, level, path, (LENGTH(path) - LENGTH(REPLACE(path, /, )) - 1) AS actual_level FROM product_category WHERE level (LENGTH(path) - LENGTH(REPLACE(path, /, )) - 1);先看有没有历史脏数据再对照这篇文章里的思路去改表结构、封装服务类。顺序千万别搞反否则你会在一个不完整的数据模型上反复打补丁越补越乱。