找回密码
立即注册
搜索
热搜: Java Python Linux Go
发回帖 发新帖

4524

积分

0

好友

592

主题
发表于 1 小时前 | 查看: 4| 回复: 0

周一早晨的 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 会出现在哪——然后提前在那里搭好防御」。




上一篇:Claude Code 输出太啰嗦?caveman 压缩 Token 最高省 87% API 费用
下一篇:IT外包为什么永远在招人?网友真实经历告诉你外包的真相
您需要登录后才可以回帖 登录 | 立即注册

手机版|小黑屋|网站地图|云栈社区 ( 苏ICP备2022046150号-2 )

GMT+8, 2026-8-13 06:27 , Processed in 1.242557 second(s), 41 queries , Gzip On.

Powered by Discuz! X3.5

© 2025-2026 云栈社区.

快速回复 返回顶部 返回列表