先看一个真实的线上事故:一张十万级记录的分类表,存的是最简单的 id、parent_id,Java 业务代码里一个方法一层层查库去拼菜单树,平时也就几十毫秒的事。结果双十一大促流量一上来,接口直接超时,日志打出来全是“查询分类子节点”的慢 SQL。我接手排查的时候第一反应是:是不是该换索引了?等我把 EXPLAIN 全看了一遍才发现,索引没用错,SQL 也没写错,真正错的是方案本身——关系型数据库里的树形结构,如果只靠一张邻接表加 Java 循环递归查,数据量一大就是灾难现场。
这篇文章就从那次事故出发,把数据库树形结构设计的五种主流方案从头到尾捋一遍,包括邻接表、递归 CTE、路径枚举、嵌套集、闭包表的结构长什么样、查询怎么写、代价在哪里,以及我在 Java 工程里落地时踩过的坑。适合正在做分类、菜单、组织架构、评论楼中楼这类功能,并且已经在数据量变大后感受到性能压力的同学阅读。
1. 事故复盘:不是 SQL 慢,是“循环查库”把数据库打垮了
1.1 业务代码里的递归查库,才是性能毒瘤
当时后台系统里有一张 category 表,表结构非常简单:
sql复制CREATE TABLE `category` (
`id` BIGINT PRIMARY KEY AUTO_INCREMENT,
`name` VARCHAR(64) NOT NULL,
`parent_id` BIGINT NULL,
`sort_no` INT NOT NULL DEFAULT 0,
KEY `idx_parent_id` (`parent_id`)
) ENGINE=InnoDB;
很经典的邻接表。某个查询商品类目树的接口逻辑大概长这样:先从根节点查出来,得到一个子节点列表,然后对每个子节点再执行一次“查它的子节点”的 SQL,逐层往下。数据量小的时候,这种写法非常直观,每层查询也就几个毫秒,接口整体耗时几百毫秒,看起来无伤大雅。一旦某个根节点下面的全量分类到几千上万,这个递归过程就会变成几万次数据库往返。Java 线程和数据库连接池很快被占满,接口直接雪崩。
这类问题的本质不是某一条 SQL 没有索引,而是把树的遍历次数浪费在了网络往返上。每查一次子节点,都伴随着一次 TCP 连接、一次 SQL 解析、一次结果集封装。假设单次查询平均 1 毫秒,1 万个节点就要 10 秒以上,这还是没有计算 JSON 序列化和 GC 的前提下。
1.2 关系模型与树形结构的阻抗失配
关系型数据库天生擅长处理“平坦表”,每一行和另一行的关联靠外键或中间表的一组等值关系表达。树形结构的特点是“递归”,一个节点的价值往往体现在祖先链和整棵子树。用平坦表表达递归关系,必然会遇到阻抗失配:要么在代码里一层层拼,要么在 SQL 里用递归语法硬查,要么额外冗余出很多便于查询的“路径”和“闭包”数据。
阻抗失配带来的直接结果就是,很多方案不是不能做,而是某个方向上的代价被埋在了未来。邻接表插入简单、查询祖先链难;嵌套集查询极快、增删移动极其痛苦;闭包表查询灵活、冗余空间大。选型本质上就是在读、写、空间、复杂度之间找平衡。既然树形结构无法用一种理想模型同时满足所有场景,我们就得把五种常见方案的边界都搞清楚。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 五种树形方案逐个拆解:结构、写法与代价
2.1 邻接表:最基础,也最容易被人写坏
邻接表不需要额外解释,就是每行记录一个 parent_id。它的优点是非常直观,插入一个节点只需要往表里插一行,移动一个节点的业务含义也很清晰:把它的 parent_id 改掉。
但它有两个天生短板:
第一,查询一个节点的全部后代,需要沿着 parent_id 往下走很多层。标准 SQL 在没有递归语法之前,几乎只能靠代码循环。后来 MySQL 8.0 支持了 WITH RECURSIVE,这个问题缓解了,但很多老项目还在用 MyBatis 的 <foreach> 或者 Java for 循环一层层查。第二,查询“根到某个节点的祖先链”同样困难,要么反向递归,要么每次往里拼接一遍 parent_id。
可以这样理解:邻接表存的是“边的单跳信息”,只记录了相邻两级的父子关系。任何涉及多跳的操作都需要让数据库或业务代码去“跳”很多次。如果层级浅、单点数据量小,问题不大;一旦树又宽又深,性能就成了大麻烦。
在邻接表上能做的优化,首先是给 parent_id 建索引,更进一步是建 (parent_id, sort_no) 联合索引,让“同一父节点下的排序”和“过滤出子节点”都能走索引覆盖。但索引只能解决单次查询,解决不了多跳递归的总耗时。
2.2 不换表的黑科技:用递归 CTE 把多跳查询压成一条 SQL
如果表结构已经是邻接表,最值得优先尝试的优化手段是 MySQL 8.0 的递归公共表表达式。它允许你一条 SQL 完成从某个节点向下的全部子孙查询:
sql复制WITH RECURSIVE category_tree AS (
SELECT id, parent_id, name, 1 AS depth
FROM category
WHERE id = 1
UNION ALL
SELECT c.id, c.parent_id, c.name, ct.depth + 1
FROM category c
INNER JOIN category_tree ct ON c.parent_id = ct.id
)
SELECT * FROM category_tree;
这条 SQL 的执行过程,相当于数据库在内部把递归展开成了多次查询。每一轮递归都会去访问 category 表,通过 parent_id = 上一轮产生的 id 找到下一层节点。如果 parent_id 上没有索引,每一轮都是全表扫描,数据量大时照样慢;有索引的情况下,这条 SQL 的耗时主要取决于要返回的子树节点总数。
实际使用中,我建议把 WITH RECURSIVE 只用于“获取整棵子树”这种场景,不要把它作为万能药。因为它仍然会产生“多次查询”的开销,只是把网络往返和 Java 循环换成了数据库内部操作,减少了一次次 remap 和网络延迟。对于一个 2 万节点的子树,从 Java 循环查库的几十秒优化到几十毫秒是完全可能的,这也是很多老旧系统提升最明显的一步。
但递归 CTE 解决不了另一个问题:如果一个页面需要同时展示多棵子树的汇总信息,你仍然要先分别执行多次递归,再在内存里合并。这个场景下,就要考虑下面这些把“树关系”物化出来的方案。
2.3 路径枚举:把祖先折叠进冗余字段,树查询退化成了字符串扫描
路径枚举的思路非常朴素:与其每次递归去找父节点,不如在每行上直接记录一条类似文件路径的完整祖先链。表结构可以设计成:
sql复制CREATE TABLE `category` (
`id` BIGINT PRIMARY KEY AUTO_INCREMENT,
`name` VARCHAR(64) NOT NULL,
`path` VARCHAR(500) NOT NULL,
`depth` INT NOT NULL,
KEY `idx_path` (`path`),
KEY `idx_parent_id` (`parent_id`)
) ENGINE=InnoDB;
假如根节点 id 为 1,那么它的 path 可以存成 /1/。一个 id 为 5 的节点挂在 id 为 1 的节点下,path 就存成 /1/5/。再往下,id 为 9 的节点挂在 id 为 5 的节点下,path 存成 /1/5/9/。
此时查询“id 为 5 的节点下所有子孙”就变得极其简单:
sql复制SELECT * FROM category WHERE path LIKE '/1/5/%';
只要查询条件是以固定字符串开头,数据库是有机会使用 idx_path 索引的,因为 /1/5/ 这个前缀是确定的,LIKE 后面不是以 % 开头。查询路径也方便:拿到某个节点自己的 path 之后,按路径里的 id 把祖先取出来即可。
但路径枚举也有明显的坑:
- 路径中的分隔符和 ID 排序。如果路径里直接存数字 ID,字符串排序时
99会排在100后面,导致通过 path 排序时和业务树的顺序可能不一样。通常要给树节点配一个固定长度的排序号段,并保证位数一致。 - 移动一棵子树成本高。如果把 node 5 从 node 1 下面移动到 node 2 下面,所有后代的 path 都要重新拼接更新,这是典型的大范围 UPDATE。
- 深度不可无限增长。路径字段长度有限制,如果业务是一个无限层级、深度可能上千的论坛楼中楼,路径枚举会非常吃力。
路径枚举比较适合组织架构、部门编码这类节点本身有清晰编码规则,而且树结构不会频繁拖拽移动的场景。如果业务上节点就是文件夹一样的存在,新节点只在叶子级插入,移动是低频操作,它比邻接表直观很多。
2.4 嵌套集:左右值的数学把戏,查询快到离谱但写入要冷静
嵌套集方案和普通人的直觉不太一样。它不是直接记录父子关系,而是给每个节点分配两个整数:lft 和 rgt。规则是:父子节点的区间是包含关系,子节点的 lft、rgt 都被包含在父节点的 lft、rgt 之间;一个节点如果是叶子节点,那么 rgt - lft = 1。
表结构:
sql复制CREATE TABLE `category` (
`id` BIGINT PRIMARY KEY AUTO_INCREMENT,
`name` VARCHAR(64) NOT NULL,
`lft` INT NOT NULL,
`rgt` INT NOT NULL,
KEY `idx_lft_rgt` (`lft`, `rgt`)
) ENGINE=InnoDB;
查询某节点下所有子树的 SQL,可以写成区间扫描:
sql复制SELECT * FROM category WHERE lft BETWEEN ? AND ? ORDER BY lft;
因为父节点区间覆盖所有后代区间,所以只需要一次区间范围查询,整棵子树就全出来了,连递归都省了。这在读取性能上非常优越。
对应的代价,集中在插入和移动节点。假设在父节点 P 的最后一个子节点后面插入一个新叶子,需要把 P 自己和所有右侧节点的 lft、rgt 集体加 2,影响的行数取决于树的位置。在最坏情况下,整个树的后半部分都要更新。删除一个节点同样需要反向平移。如果业务场景是商品分类、导航菜单这类结构稳定、极少夜里批量导入修改的树,嵌套集很合适。如果是用户随时可以创建文件夹并自由拖拽移动的网盘目录,用嵌套集就是给自己找麻烦。
我见过不少团队被嵌套集“查询快”吸引,上线后才发现,每新增一个一级分类就要更新上万行的左右值,数据库主从延迟和锁等待全都出来了。而且嵌套集对错误数据非常敏感,只要 lft、rgt 维护错一处,整棵树的区间就乱了,排查起来很痛苦。
2.5 闭包表:用一张关系表把所有祖先、后代都显式存下来
闭包表看起来最“重”,因为它不再只在节点表上做文章,而是额外建一张专门存储“任意两个有祖先-后代关系节点”的路径表。节点表和路径表分别是:
sql复制CREATE TABLE `category` (
`id` BIGINT PRIMARY KEY AUTO_INCREMENT,
`name` VARCHAR(64) NOT NULL
) ENGINE=InnoDB;
CREATE TABLE `category_path` (
`ancestor_id` BIGINT NOT NULL,
`descendant_id` BIGINT NOT NULL,
`depth` INT NOT NULL DEFAULT 0,
PRIMARY KEY (`ancestor_id`, `descendant_id`),
KEY `idx_descendant` (`descendant_id`, `ancestor_id`)
) ENGINE=InnoDB;
闭包表里会同时保存每个节点和自己的关系,也就是 ancestor_id = descendant_id、depth = 0 这一行也要放进去。如果节点 1 是节点 2 的父节点,节点 2 是节点 3 的父节点,那么 category_path 里至少会有这些行:
| ancestor_id | descendant_id | depth |
|---|---|---|
| 1 | 1 | 0 |
| 2 | 2 | 0 |
| 3 | 3 | 0 |
| 1 | 2 | 1 |
| 2 | 3 | 1 |
| 1 | 3 | 2 |
查询某个节点下的所有后代,直接按 ancestor_id 过滤:
sql复制SELECT c.*, cp.depth
FROM category_path cp
INNER JOIN category c ON c.id = cp.descendant_id
WHERE cp.ancestor_id = #{nodeId}
ORDER BY cp.depth, c.id;
查询某个节点的所有祖先,反过来按 descendant_id 过滤:
sql复制SELECT c.*, cp.depth
FROM category_path cp
INNER JOIN category c ON c.id = cp.ancestor_id
WHERE cp.descendant_id = #{nodeId}
ORDER BY cp.depth;
这两类操作在树结构里非常常见,而闭包表让它们全部变成了等值连接查询。因为 ancestor_id、descendant_id 都有索引,数据库不需要递归,也不需要扫描范围,单次查询的耗时几乎与树的深度无关。
代价是空间膨胀。一个 10 万个节点的树,如果平均深度比较可观,闭包表可能达到几十万甚至几百万行。插入一个叶子节点时,不再是单纯插一条记录,而是要把这个节点和所有祖先的关系都插一遍。这里给出新增节点时的完整 SQL 模板,假设新节点 id 是 #{nodeId},它的父节点 id 是 #{parentId}:
sql复制INSERT INTO category_path (ancestor_id, descendant_id, depth)
SELECT ancestor_id, #{nodeId}, depth + 1
FROM category_path
WHERE descendant_id = #{parentId};
INSERT INTO category_path (ancestor_id, descendant_id, depth)
VALUES (#{nodeId}, #{nodeId}, 0);
第一次 INSERT SELECT 会查出父节点自己的全部祖先链,然后把这些祖先全部与“新节点”建立路径关系,深度加 1。举例来说,如果父节点到根有 6 层,这一步会插入 6 行,第二次插入再补上自身关系。所以闭包表的新增写入比邻接表多,但深度一般在几十以内,相对可控。
闭包表另一个容易踩的坑是删除。因为关系行分散在多条记录里,删除某棵子树时要先查出子树内所有节点,再把这些节点作为 descendant_id 的关系行删除。MySQL 里不能直接对同一张表既 SELECT 又 DELETE,需要用派生表包一层。这也是很多初用闭包表的团队到了删除阶段才发现麻烦的原因。
3. 同量级下的性能实测:从几十秒到几十毫秒是怎么发生的
3.1 实测数据与测试口径
下面这组数据来自我当时压测用的一个内部测试库,环境是 MySQL 8.0、InnoDB、一张 50 万节点左右的类目树,整体树深约 12 层,查询目标是某个根节点下约 2 万个子节点。为了减少偶然性,每个方案跑了 20 次取中位数,语句预热完成后再统计。由于不同业务数据形态差异很大,这个表更适合看数量级,而不是当成一套固定的基准。
| 方案 | 查询指定节点下2万节点子树 | 查询某叶子到根的6层路径 | 新增一个叶子节点 | 说明 |
|---|---|---|---|---|
| 邻接表 + Java 循环查库 | 约 30 秒以上 | 约 100ms | 约 1ms | 2万次数据库往返,这是典型反例 |
| 邻接表 + WITH RECURSIVE | 约 80ms | 约 3ms | 约 1ms | 不换表也能缓解,但深层级会退化 |
| 路径枚举 | 约 25ms | 约 4ms | 约 2ms | 查询快,移动子树不友好 |
| 嵌套集 | 约 8ms | 约 2ms | 约 20ms,视插入位置可能更高 | 写放大严重 |
| 闭包表 | 约 15ms | 约 2ms | 约 4ms | 优势祖先/后代等值查询 |
有人看到“邻接表 + Java 循环查库”要 30 秒,会觉得是不是太夸张了。实际在 2 万节点子树下,如果用 MyBatis 的嵌套 resultMap 或者到处 selectByParentId,线程池还要做上下文切换和连接获取,30 秒并不夸张。我之前在事故现场看到的最坏情况是 40 秒。
对比之后会发现一个规律:凡是能提前把“树关系”物化成可索引的字段或行的方案,查询性能都会产生质的提升。邻接表本质是把关系隐含在相邻行里,递归每一步都必须经过一次额外的匹配操作;路径枚举、嵌套集、闭包表则相当于把查询需要的路径信息提前算好,查询时只需一次范围扫描或等值连接。说白了,是用空间和写入时的维护成本,换取了查询时的复杂度下降。
3.2 “性能提升 100 倍”到底从哪里来
树形结构查询在方案优化后能提升到百倍量级,通常不是某一条 SQL 从 1 秒优化到了 10 毫秒,而是发生在这两种情况里:
第一种是从“循环查询”变成“一次查出来”。原先 2 万次单点查询,换成一次闭包表关联或者一次 CTE 递归,数据库的访问次数从 2 万次降到 1 次,即便单次查询稍微慢一点,总耗时也是断崖式下降。这个场景下 100 倍提升并不夸张。
第二种是从“无法使用索引的递归路径”变成“等值查询或范围查询”。邻接表反查父链时,每个节点都要按主键查询一次;闭包表却可以直接通过 descendant_id 找到所有祖先并一次性 JOIN 出节点信息。查询次数降下来了,单机的 CPU 和 IO 占用也降了。
但一定要注意,网上很多言论会把某个方案的数值吹成普适结论。树形结构性能高度依赖数据形状、索引状态、主键分布和数据库版本。同样是闭包表,如果某棵树平均深度只有 3,它的优势就没那么明显,反而因为路径表行数膨胀拉低了写入性能。所以先弄清自己业务是读多写少还是写多读少,再选方案,比盲目套“最优解”重要得多。
4. Java 工程落地:从数据库查出扁平数据到组装一棵树
4.1 一次性取出,用 HashMap 规避 N+1
很多 Java 开发在实现树形结构时,第一反应是写一个递归方法,每层调用 Mapper 的 selectByParentId,然后向上返回。这种代码从逻辑上没错,但性能上非常危险。更稳妥的做法是明确一层的数据范围,一次性查出来,在内存里完成树的组装。
下面是一个最常见的内存建树实现,以类别菜单为例:
java复制public class CategoryNode {
private Long id;
private Long parentId;
private String name;
private List<CategoryNode> children = new ArrayList<>();
public CategoryNode(Long id, Long parentId, String name) {
this.id = id;
this.parentId = parentId;
this.name = name;
}
// getter / setter 省略
}
组装逻辑:
java复制public List<CategoryNode> buildTree(List<CategoryNode> nodeList) {
Map<Long, CategoryNode> nodeMap = new HashMap<>(nodeList.size() * 2);
for (CategoryNode node : nodeList) {
nodeMap.put(node.getId(), node);
}
List<CategoryNode> roots = new ArrayList<>();
for (CategoryNode node : nodeMap.values()) {
if (node.getParentId() == null || node.getParentId() == 0L
|| !nodeMap.containsKey(node.getParentId())) {
roots.add(node);
} else {
nodeMap.get(node.getParentId()).getChildren().add(node);
}
}
return roots;
}
这个写法的核心优势是只遍历了两遍集合,时间复杂度是 O(n)。只要 parent_id 是数据库里真实存在的外键,nodeMap.containsKey 的判断通常不会走到“根节点”分支;如果部分数据出现脏数据,比如父节点不存在,它会自动把该节点当成根节点,不至于直接丢数据。
4.2 别在 MyBatis 的 resultMap 里无脑套 collection 嵌套查询
MyBatis 的 <collection> 标签
