Spring Boot 菜单无限层级,别再只会用 parent_id 了!多种建设方案?
👉这是一个或许对你有用的社群
🐱 一对一交流/面试小册/简历优化/求职解惑,欢迎加入「芋道快速开发平台」知识星球。下面是星球提供的部分资料:
《项目实战(视频)》:从书中学,往事中“练”
《互联网高频面试题》:面朝简历学习,春暖花开
《架构 x 系统设计》:摧枯拉朽,掌控面试高频场景题
《精进 Java 学习指南》:系统学习,互联网主流技术栈
《必读 Java 源码专栏》:知其然,知其所以然
👉这是一个或许对你有用的开源项目
国产Star破10w的开源项目,前端包括管理后台、微信小程序,后端支持单体、微服务架构
RBAC权限、数据权限、SaaS多租户、商城、支付、工作流、大屏报表、ERP、CRM、AI大模型、IoT物联网等功能:
多模块:https://gitee.com/zhijiantianya/ruoyi-vue-pro
微服务:https://gitee.com/zhijiantianya/yudao-cloud
视频教程:https://doc.iocoder.cn
【国内首批】支持 JDK17/21+SpringBoot3、JDK8/11+Spring Boot2双版本
周一早晨的 StackOverflowError
事故的真凶不是 parent_id,是循环引用没拦住
先算笔账:你真的需要闭包表吗
三种建模方案:你不是在选 schema,是在选未来要修哪种 bug
量小场景:parent_id + 两道防御就够
量大场景:闭包表的四个核心动作
生产里真正会让你掉头发的细节
选 schema = 选 bug 落在哪里
周一早晨的 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 就能拿到,不用递归。代价是写入时要同步维护两张表。后面会详细展开,现在记住它「以空间换查询性能」就够了。
但这是一篇反潮流的文章——你猜怎么着,这个事故就算上了闭包表也照样会发生。
基于 Spring Boot + MyBatis Plus + Vue & Element 实现的后台管理系统 + 用户小程序,支持 RBAC 动态权限、多租户、数据权限、工作流、三方登录、支付、短信、商城等功能
项目地址:https://github.com/YunaiV/ruoyi-vue-pro
视频教程:https://doc.iocoder.cn/video/
事故的真凶不是 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 设计的一部分」。
基于 Spring Cloud Alibaba + Gateway + Nacos + RocketMQ + Vue & Element 实现的后台管理系统 + 用户小程序,支持 RBAC 动态权限、多租户、数据权限、工作流、三方登录、支付、短信、商城等功能
项目地址:https://github.com/YunaiV/yudao-cloud
视频教程:https://doc.iocoder.cn/video/
先算笔账:你真的需要闭包表吗
闭包表确实是好方案,但它不是免费的——上之前算清楚账:
维度 | 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 长在哪里 | 修起来什么感觉 |
|---|---|---|
邻接表 | 查询时 :递归慢、栈溢出、循环引用没拦住 | 加防御、加缓存、改用 |
路径枚举 | 数据维护时 :移动节点要批量重写路径、字段长度限制、并发脏写 | 全表 UPDATE,遇上千节点心都凉 |
闭包表 | 写入时 :双表一致性、批量插入失败、移动节点重建顺序不能反 | 排查链路长,生产事故得熬夜 |
没有"对的"方案,只有"对你的场景合适的"方案。下面把三种方案核心特点过一遍。
邻接表:最朴素,但配上防御一样能打
每个节点只记父节点 ID。schema 只列跟树结构有关的字段——permission / path / component / icon / status / visible / 审计字段这些跟权限、路由、UI 相关的列按你公司规范补齐就行:
CREATE TABLE system_menu ( id BIGINT AUTO_INCREMENT PRIMARY KEYCOMMENT'菜单ID', parent_id BIGINT NOTNULLDEFAULT0 COMMENT'父菜单ID,0=顶级', menu_name VARCHAR(50) NOTNULL COMMENT'菜单名称', type TINYINT NOTNULL COMMENT'类型:1目录 2菜单 3按钮', sort INT NOTNULLDEFAULT0 COMMENT'同级排序', deleted TINYINT NOTNULLDEFAULT0 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 BIGINTNOTNULLCOMMENT'祖先菜单ID', descendant_id BIGINTNOTNULLCOMMENT'后代菜单ID', depth INT NOTNULLCOMMENT'层级深度,自身=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 INNERJOIN system_menu_relation r ON m.id = r.descendant_id WHERE r.ancestor_id = 1AND r.depth > 0AND m.deleted = 0 ORDERBY r.depth, m.sort; -- 2. 查节点 1 的直接子菜单(depth = 1,前端渲染一级目录用) SELECT m.* FROM system_menu m INNERJOIN system_menu_relation r ON m.id = r.descendant_id WHERE r.ancestor_id = 1AND r.depth = 1 ORDERBY m.sort; -- 3. 查节点 5 的完整祖先链(面包屑导航,按 depth 倒序) SELECT m.* FROM system_menu m INNERJOIN system_menu_relation r ON m.id = r.ancestor_id WHERE r.descendant_id = 5AND r.depth > 0 ORDERBY r.depth DESC; -- 4. 移动菜单前的循环引用检查(一条 SQL 搞定) SELECT1FROM 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,最后补防御」。
三句话总结:
不要默认上闭包表——大部分后台菜单(节点 < 500,查询模式简单)parent_id + 缓存 + 防御就够,更轻、更易排查;
真上闭包表的场景是「节点 > 千,频繁查任意子树/祖先链」,这时候它确实是无可替代的方案;
不管哪种 schema,循环引用检查都不能省——这是开篇那个事故真正的根因。
技术选型的成熟,往往不是「用了多新的方案」,而是「知道每个方案的 bug 会出现在哪——然后提前在那里搭好防御」。
欢迎加入我的知识星球,全面提升技术能力。
👉 加入方式,“长按”或“扫描”下方二维码噢:
星球的内容包括:项目实战、面试招聘、源码解析、学习路线。
文章有帮助的话,在看,转发吧。 谢谢支持哟 (*^__^*)