周一早上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%的查询需求:

  1. 查所有子孙(不含自身)

  2. 查直接子菜单

  3. 查完整祖先链(面包屑导航)

  4. 移动前的循环引用检查

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

06、生产里最容易踩的三个坑

闭包表的代码抄通了和撑住生产是两回事。下面三条最容易被忽略、又最容易在生产环境炸。

坑一:循环引用检查不能省

哪怕用了闭包表,不写循环检查,前端拖拽时把父节点拖到自己的孩子下面,关系表瞬间被污染。闭包表的脏数据比邻接表还难修——邻接表只有一行错,闭包表是一片错。

坑二:移动节点的重建顺序不能倒

顺序反了,子节点继承的是父节点重建前的旧关系,树的某些节点关系永远算错——而且不会报任何错,查询照常返回,但返回的是错的。这是闭包表最隐蔽的陷阱:没有红色异常,只有静默错误。

坑三:关系表的descendant索引不能漏

主键(ancestor_id, descendant_id)只能加速「按祖先查后代」。「按后代查祖先」(面包屑、循环检查)必须靠单独的descendant_id索引。漏建这条索引,所有查祖先的SQL都会全表扫描。菜单一多,关系表就成了慢查询日志的常客。

07、回到原点

开篇那个周一的StackOverflowError:

  • 第一反应「上闭包表」——治标不治本,不加循环检查,闭包表也会被污染

  • 第二反应「parent_id全面够用」——也不对,菜单真上千节点加频繁查任意子树,递归一次几百毫秒

  • 正确的反应是「先评估场景,再选schema,最后补防御」

三句话收尾:

  1. 不要默认上闭包表——大部分后台菜单,parent_id加缓存加防御就够了,更轻、更易排查

  2. 真上闭包表的场景是节点过千且频繁查任意子树,这时候它确实是无可替代的方案

  3. 不管哪种schema,循环引用检查都不能省——这才是开篇那个事故真正的根因

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

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐