周一早晨的 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,最后补防御」。
三句话总结:
- 不要默认上闭包表——大部分后台菜单(节点 < 500,查询模式简单)parent_id + 缓存 + 防御就够,更轻、更易排查;
- 真上闭包表的场景是「节点 > 千,频繁查任意子树/祖先链」,这时候它确实是无可替代的方案;
- 不管哪种 schema,循环引用检查都不能省——这是开篇那个事故真正的根因。
技术选型的成熟,往往不是「用了多新的方案」,而是「知道每个方案的 bug 会出现在哪——然后提前在那里搭好防御」。