Spring Boot 菜单无限层级,别再只会用 parent_id 了!多种建设方案?
周一早上9:50,运维群里弹出第一条消息:「后台进不去,登录完一直白屏。」
10分钟后,被@的人越来越多。又过了5分钟,运维群、研发群、PM群同时在找你。
打开线上日志,错误信息短得让人哭笑不得:
java.lang.StackOverflowError
atcom.xxx.MenuService.buildTree(MenuService.java:88)
atcom.xxx.MenuService.buildTree(MenuService.java:91)
atcom.xxx.MenuService.buildTree(MenuService.java:91)
...(重复数千行)
排查10分钟找到根因:周末有人改了一条菜单的parent_id,不小心把它指向了自己孙子节点的id。代码里查菜单树用的是parent_id递归——遇到这条数据就开始无限循环,直到把JVM栈撑爆。
按很多技术文章的路子,下一步就该说「赶紧上闭包表,一劳永逸」。
但今天这篇文章想说的是另一个观点:就算上了闭包表,这个事故照样会发生。
01、事故的真凶不是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设计的一部分」。
02、真的需要闭包表吗?
闭包表确实是好方案,但它不是免费的。上之前先把账算清楚:
|
维度 |
parent_id(邻接表) |
闭包表 |
|---|---|---|
|
表数量 |
1 |
2(多一张关系表) |
|
同步逻辑代码量 |
约50行 |
约200行 |
|
写入开销 |
1次INSERT |
1次INSERT + N次关系插入 |
|
存储空间 |
O(节点数) |
O(节点数 × 平均深度) |
|
学习成本 |
几乎为零 |
中等 |
|
排查难度 |
直接看parent_id |
要JOIN关系表 |
一句话总结:闭包表把「查询的脆弱性」换成了「写入的复杂度 + 一致性维护成本」,问题没有消失,只是换了个地方藏。
判断要不要上闭包表,看这两个维度就够了:
-
节点数 < 500:邻接表完全够用
-
节点数 500-5000:看查询模式,大部分情况邻接表+缓存就行
-
节点数 > 5000:考虑闭包表
90%的后台管理系统菜单,节点数撑死几百个,查询模式就是「把整棵树拉出来给前端」。一条SELECT *全表扫描加内存里O(n)构建,几百节点的耗时是几毫秒,比多维护一张关系表划算得多。
真正值得上闭包表的场景,通常就这几类:
-
电商商品分类(几万个分类,频繁按某分类查所有子孙商品)
-
大型组织架构(跨国公司几万部门,频繁查某部门下所有层级)
-
评论楼中楼(单帖几千条嵌套回复)
-
CMS内容树(几千篇文章按目录嵌套)
如果你的项目不在这几类里,闭包表的复杂度大概率是你白交的学费。
03、选schema就是在选以后要修哪种bug
关系数据库存树形结构,业内主要有三套方案:邻接表、路径枚举、闭包表。
打个比方:
-
邻接表:家谱里只记「这个人的爹是谁」,查曾孙得一代代往下问
-
路径枚举:给每个人贴一个完整标签,从老祖宗一路到自己(比如
/1/2/3)
-
闭包表:给每对祖孙关系都立一份契约,不管隔几代,关系都存得明明白白
更深一层看:你不是在选schema,你是在选以后要修哪种bug。
|
方案 |
bug常出在哪里 |
修起来什么感觉 |
|---|---|---|
|
邻接表 |
查询时:递归慢、栈溢出、循环引用没拦住 |
加防御、加缓存 |
|
路径枚举 |
数据维护时:移动节点要批量改路径、字段长度限制 |
全表UPDATE,心凉 |
|
闭包表 |
写入时:双表一致性、批量插入失败 |
排查链路长,熬夜 |
没有「对的」方案,只有「对你的场景合适的」方案。
04、parent_id + 两道防御就够了
如果你的菜单符合「节点 < 500,查询主要是整树拉给前端」,两道防御做完就可以上线,不用碰闭包表。
防御一:写入时拦循环
改parent_id前先做检查——从目标父节点一路往上找,看会不会走到当前节点。沿途记visited集合,防老数据已经成环。这段逻辑十几行就能写完。
防御二:查询时再兜一层
内存递归构建树时带一个visited集合,遇到重复访问的节点立刻打日志并切断。写入侧拦不住的环,查询侧也炸不了。
核心逻辑就这一条:
if (!visited.add(parentId)) {
log.error("菜单数据存在循环引用,节点ID: {}", parentId);
return Collections.emptyList();
}
「一次查全表 + 内存里O(n)构建」是90%后台菜单的最佳姿势。一条SELECT *在500节点以下就是几毫秒,再加个@Cacheable,能撑到几千节点。
05、闭包表的核心操作
当节点数上千、且频繁查任意子树时,闭包表确实是无可替代的方案。下面把核心逻辑过一遍。
动作一:新增菜单
新菜单的关系 = 自身(self, self, 0)+ 父节点所有祖先(ancestor, self, depth+1)。
关键点:用批量插入,别循环insert——100个菜单批量导入时差距是响应时间翻倍。
动作二:移动菜单
先做循环引用检查,然后重建当前节点,再递归重建子节点——顺序不能反。
闭包表相比邻接表最大的优势:循环检查一条SQL就能搞定——查关系表里是否已存在(menuId, targetParent)这条记录。
为什么顺序不能反?因为子节点的关系是基于父节点重建后的祖先链算的,父没重建子先重建,子继承的就是旧关系。
动作三:删除菜单(级联)
闭包表在这个动作上优势明显:一条SQL拿到所有要删的ID,然后主表和关系表批量删。邻接表做同样的事得递归一层层往下挖,深的菜单要打一堆SQL。
动作四:查询
四条核心SQL覆盖菜单系统99%的查询需求:
-
查所有子孙(不含自身)
-
查直接子菜单
-
查完整祖先链(面包屑导航)
-
移动前的循环引用检查
四条SQL都是索引完美覆盖、JOIN单层、不递归。这就是闭包表查询性能甩开邻接表的根本原因。
06、生产里最容易踩的三个坑
闭包表的代码抄通了和撑住生产是两回事。下面三条最容易被忽略、又最容易在生产环境炸。
坑一:循环引用检查不能省
哪怕用了闭包表,不写循环检查,前端拖拽时把父节点拖到自己的孩子下面,关系表瞬间被污染。闭包表的脏数据比邻接表还难修——邻接表只有一行错,闭包表是一片错。
坑二:移动节点的重建顺序不能倒
顺序反了,子节点继承的是父节点重建前的旧关系,树的某些节点关系永远算错——而且不会报任何错,查询照常返回,但返回的是错的。这是闭包表最隐蔽的陷阱:没有红色异常,只有静默错误。
坑三:关系表的descendant索引不能漏
主键(ancestor_id, descendant_id)只能加速「按祖先查后代」。「按后代查祖先」(面包屑、循环检查)必须靠单独的descendant_id索引。漏建这条索引,所有查祖先的SQL都会全表扫描。菜单一多,关系表就成了慢查询日志的常客。
07、回到原点
开篇那个周一的StackOverflowError:
-
第一反应「上闭包表」——治标不治本,不加循环检查,闭包表也会被污染
-
第二反应「parent_id全面够用」——也不对,菜单真上千节点加频繁查任意子树,递归一次几百毫秒
-
正确的反应是「先评估场景,再选schema,最后补防御」
三句话收尾:
-
不要默认上闭包表——大部分后台菜单,parent_id加缓存加防御就够了,更轻、更易排查
-
真上闭包表的场景是节点过千且频繁查任意子树,这时候它确实是无可替代的方案
-
不管哪种schema,循环引用检查都不能省——这才是开篇那个事故真正的根因
技术选型的成熟,往往不是「用了多新的方案」,而是「知道每个方案的bug会出在哪里,然后提前在那里搭好防御」。
更多推荐




所有评论(0)