Spring Boot 菜单无限层级设计

作者:old wang 发布时间: 2025-05-22 阅读量:7 评论数: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;节点多、频繁查任意子树和祖先链,用闭包表。

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

评论