⭐⭐⭐ Spring Boot 项目实战 ⭐⭐⭐ Spring Cloud 项目实战
《Dubbo 实现原理与源码解析 —— 精品合集》 《Netty 实现原理与源码解析 —— 精品合集》
《Spring 实现原理与源码解析 —— 精品合集》 《MyBatis 实现原理与源码解析 —— 精品合集》
《Spring MVC 实现原理与源码解析 —— 精品合集》 《数据库实体设计合集》
《Spring Boot 实现原理与源码解析 —— 精品合集》 《Java 面试题 + Java 学习指南》

摘要: 原创出处 http://www.iocoder.cn/Fight/SpringBoot-wuxiancengjicaidanbiezaizhihuiyong-parentid-le/ 「芋道源码」欢迎转载,保留摘要,谢谢!


🙂🙂🙂关注微信公众号:【芋道源码】有福利:

  1. RocketMQ / MyCAT / Sharding-JDBC 所有源码分析文章列表
  2. RocketMQ / MyCAT / Sharding-JDBC 中文注释源码 GitHub 地址
  3. 您对于源码的疑问每条留言都将得到认真回复。甚至不知道如何读源码也可以请教噢。
  4. 新的源码解析文章实时收到通知。每周更新一篇左右。
  5. 认真的源码交流微信群。

周一早晨的 StackOverflowError

周一 9:50,OPS 群里冒出第一条消息:「后台进不去,登录完一直白屏。」

10 分钟后,被 @ 的人变多。再 5 分钟,运维群、研发群、PM 群同时在 ping 你。

打开线上日志,错误简洁得让人发笑:

java.lang.StackOverflowError
at com.xxx.MenuService.buildTree(MenuService.java:88)
at com.xxx.MenuService.buildTree(MenuService.java:91)
at com.xxx.MenuService.buildTree(MenuService.java:91)
at com.xxx.MenuService.buildTree(MenuService.java:91)
...(重复 N 千行)

排查 10 分钟定位根因:周末有人手抖改了一条菜单的 parent_id,把它指向了自己孙子节点的 id。代码里查菜单树用的是 parent_id 递归——遇到这条数据就开始无限往下挖,直到把 JVM 栈打爆。

按惯常的”解决方案”叙事,下一步该是「立刻上闭包表,再也不出事」。

什么是闭包表(Closure Table)?一句话:树形数据除了主表保留 parent_id 之外,额外建一张「祖先-后代关系表」,把任意层级的祖孙关系都预先存进去——查整棵子树时一次 JOIN 就能拿到,不用递归。代价是写入时要同步维护两张表。后面会详细展开,现在记住它「以空间换查询性能」就够了。

但这是一篇反潮流的文章——你猜怎么着,这个事故就算上了闭包表也照样会发生。

事故的真凶不是 parent_id,是循环引用没拦住

很多文章遇到这种事故,第一反应是「parent_id 不行了,得上闭包表」。这个结论错得离谱。

先算个数。JVM 默认线程栈大小是 512KB(Linux/Mac,Windows 是 320KB)。一个最简单的 buildTree(Long parentId) 方法栈帧大约 80-200 字节,理论上可以递归 2500-6400 层。

如果你的菜单不深,纯粹的”层级太深”几乎不可能撑爆栈。

那开篇的事故是怎么炸的?两个字:循环。一旦 parent_id 形成环(A→B→C→A),递归就不再是按层级前进,而是无限打转——这种情况下,几个节点也能瞬间把栈挤爆。

换句话说,事故的真凶不是 schema 选错了,是缺一道「循环引用检查」的防御——不管你用 parent_id 还是闭包表,这道防御都得做。

防御的思路一句话能讲完:

  • 写入侧:改 parent_id 之前,确认目标父节点不是当前节点的后代(递归往上找一遍,看会不会绕到自己);
  • 查询侧:内存递归构建树时带一个 visited 集合,发现节点重复访问立刻断开。

两道加起来不到 30 行代码,开篇那种事故 99% 跟你无关。所以问题不在 parent_id,在「写菜单系统的人没把数据完整性当 schema 设计的一部分」。

先算笔账:你真的需要闭包表吗

闭包表确实是好方案,但它不是免费的——上之前算清楚账:

维度 parent_id(邻接表) 闭包表
表数量 1 2(多一张关系表)
同步逻辑代码量 ~50 行 ~200 行(加入/移动/删除都要维护两表)
写入开销 1 次 INSERT 1 次 INSERT + N 次关系插入(N = 祖先数)
存储空间 O(节点数) O(节点数 × 平均深度)
学习成本 0 中(要理解祖先-后代对的概念)
排查复杂度 直接看 parent_id 要 JOIN 关系表,DBA 不一定熟

简单一句话:闭包表把”查询脆弱性”换成了”写入复杂度 + 一致性维护成本”,没消失,只是换了藏的地方。

判断你需不需要闭包表,看这两个维度:

节点数 < 500 节点数 500-5000 节点数 > 5000
只查直接子菜单 / 整树 parent_id ✅ parent_id ✅ parent_id + 缓存 ✅
频繁查任意子树 / 祖先链 parent_id ✅ 闭包表 ⚠️ 闭包表 ✅

直接结论:90% 后台管理系统的菜单都落在左上角和中上角的格子里——节点数撑死几百个,查询模式就是「整棵树拉出来给前端」。SELECT * FROM system_menu 一次查全表 + 内存里 O(n) 构建,几百节点的耗时是几毫秒,比起多养一张关系表性价比高得多。

真正落到右下角值得上闭包表的场景,常见的就这几类:

  • 商品分类:电商有几万个分类,需要频繁按某分类查所有子孙商品;
  • 大型组织架构:跨国公司几万部门,频繁查某部门下属任意层级;
  • 评论楼中楼:单帖几千条嵌套回复,要支持任意子线程展开;
  • CMS 内容树:站点几千篇文章按目录嵌套,前台频繁按某栏目查所有内容。

如果你做的不是这几类系统,闭包表的复杂度大概率是你白交的学费——精力应该花在循环引用检查、缓存策略、并发安全这些更能影响线上稳定性的地方。

三种建模方案:你不是在选 schema,是在选未来要修哪种 bug

哪怕你确认要上闭包表,也得知道完整图景。关系数据库存树形结构,业内总结过三套主流模型:邻接表(Adjacency List)、路径枚举(Path Enumeration)、闭包表(Closure Table)。Joe Celko 在《Trees and Hierarchies in SQL》里把它们当独立流派对比过;Bill Karwin 在《SQL Antipatterns》(2010)里把闭包表正式写成了生产级模板。

打个比方,三种模型对应三种”族谱”的记法:

  • 邻接表 = 家谱里只记「这个人的爹是谁」,要查曾孙得一代代往下问;
  • 路径枚举 = 给每个人贴一个完整的”家族标签”,从太祖一路到自己(比如 /1/2/3);
  • 闭包表 = 给每对祖孙关系都立一份契约,不管隔几代,关系都存得明明白白。

但更深一层看——你不是在选 schema,你是在选未来要修哪种 bug:

方案 bug 长在哪里 修起来什么感觉
邻接表 查询时:递归慢、栈溢出、循环引用没拦住 加防御、加缓存、改用 WITH RECURSIVE
路径枚举 数据维护时:移动节点要批量重写路径、字段长度限制、并发脏写 全表 UPDATE,遇上千节点心都凉
闭包表 写入时:双表一致性、批量插入失败、移动节点重建顺序不能反 排查链路长,生产事故得熬夜

没有”对的”方案,只有”对你的场景合适的”方案。下面把三种方案核心特点过一遍。

邻接表:最朴素,但配上防御一样能打

每个节点只记父节点 ID。schema 只列跟树结构有关的字段——permission / path / component / icon / status / visible / 审计字段这些跟权限、路由、UI 相关的列按你公司规范补齐就行:

CREATE TABLE system_menu (
id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '菜单ID',
parent_id BIGINT NOT NULL DEFAULT 0 COMMENT '父菜单ID,0=顶级',
menu_name VARCHAR(50) NOT NULL COMMENT '菜单名称',
type TINYINT NOT NULL COMMENT '类型:1目录 2菜单 3按钮',
sort INT NOT NULL DEFAULT 0 COMMENT '同级排序',
deleted TINYINT NOT NULL DEFAULT 0 COMMENT '软删除:0未删除 1已删除',
INDEX idx_parent_id (parent_id)
) COMMENT '系统菜单表';

邻接表模型:每个节点通过 parent_id 自引用形成树。

优点:新增、修改一条 SQL,零冗余,DBA 排查问题第一眼就懂。缺点:查任意子树要递归。前提:必须配防御(循环引用检查 + visited 集合)。

路径枚举:现在已经基本被淘汰

每个节点存一个完整路径字段(比如 /1/2/3),查子孙用 LIKE '/1/2/%'。

它的查询能力比邻接表强,但维护成本几乎是闭包表的反向极端:

  • 路径字段长度有上限,层级真深了会截断;
  • 移动节点要批量更新所有子孙的 menu_path——千节点级以上是噩梦;
  • LIKE '/1/2/%' 索引利用率有限;
  • 排序逻辑变复杂(路径里穿插不同长度的 ID)。

这套方案现在已经很少见了——除非你接手的是老系统、改不动,否则建议直接跳过。

闭包表:性能最强,代价是多一张表

闭包表的思路是空间换时间:把所有「祖先-后代」对都额外存一份,不管隔几层,一次 JOIN 就能查到。

这个 pattern 不是新东西——1989 年就有学术论文给出过形式化定义,2010 年 Bill Karwin 在《SQL Antipatterns》里把它写成生产级方案模板,从此成为后台系统处理树形数据的”重装步兵”。它不是最快写出来的方案,但是少数几个”写完就不用再改”的方案之一。

主表保持邻接表结构,单独建一张关系表:

CREATE TABLE system_menu_relation (
ancestor_id BIGINT NOT NULL COMMENT '祖先菜单ID',
descendant_id BIGINT NOT NULL COMMENT '后代菜单ID',
depth INT NOT NULL COMMENT '层级深度,自身=0,直接子节点=1',
PRIMARY KEY (ancestor_id, descendant_id),
INDEX idx_descendant (descendant_id),
INDEX idx_depth (depth)
) COMMENT '菜单关系表(闭包表实现)';

索引设计的细节——这是闭包表能跑出性能的关键:

  • 主键 (ancestor_id, descendant_id) 直接覆盖「按祖先查后代」的高频场景;
  • idx_descendant:单独索引 descendant_id,否则「按后代查祖先」(面包屑导航的核心查询)会全表扫;
  • idx_depth:用于按层级过滤,比如「只查直接子菜单(depth=1)」。

数据形态——比如 1→2→3 这条链会记录这些行:

ancestor_id descendant_id depth 含义
1 1 0 自身
1 2 1 1→2 直接子节点
1 3 2 1→3 跨两层
2 2 0 自身
2 3 1 2→3 直接子节点
3 3 0 自身

邻接表(左)与闭包表(右)方案对比。闭包表通过额外关系表存储所有祖先-后代路径。

三种方案速查表

维度 邻接表 路径枚举 闭包表
查子树 慢(需递归 + 防御) 较快(LIKE) 快(JOIN)
查祖先 慢 快 快
新增节点 最快 快 慢一点(多写几条)
移动节点 快(改一字段) 麻烦(批量更新路径) 麻烦(重建关系)
循环引用检查 应用层递归 应用层递归 一条 SQL
无限层级 受 JVM 栈限制 受字段长度限制 支持
实现复杂度 低 中 高

量小场景:parent_id + 两道防御就够

如果你的菜单符合「节点 < 500,查询主要是整树拉给前端」,两道防御做完就上线,不要碰闭包表。

防御一:写入时拦循环。 改 parent_id 前先做 isDescendant 检查——从目标父节点一路往上找,看会不会走到当前节点。沿途记 visited 集合防御老数据已经成环。这段逻辑十几行就能写完。

防御二:查询时再兜一层。 内存递归构建树带一个 visited 集合,遇到重复访问的节点立刻 log.error + 切断。写入侧拦不住的环,查询侧也炸不了。

// 关键就这一条:visited.add 失败立即返回
private List<MenuTreeDTO> buildSafe(Long parentId,
Map<Long, List<SystemMenu>> byParent,
Set<Long> visited) {
if (!visited.add(parentId)) {
log.error("菜单数据存在循环引用,节点 ID: {}", parentId);
return Collections.emptyList();
}
return byParent.getOrDefault(parentId, Collections.emptyList()).stream()
.map(m -> {
MenuTreeDTO dto = toDTO(m);
dto.setChildren(buildSafe(m.getId(), byParent, visited));
return dto;
}).toList();
}

「一次查全表 + 内存里 O(n) 构建」是 90% 后台菜单的最佳姿势——一行 SELECT * 在 500 节点以下都是几毫秒,再加一个 @Cacheable,能撑到几千节点。

讲完轻量方案,下面看真正需要闭包表那 10% 的场景怎么做。

量大场景:闭包表的四个核心动作

闭包表方案的代码看起来唬人,本质就是四个动作要把两张表的一致性维护住。下面按动作讲清逻辑——代码片段只放每个动作最核心的几行,不堆完整 Service 类。

动作一:新增菜单

算法:新菜单的关系 = 自身 (self, self, 0) + 父节点的所有祖先 (ancestor, self, depth+1)。

举例:父节点是 5,5 的祖先链 = (1,5,2), (3,5,1), (5,5,0)。新增菜单 8 挂在 5 下,要插入:

(8, 8, 0)   ← 自身
(1, 8, 3) ← 继承 (1,5,2),depth+1
(3, 8, 2) ← 继承 (3,5,1),depth+1
(5, 8, 1) ← 继承 (5,5,0),depth+1

核心代码片段:

// 查父节点的所有祖先关系,每条 depth+1 后插一条新关系
relationMapper.selectByDescendant(parentId).forEach(a ->
list.add(new SystemMenuRelation(a.getAncestorId(), newId, a.getDepth() + 1)));
list.add(new SystemMenuRelation(newId, newId, 0)); // 自身
relationMapper.batchInsert(list);

关键点:用 batchInsert,别循环 insert——100 个菜单批量导入时差距是 RT 翻倍。

动作二:移动菜单

算法:先做循环引用检查,然后重建当前节点 + 递归重建子节点——顺序不能反。

为什么循环检查在闭包表里是「一条 SQL」?因为关系表里如果存在 (menuId, newParentId) 这条记录,就说明 newParentId 已经是 menuId 的后代了——挂过去就是环:

@Select("SELECT COUNT(*) FROM system_menu_relation " +
"WHERE ancestor_id = #{menuId} AND descendant_id = #{targetParent}")
int countCycle(@Param("menuId") Long menuId,
@Param("targetParent") Long targetParent);

这是闭包表相对邻接表最大的优势——邻接表做同样的检查得递归遍历整棵子树。

重建逻辑伪代码:

rebuild(menuId):
deleteByDescendant(menuId) # 清掉旧关系
rebuildAsNew(menuId, parentId) # 当作新增重建
for child in directChildren(menuId):
rebuild(child) # 递归子树

顺序不能反的原因:子节点的关系是基于父节点重建后的祖先链算的,父没重建子先重建,子继承的就是旧关系。

动作三:删除菜单(级联子树)

闭包表在这个动作上把邻接表甩出半个赛道:

// 一条 SQL 拿到所有要删的 ID,主表批量删 + 关系表按后代删
List<Long> ids = relationMapper.selectSubTree(menuId).stream()
.map(SystemMenu::getId).collect(Collectors.toList());
ids.add(menuId);
menuMapper.deleteBatchIds(ids);
ids.forEach(relationMapper::deleteByDescendant);

邻接表做同样的事得递归一层层往下挖,遇上深的菜单是一打 SQL。闭包表是 1 + 1 + N(N = 子树节点数)。

动作四:查子树 / 查祖先 / 查直接子菜单

四条核心 SQL,覆盖了菜单系统 99% 的查询需求:

-- 1. 查节点 1 的所有子孙(不含自身),按业务排序
SELECT m.* FROM system_menu m
INNER JOIN system_menu_relation r ON m.id = r.descendant_id
WHERE r.ancestor_id = 1 AND r.depth > 0 AND m.deleted = 0
ORDER BY r.depth, m.sort;

-- 2. 查节点 1 的直接子菜单(depth = 1,前端渲染一级目录用)
SELECT m.* FROM system_menu m
INNER JOIN system_menu_relation r ON m.id = r.descendant_id
WHERE r.ancestor_id = 1 AND r.depth = 1
ORDER BY m.sort;

-- 3. 查节点 5 的完整祖先链(面包屑导航,按 depth 倒序)
SELECT m.* FROM system_menu m
INNER JOIN system_menu_relation r ON m.id = r.ancestor_id
WHERE r.descendant_id = 5 AND r.depth > 0
ORDER BY r.depth DESC;

-- 4. 移动菜单前的循环引用检查(一条 SQL 搞定)
SELECT 1 FROM system_menu_relation
WHERE ancestor_id = #{menuId} AND descendant_id = #{newParentId} LIMIT 1;

四条 SQL 都是 INDEX 完美覆盖、JOIN 单层、不递归。这就是闭包表查询性能能甩开邻接表的根本原因。

生产里真正会让你掉头发的细节

闭包表代码抄通和撑住生产是两回事。@Transactional 这种大家都不会忘的就不展开了,下面三条是闭包表方案特有、最容易省、又最容易在生产炸的:

1. 循环引用检查不能省(最容易省,最不该省)

哪怕用了闭包表,countCycle 那条 SQL 不写,前端拖拽时把父节点拖到自己孩子下面,关系表瞬间被错误的祖先关系污染——查祖先无限链、查子树永远漏,关系表的脏数据比邻接表还难修(邻接表只有一行错,闭包表是一片错)。

闭包表的优势就在循环检查只要一条 SQL,邻接表得递归遍历整棵子树。这条 SQL 不写,就等于花闭包表的钱、享邻接表的服务。

2. 移动节点的重建顺序不能倒

rebuildRelation 里的递归——必须先重建当前节点,再递归重建子节点。顺序反了,子节点继承的是父节点重建前的旧祖先关系,树的某些节点关系永远算错——而且不会报任何错,查询照常返回,但返回的是错的。

这是闭包表算法里最隐蔽的陷阱:没有红色异常,只有静默错误。

3. 关系表的 idx_descendant 不能漏

主键 (ancestor_id, descendant_id) 只能加速「按祖先查后代」。「按后代查祖先」(面包屑、循环检查)必须靠 idx_descendant——漏建这条索引,所有查祖先的 SQL 都会全表扫。

菜单一多,关系表就是慢查询日志的常客。索引设计的成本是 0,漏建的成本是线上 P99。

选 schema = 选 bug 落在哪里

回到开篇那个 9:50 的 StackOverflowError:

  • 第一反应是「上闭包表」——治标不治本,不加循环引用检查,闭包表也会被环污染;
  • 第二反应是「parent_id 全面够用」——也不对,菜单真上千节点 + 频繁查任意子树,递归查一次几百毫秒,前端体验肉眼可见地差;
  • 第三、也是正确的反应是「先评估场景,再选 schema,最后补防御」。

三句话总结:

  1. 不要默认上闭包表——大部分后台菜单(节点 < 500,查询模式简单)parent_id + 缓存 + 防御就够,更轻、更易排查;
  2. 真上闭包表的场景是「节点 > 千,频繁查任意子树/祖先链」,这时候它确实是无可替代的方案;
  3. 不管哪种 schema,循环引用检查都不能省——这是开篇那个事故真正的根因。

技术选型的成熟,往往不是「用了多新的方案」,而是「知道每个方案的 bug 会出现在哪——然后提前在那里搭好防御」。

文章目录
  1. 1. 周一早晨的 StackOverflowError
  2. 2. 事故的真凶不是 parent_id,是循环引用没拦住
  3. 3. 先算笔账:你真的需要闭包表吗
  4. 4. 三种建模方案:你不是在选 schema,是在选未来要修哪种 bug
    1. 4.0.1. 邻接表:最朴素,但配上防御一样能打
    2. 4.0.2. 路径枚举:现在已经基本被淘汰
    3. 4.0.3. 闭包表:性能最强,代价是多一张表
    4. 4.0.4. 三种方案速查表
  • 5. 量小场景:parent_id + 两道防御就够
  • 6. 量大场景:闭包表的四个核心动作
    1. 6.0.1. 动作一:新增菜单
    2. 6.0.2. 动作二:移动菜单
    3. 6.0.3. 动作三:删除菜单(级联子树)
    4. 6.0.4. 动作四:查子树 / 查祖先 / 查直接子菜单
  • 7. 生产里真正会让你掉头发的细节
    1. 7.0.1. 1. 循环引用检查不能省(最容易省,最不该省)
    2. 7.0.2. 2. 移动节点的重建顺序不能倒
    3. 7.0.3. 3. 关系表的 idx_descendant 不能漏
  • 8. 选 schema = 选 bug 落在哪里