Spring Boot 菜单无限层级设计

作者:old wang 发布时间: 2025-05-22 阅读量:13 评论数:0

parent_id、路径枚举与闭包表怎么选

后台系统里,菜单、部门、分类、权限资源都绕不开树形结构。很多系统一开始都会用 parent_id,因为它简单、直观、好维护。但一旦线上出现递归栈溢出、循环引用、查询变慢,就容易得出一个过度结论parent_id 不行,必须换闭包表。

这个结论并不准确。

树形结构的建模不是在选一个“高级方案”,而是在选择未来的复杂度落在哪里。小规模菜单用 parent_id 完全没问题,真正要补的是写入校验和查询兜底;大规模分类、组织架构、内容树才更适合闭包表。

这篇文章从 Spring Boot 后台菜单的场景出发,梳理三种常见建模方案,以及它们各自适合的边界。

问题通常不是 parent_id,而是循环引用

一个典型事故是这样的:

后台登录后白屏,服务端日志不断刷:

java.lang.StackOverflowError
    at com.example.MenuService.buildTree(MenuService.java:88)
    at com.example.MenuService.buildTree(MenuService.java:91)
    at com.example.MenuService.buildTree(MenuService.java:91)
    at com.example.MenuService.buildTree(MenuService.java:91)

很多人第一反应是递归层级太深。但对于后台菜单来说,真正因为层级太深打爆 JVM 栈的概率很低。

后台菜单通常只有几层。真正危险的是数据形成了环:

A -> B -> C -> A

一旦某个节点的 parent_id 指向了自己的子孙节点,递归构建树时就会无限循环,直到栈被打爆。

所以这类问题的根因不是 parent_id 本身,而是没有在写入和查询两侧防住循环引用。

最少需要两道防线:

- 写入侧:修改父节点之前,确认目标父节点不是当前节点的后代。

- 查询侧:构建树时维护 visited 集合,发现重复访问立即中断。

只要这两道防线在,小规模菜单系统用 parent_id 是完全够的。

先判断你的菜单规模

在决定是否引入闭包表之前,先判断业务规模和查询模式。

场景

推荐方案

节点数小于 500,主要查询整棵菜单树

parent_id

节点数 500 到 5000,主要查询整棵树

parent_id + 缓存

节点数较大,频繁查询任意子树

闭包表

频繁查询祖先链、面包屑、任意层级归属

闭包表

历史系统已经使用路径字段

路径枚举,谨慎维护

大部分管理后台的菜单都属于前两类:节点数不多,登录后一次查全量菜单,再在内存里构建树。

这种场景下,一次 SELECT * FROM system_menu 加 O(n) 内存组装,通常已经足够快。引入闭包表反而会增加写入、移动、删除时的维护成本。

真正适合闭包表的通常是:

- 电商商品分类。

- 大型组织架构。

- 多层评论楼中楼。

- CMS 栏目树。

- 权限资源非常多且频繁做子树查询的系统。

方案一:邻接表 parent_id

邻接表是最常见的树形结构建模方式。每个节点只保存自己的父节点。

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 '系统菜单表';

优点:

- 表结构简单。

- 新增、修改、删除成本低。

- DBA 和开发都容易理解。

- 适合大部分后台菜单。

缺点:

- 查询任意子树需要递归。

- 不加防御时容易被循环引用打爆。

- 层级很深或节点很多时,递归查询体验较差。

写入侧防循环

修改父节点时,核心判断是:新的父节点不能是当前节点的后代。

对于邻接表,可以从目标父节点一路向上找父级。如果找到了当前节点,说明会形成环。

public boolean isInvalidParent(Long currentId, Long newParentId) {
    Set<Long> visited = new HashSet<>();
    Long cursor = newParentId;
    while (cursor != null && cursor != 0) {
        if (!visited.add(cursor)) {
            return true;
        }
        if (Objects.equals(cursor, currentId)) {
            return true;
        }
        cursor = menuMapper.selectParentId(cursor);
    }
    return false;

}

查询侧防循环

构建树时也要兜底,避免历史脏数据把接口打爆。

private List<MenuTreeDTO> buildTree(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(menu -> {
                MenuTreeDTO dto = toDTO(menu);
                dto.setChildren(buildTree(menu.getId(), byParent, visited));
                return dto;
            })
            .toList();

}

小规模菜单系统parent_id + 写入校验 + 查询 visited + 缓存 是最稳妥的组合。

方案二:路径枚举

路径枚举会在每个节点上保存完整路径。

例如:

/1/2/3

查询某个节点的所有子孙时,可以使用:

SELECT *
FROM system_menu
WHERE menu_path LIKE '/1/2/%';

优点:

- 查子树比纯 parent_id 直观。

- 查祖先路径比较方便。

缺点:

- 移动节点时,要批量更新所有子孙节点路径。

- 路径字段有长度限制。

- 路径更新容易出现并发一致性问题。

- LIKE 查询的索引利用有限。

路径枚举适合读多写少、层级稳定的老系统。如果是新系统,一般不建议优先选择它。

方案三:闭包表

闭包表会额外维护一张祖先和后代的关系表。

主表仍然可以保留 parent_id,关系表记录所有祖先后代关系:

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 '菜单关系表';

假设有一条链:

1 -> 2 -> 3

关系表会存:

ancestor_id

descendant_id

depth

1

1

0

1

2

1

1

3

2

2

2

0

2

3

1

3

3

0

闭包表的本质是以空间换查询性能。

它适合:

- 频繁查询任意子树。

- 频繁查询祖先链。

- 节点规模较大。

- 查询性能比写入复杂度更重要。

它不适合:

- 菜单节点只有几十或几百个。

- 基本只查整棵树。

- 团队没有维护双表一致性的经验。

闭包表的四个核心动作

闭包表真正的复杂度在写入侧。新增、移动、删除都要同步维护关系表。

新增节点

新增节点时,需要插入:

- 自身到自身的关系。

- 父节点所有祖先到新节点的关系。

List<SystemMenuRelation> relations = new ArrayList<>();
relations.add(new SystemMenuRelation(newId, newId, 0));
relationMapper.selectByDescendant(parentId).forEach(parentRelation -> {
    relations.add(new SystemMenuRelation(
            parentRelation.getAncestorId(),
            newId,
            parentRelation.getDepth() + 1
    ));
});
relationMapper.batchInsert(relations);

移动节点

移动节点前,先判断新父节点是否已经是当前节点的后代。

闭包表里这个判断只需要一条 SQL:

SELECT COUNT(*)
FROM system_menu_relation
WHERE ancestor_id = #{menuId}
  AND descendant_id = #{newParentId};

如果结果大于 0,说明移动后会形成环,必须拒绝。

移动时要先重建当前节点关系,再重建子节点关系。顺序不能反,因为子节点的祖先链依赖父节点的新关系。

rebuild(menuId):

删除当前节点旧关系

按当前 parent_id 重建当前节点关系

查询直接子节点

递归 rebuild(childId)

删除子树

闭包表查询子树很方便。

SELECT descendant_id
FROM system_menu_relation
WHERE ancestor_id = #{menuId};

拿到所有后代 ID 后,可以批量删除主表,再删除关系表。

查询子树和祖先链

查所有子孙:

SELECT m.*
FROM system_menu m
JOIN system_menu_relation r ON m.id = r.descendant_id
WHERE r.ancestor_id = #{menuId}
  AND r.depth > 0
  AND m.deleted = 0
ORDER BY r.depth, m.sort;

查直接子菜单:

SELECT m.*
FROM system_menu m
JOIN system_menu_relation r ON m.id = r.descendant_id
WHERE r.ancestor_id = #{menuId}
  AND r.depth = 1
  AND m.deleted = 0
ORDER BY m.sort;

查祖先链:

SELECT m.*
FROM system_menu m
JOIN system_menu_relation r ON m.id = r.ancestor_id
WHERE r.descendant_id = #{menuId}
  AND r.depth > 0
ORDER BY r.depth DESC;

三种方案对比

维度

邻接表 parent_id

路径枚举

闭包表

表数量

1 张

1 张

2 张

新增节点

简单

简单

需要维护关系表

移动节点

简单

需要批量更新路径

需要重建关系

删除子树

需要递归

可按路径查

子树查询方便

查询子树

一般

较方便

最方便

查询祖先

一般

较方便

最方便

循环引用检查

应用侧递归

应用侧校验

一条 SQL

实现复杂度

低

中

高

适合场景

后台菜单、小规模树

老系统、路径稳定

大规模分类、组织架构

生产环境容易踩的坑

循环引用检查不能省

不管用哪种模型,循环引用检查都必须做。

parent_id 不检查,会导致递归构建树时无限循环。

闭包表不检查,会污染关系表,而且污染范围比邻接表更大。邻接表通常只错一行,闭包表可能错一片祖先后代关系。

闭包表移动节点顺序不能错

移动节点时要先重建当前节点,再重建子节点。

如果先重建子节点,子节点会继承父节点的旧祖先链。这个错误通常不会立刻抛异常,但查询结果会悄悄变错。

idx_descendant 不能漏

闭包表主键 (ancestor_id, descendant_id) 只适合按祖先查后代。

如果要按后代查祖先,必须有 descendant_id 索引。

漏掉这个索引后,面包屑查询、循环检查、祖先链查询都会变慢。

缓存要和写入保持一致

后台菜单通常会加缓存。需要明确缓存失效时机:

- 新增菜单后失效。

- 修改父节点后失效。

- 修改排序后失效。

- 删除菜单后失效。

- 修改权限标识后失效。

缓存本身不是问题,问题是只缓存不设计失效策略。

推荐选型

如果你做的是普通管理后台菜单:

parent_id + 写入循环校验 + 查询 visited 兜底 + 菜单缓存

这是最简单、最容易维护、最适合团队协作的方案。

如果你做的是大型分类、组织架构、内容树:

主表 parent_id + 闭包表 + 事务维护关系 + 完整索引

这是更适合大规模查询的方案,但要接受写入侧复杂度。

如果你接手的是路径枚举老系统:

谨慎维护路径字段 + 控制移动节点频率 + 做好批量更新事务

不要轻易在路径枚举上继续堆复杂逻辑。如果系统还在快速演进,应该评估迁移到邻接表或闭包表。

结论

树形结构没有绝对正确的 schema。

parent_id 的问题通常不是它太弱,而是没有补齐循环引用检查和查询兜底。

闭包表也不是万能解法。它把查询复杂度转移到了写入侧,需要维护双表一致性、索引和移动节点重建顺序。

最终选择可以按一句话判断:

> 节点少、主要查整树,用 parent_id;节点多、频繁查任意子树和祖先链,用闭包表。

技术选型的关键不是用更复杂的方案,而是知道复杂度会落在哪里,并提前把防线放在那里。

评论