先看一个真实的线上事故:一张十万级记录的分类表,存的是最简单的 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
