分类树设计(邻接表、路径枚举、嵌套集、闭包表、混合方案)
通过这篇文章你将了解到分类树设计时要考虑的问题,常见的设计方案,如邻接表、路径枚举、嵌套集、闭包表、混合方案等,包括每个方案的存储设计、代码示例、优缺点分析等,以及这些设计方案的演化过程。

前言:
分类树是某些系统中的核心模块,尤其像淘宝/京东等电商系统。其是整个系统的组织架构、导航体系和基础设施。对用户来讲,其便于快速浏览或检索商品,降低了决策成本;同时,系统可通过分析用户历史数据来构建用户画像,从而使用智能推荐来提高用户满意度。对平台来讲,其便于商品的管理,更易于进行营销活动。本文将从设计分类树时要考虑的问题、常见的设计方案及其演进过程等方面来讨论如何设计分类树。
目录
1 设计时要考虑的问题
设计分类树时可能需要考虑以下几个问题:
- 层级:这是分类树最重要的一个特点,层级或树的深度会直接影响系统的性能、可扩展性和可维护性。虽然在通常业务场景下不会超过 3 级或 4 级,但站在设计者的角度,我们总是希望尽最大可能满足系统的非功能性需求,所以一般情况下会设计为无限极。
- 存储:分类树的存储结构如何设计,才能满足系统的性能、可扩展性和可维护性,同时亦可降低系统复杂度。
- 维护:如增加、删除、修改和移动等操作,考虑到分类树维护频率较低,故对其性能要求也较低。
- 查询:分类树被使用最多的操作,如查询整棵树、子节点、父节点和同级节点等,同时对查询性能可能要求较高。
2 常见设计方案
常见的分类树设计方案及其特点和适用场景如下:

接下来通过详细介绍上述各种设计方案,并以下图所示分类树结构为例,来描述分类树设计的演进过程。

3 邻接表
邻接表特点是每个节点都记录其父节点的ID,是最简单直观的设计,是最早期最常见的分类树设计方案。
3.1 存储设计
分类表 category ,建议为 parent_id 字段建立索引,表结构为:
| 字段名 | 字段类型 | 字段描述 | 备注 |
|---|---|---|---|
| id | BIGINT | 主键 | 自增 |
| parent_id | BIGINT | 父节点 ID | 0 表示根节点 默认值 0 |
| name | VARCHAR | 名称 | |
| sort_order | INT | 排序 | 同层内排序 默认值 0 |
| level | INT | 层级 |
示例数据为:
| id | parent_id | name | sort_order | level |
|---|---|---|---|---|
| 1 | 0 | 电子产品 | 0 | 1 |
| 2 | 1 | 手机 | 0 | 2 |
| 3 | 1 | 电脑 | 1 | 2 |
| 4 | 2 | 华为 | 0 | 3 |
| 5 | 2 | 苹果 | 1 | 3 |
| 6 | 3 | 笔记本 | 0 | 3 |
| 7 | 3 | 台式机 | 1 | 3 |
3.2 常用操作
3.2.1 查询直接子节点
# 查询指定节点的直接子节点 简单高效
select * from category where parent_id = 2;
3.2.2 查询所有子节点
若 mysql 版本为 8.0+ 则可使用递归公共表达式 CTE:
with recursive sub_tree as (
-- 初始查询(即指定节点)
union all
-- 递归查询(即指定节点的子节点)
)
select * from sub_tree;
# 如查询指定节点 '手机' 的所有子节点
with recursive sub_tree as (
select * from categtory where id = 2
union all
select c.* from category c inner join sub_tree st on c.parent_id = st.id
)
select * from sub_tree where id != 2;
注:CTE 表达式在大数据量情况下会有性能瓶颈,可通过对 parent_id 字段建立索引来提高查询性能,但随着数据量的增长,依然会有性能瓶颈。
若 mysql 版本为 5.7 及以下或你使用的数据库不支持 CTE,则需要在应用层递归。如下:
// 先获取所有数据 然后在内存中递归处理 伪代码如下:
public List<Category> buildTree(long parentId) {
// 获取所有节点记录
List<Category> list = categoryMapper.selectList(new LambdaQueryWrapper<Category>());
List<Category> tree = new ArrayList<>();
for (Category parent : getFirstLevelChildren(parentId, list)) {
tree.add(buildChildTree(parent, list));
}
return tree;
}
// 获取指定节点的一级子节点
public List<Category> getFirstLevelChildren(long parentId, List<Category> list) {
return list.stream().filter(item -> Objects.equals(parentId, item.getParentId()))
.collect(Collectors.toList());
}
// 构建指定节点的子树
public Category buildChildTree(Category parent, List<Category> list) {
List<Category> children = new ArrayList<>();
for (Category child : list) {
if (Objects.equals(parent.getId(), child.getParentId())) {
children.add(buildChildTree(child, list));
}
}
parent.setChildren(CollectionUtils.isEmpty(children) ? null : children);
return parent;
}
3.2.3 查询节点路径
查询节点路径有两种方案可选,一是递归访问数据,二是递归处理数据(类似于 查询所有子节点 操作中的第二种方案)。其中第一种方案伪代码如下:
List<Category> path = new ArrayList<>();
// 获取指定节点
Category current = categoryMapper.selectById(categoryId);
// 递归获取父节点
while (current != null && current.getParentId() != 0) {
current = categoryMapper.selectById(current.getParentId());
if (current != null) {
path.add(0, current);
}
}
3.3 优缺点分析
3.3.1 优点
- 设计简单、容易理解;
- 增加、删除、修改和移动操作简单;
- 占用存储空间小;
3.3.2 缺点
- 查询子树需要递归或多次查询,性能差;
- 查询路径需要递归或多次查询,性能差;
- 高并发或大数据量情况下数据库压力大;
3.3.3 适用场景
- 层级固定或较浅(如 1 ~ 3 层);
- 写多读少;
- 小型项目,数据量 < 1万;
4 路径枚举
路径枚举是指每个节点都存储根节点到当前节点的完整路径,通常为 path 字段。解决了邻接表的递归查询问题,是邻接表的第一次重要改进。
4.1 存储设计
分类表 category ,建议为 path 字段建立索引,表结构为:
| 字段名 | 字段类型 | 字段描述 | 备注 |
|---|---|---|---|
| id | BIGINT | 主键 | 自增 |
| parent_id | BIGINT | 父节点 ID | 0 表示根节点 默认值 0 |
| name | VARCHAR | 名称 | |
| path | VARCHAR | 路径 | 用 / 拼接 |
| sort_order | INT | 排序 | 同层内排序 默认值 0 |
| level | INT | 层级 |
示例数据为:
| id | parent_id | name | path | sort_order | level |
|---|---|---|---|---|---|
| 1 | 0 | 电子产品 | 1 | 0 | 1 |
| 2 | 1 | 手机 | 1/2 | 0 | 2 |
| 3 | 1 | 电脑 | 1/3 | 1 | 2 |
| 4 | 2 | 华为 | 1/2/4 | 0 | 3 |
| 5 | 2 | 苹果 | 1/2/5 | 1 | 3 |
| 6 | 3 | 笔记本 | 1/3/6 | 0 | 3 |
| 7 | 3 | 台式机 | 1/3/7 | 1 | 3 |
4.2 常用操作
4.2.1 查询所有子节点
# 查询所有子节点 直接 LIKE 即可 如查询指定节点 '手机' 的所有子节点
select * from category where path like '1/2/%';
4.2.2 查询节点路径
# 获取到 path 字段后在应用层分割即可
select path from category where id = 2;
4.2.3 移动节点
# 移动节点需要更新所有子节点的路径 如将 '5 苹果' 节点从 '2 手机' 节点移动到 '3 笔记本' 节点下
-- 获取要移动的所有节点
select * from category where path like '1/2/5%';
-- 然后将获取到的所有记录的 path 值修改为 1/3/5 开头即可
4.3 优缺点分析
4.3.1 优点
- 查询子树或路径操作简单速度快;
- 避免了递归查询;
- 相比邻接表读性能大幅提升;
4.3.2 缺点
- 路径字段会占用较大存储空间;
- 路径字段的长度限制会限制树的深度;
- 移动节点时需要更新所有子节点的路径字段;
4.3.3 适用场景
- 读多写少或移动操作少;
- 子树或路径查询频率高;
- 树深度有限(建议 <= 10层);
- 中型项目,数据量约 1万 ~ 10万;
5 嵌套集
依据树的前序遍历,通过使用节点的左右值来表示节点在树中的位置关系,是一种数学化的设计,避免了递归查询。
5.1 核心原理
其核心原理是通过前序遍历来给每个节点分配/计算左值 left 和右值 right,注意,左右值是节点在树中的顺序或位置值,和节点ID没关系。

5.2 存储设计
分类表 category ,建议为 left、right 和 left & right 建立索引,表结构为:
| 字段名 | 字段类型 | 字段描述 | 备注 |
|---|---|---|---|
| id | BIGINT | 主键 | 自增 |
| name | VARCHAR | 名称 | |
| left | INT | 左值 | |
| right | INT | 右值 | |
| sort_order | INT | 排序 | 同层内排序 默认值 0 |
| level | INT | 层级 |
示例数据为:
| id | name | left | right | sort_order | level |
|---|---|---|---|---|---|
| 1 | 电子产品 | 1 | 14 | 0 | 1 |
| 2 | 手机 | 2 | 7 | 0 | 2 |
| 3 | 电脑 | 8 | 13 | 1 | 2 |
| 4 | 华为 | 3 | 4 | 0 | 3 |
| 5 | 苹果 | 5 | 6 | 1 | 3 |
| 6 | 笔记本 | 9 | 10 | 0 | 3 |
| 7 | 台式机 | 11 | 12 | 1 | 3 |
5.3 常用操作
5.3.1 查询所有子节点
# 如查询 '手机' 节点的所有子节点
select c2.* from category c1, category c2
where c1.id = 2
and c2.left > c1.left
and c2.right < c1.right;
5.3.2 查询节点路径
# 如查询 '华为' 节点的路径
select c1.* from category c1, category c2
where c2.id = 4
and c1.left < c2.left
and c1.right > c2.right;
5.3.3 查询所有叶子节点
select * from category where right = left + 1;
5.3.4 统计某节点的叶子节点数量
# 如统计 '手机' 节点的叶子节点数量
select (right - left - 1) / 2 as childTotal
from category where id = 2;
5.3.5 增加节点
// 增加节点需要更新大量左右值(因为要给新插入节点的左右值腾出空间 即右移插入位置右侧的所有节点的左右值)
// 如在节点 '手机' 下插入新节点 '诺基亚' 伪代码如下:
// 1、获取待插入节点的父节点
Category parent = categoryMapper.selectById(parentId);
int newLeft = parent.getRight(); // 父节点的右值即为新节点的左值
// 2、将所有左值 >= parent.getRight() 的节点的左值 +2
categoryMapper.update(null, new LambdaUpdateWrapper<Category>()
.setSql("left = left + 2")
.ge(Category::getLeft, newLeft));
// 3、将所有右值 >= parent.getRight() 的节点的右值 +2
categoryMapper.update(null, new LambdaUpdateWrapper<Category>()
.setSql("right = right + 2")
.ge(Category::getRight, newLeft));
// 4、插入新节点
newCategory.setLeft(newLeft);
newCategory.setRight(newLeft + 1);
categortMapper.insert(newCategory);
增加后效果如下图所示;

5.3.6 删除节点
# 删除节点后要更新左右值
-- 如删除 '诺基亚' 节点(假设其ID为 8)
delete from category where id = 8
-- 将所有左值 > 7 的节点的左值 -2
categoryMapper.update(null, new LambdaUpdateWrapper<Category>()
.setSql("left = left - 2")
.gt(Category::getLeft, 7));
-- 将所有右值 > 8 的节点的右值 -2
categoryMapper.update(null, new LambdaUpdateWrapper<Category>()
.setSql("right = right - 2")
.gt(Category::getRight, 8));
5.3.7 移动节点
移动节点是嵌套集方案中最复杂的操作,需要重新计算整个子树的左右值,故不推荐频繁移动节点。通常做法为:
- 1、记录要移动的所有子树(即要移动的所有节点);
- 2、删除要移动的所有子树(即删除要移动的所有节点);
- 3、在新位置插入节点(删除再插入是为了更新左右值);
5.4 优缺点分析
5.4.1 优点
- 查询子树和路径极快;
- 统计节点数量效率高;
- 查询性能优于路径枚举;
5.4.2 缺点
- 增加、删除和移动节点操作极其复杂;
- 维护时需要更新大量节点的左右值,增加一个节点,可能需要更新几千个节点的左右值,且在高并发场景下容易产生死锁;
- 无法快速查询直接子节点,当然可以通过冗余
parent_id字段来实现,但在维护时需要额外计算父节点的左右值。如何取舍,全在你;
5.4.3 适用场景
- 只读或极少修改;
- 查询性能要求极高;
- 数据量不是特别大(< 5万);
6 闭包表
用额外一张表单独维护了所有节点之间的 祖先-后代 关系(包括自身),采用空间换时间的策略解决了前面所有方案的痛点,是目前大型系统最推荐的方案。
6.1 存储设计
分类表 category ,建议为 parent_id 字段建立索引,表结构为:
| 字段名 | 字段类型 | 字段描述 | 备注 |
|---|---|---|---|
| id | BIGINT | 主键 | 自增 |
| parent_id | BIGINT | 父节点 ID | 0 表示根节点 默认值 0 |
| name | VARCHAR | 名称 | |
| sort_order | INT | 排序 | 同层内排序 默认值 0 |
| level | INT | 层级 |
示例数据为:
| id | parent_id | name | sort_order | level |
|---|---|---|---|---|
| 1 | 0 | 电子产品 | 0 | 1 |
| 2 | 1 | 手机 | 0 | 2 |
| 3 | 1 | 电脑 | 1 | 2 |
| 4 | 2 | 华为 | 0 | 3 |
| 5 | 2 | 苹果 | 1 | 3 |
| 6 | 3 | 笔记本 | 0 | 3 |
| 7 | 3 | 台式机 | 1 | 3 |
闭包关系表 category_colsure,建议为 descendant 和 depth 字段建立索引,表结构为:
| 字段名 | 字段类型 | 字段描述 | 备注 |
|---|---|---|---|
| id | BIGINT | 主键 | 自增 |
| ancestor | BIGINT | 祖先 | |
| descendant | BIGINT | 后代 | |
| depth | INT | 深度 | 层级差 |
如果有 n 个节点,其闭包表的理论记录数约为:n + n * (n - 1) / 2 ,但实际上为:n * (h + 1) ,h 为平均深度。假设有 10万 条节点,平均深度为 5层,则其闭包关系约为 60万 条记录。
示例数据为:
| id | ancestor | descendant | depth |
|---|---|---|---|
| 1 | 1 | 1 | 0 |
| 2 | 1 | 2 | 1 |
| 3 | 1 | 3 | 1 |
| 4 | 1 | 4 | 2 |
| 5 | 1 | 5 | 2 |
| 6 | 1 | 6 | 2 |
| 7 | 1 | 7 | 2 |
| 8 | 2 | 2 | 0 |
| 9 | 2 | 4 | 1 |
| 10 | 2 | 5 | 1 |
| 11 | 3 | 3 | 0 |
| 12 | 3 | 6 | 1 |
| 13 | 3 | 7 | 1 |
| 14 | 4 | 4 | 0 |
| 15 | 5 | 5 | 0 |
| 16 | 6 | 6 | 0 |
| 17 | 7 | 7 | 0 |
6.2 常用操作
6.2.1 查询所有子节点
# 如查询 '手机' 节点的所有子节点
select c.* from category c
inner join category_closure cc on c.id = cc.descendant
where cc.ancestor = 2 and cc.depth > 0;
6.2.2 查询直接子节点
# 如查询 '手机' 节点的直接子节点
select c.* from category c
inner join category_closure cc on c.id = cc.descendant
where cc.ancestor = 2 and cc.depth = 1;
# 当然也可以直接使用邻接表字段 parent_id 实现
select * from category where parent_id = 2;
6.2.3 查询节点路径
# 如查询 '华为' 节点的路径(若添加 cc.depth > 0 条件则不包括自己 反之亦然)
select c.* from category c
inner join category_closure cc on c.id = cc.ancestor
where cc.descendant = 4 and cc.depth > 0;
6.2.4 查询子树深度
# 如查询 '手机' 节点子树的最大深度
select max(depth) as maxDepth
from category_closure
where ancestor = 2;
6.2.5 统计子节点数量
# 如统计 '手机' 节点的子节点的数量
select count(*) - 1 as childTotal
from category_closure
where ancestor = 2;
6.2.6 增加节点
# 如增加 '诺基亚' 节点 id 为 8 步骤及伪代码如下:
-- 1、插入分类基础信息 略
-- 2、插入自己到自己的关系
insert into category_closure (ancestor, descendant, depth) values (8, 8, 0);
-- 3、复制父节点的所有祖先关系 sql 示意如下(当然也可在应用层实现)
insert into category_closure (ancestor, descendant, depth)
select ancestor, 8, depth + 1
from category_closure
where descendant = 2;
6.2.7 删除节点
删除单个节点:
# 如删除 '苹果' 节点
-- 1、检查要删除节点是否存在子节点 即统计子节点数量(若存在则不能删除)
select count(*) - 1 as childTotal
from category_closure
where ancestor = 5;
-- 2、删除该节点的所有关系 即 where descendant = id
delete from category_closure
where descendant = 5;
-- 3、删除基础分类信息
delete from category where id = 5;
删除子树:
-- 1、先获取所有要删除的节点 id 记为 ids
select descendant from category_closure where ancestor = 2;
-- 2、删除所有关系
delete from category_closure
where descendant in(ids);
-- 3、删除基础分类信息
delete from category where id in (ids);
6.2.8 移动节点
# 如移动 '苹果' 节点到 '电脑' 节点下
-- 1、删除 '苹果' 节点的祖先关系(保留自己到自己的关系)
-- 即删除 (1, 5, 2) (2, 5, 1) 保留 (5, 5, 0)
delete from category_closure
where descendant in (
select descendant from (
select descendant from category_closure where ancestor = 5
) as temp
)
and ancestor in (
select ancestor from (
select ancestor from category_closure
where descendant = 5 and ancestor != descendant
) as temp2
);
-- 2、插入新的祖先关系
-- 即插入 (1, 5, 2) (3, 5, 1)
-- 即以新父节点的后代作为新关系的祖先 以要移动节点的后代作为新关系的后代
insert into category_closure (ancestor, descendant, depth)
select cc1.ancestor, cc2.descendant, cc1.depth + cc2.depth + 1
from category_closure cc1
cross join category_closure cc2
where cc1.descendant = 3 -- 新父节点
and cc2.ancestor = 5; -- 要移动的节点
6.3 优缺点分析
6.3.1 优点
- 查询操作性能高,时间复杂度为
O(1)或O(h); - 支持任意深度的树;
- 对增加、删除、修改和移动操作友好;
6.3.2 缺点
- 需要额外的存储空间;
- 增加和移动节点操作较复杂;
- 需要维护两张表的数据一致性;
6.3.3 适用场景
- 读写混合;
- 大型系统;
7 邻接表 + 路径枚举
即结合了 parent_id 字段与 path 字段所带来的优势。其适用场景为:
- 实现简单,性价比高;
- 树层级较浅 <= 6层;
- 中小型系统,数据量约 1万 ~ 10万 节点;
8 闭包表 + 邻接表 + 路径枚举
在闭包表的基础上整合了邻接表的 parent_id 与路径枚举的 path 的优势。其适用场景为:
- 邻接表
parent_id用于查询直接子节点; - 路径枚举
path用于查询路径或简单LIKE查询; - 闭包表用于复杂查询;
- 三者互补,查询灵活;


3334

被折叠的 条评论
为什么被折叠?



