背景与痛点
在开发基于角色的权限系统时,菜单管理是常见需求。我们使用标准的自引用表结构存储层级数据:
CREATE TABLE menu (
id BIGINT PRIMARY KEY,
parent_id BIGINT,
name VARCHAR(255),
FOREIGN KEY (parent_id) REFERENCES menu(id)
);
初期实现采用典型的递归查询方式:
public List<Menu> fetchSubtree(Long parentId) {
List<Menu> nodes = menuRepo.findByParentId(parentId);
for (Menu node : nodes) {
node.setChildren(fetchSubtree(node.getId()));
}
return nodes;
}
该逻辑看似清晰,实则存在严重性能缺陷——每层节点触发一次数据库访问。对于包含上千条记录的菜单体系,这将导致数百甚至上千次 SQL 执行,形成经典的 N+1 查询问题。实测中,加载全部菜单耗时高达 3 秒以上,用户体验极差。
第一轮优化:单次查询 + 内存组装
意识到数据库频繁往返是瓶颈后,我决定将整个菜单集一次性拉取,在应用层完成树结构构建。
核心思路如下:
- 通过
findAll() 一次性获取所有菜单项
- 利用哈希映射建立 ID 到对象的快速索引
- 遍历原始列表,依据
parent_id 关联父子关系
具体实现代码:
public List<MenuItem> buildFullTree() {
List<Menu> rawList = menuRepo.findAll();
// 构建 ID 映射
Map<Long, MenuItem> nodeMap = rawList.stream()
.map(MenuItem::fromEntity)
.collect(Collectors.toMap(MenuItem::getId, m -> m));
List<MenuItem> rootNodes = new ArrayList<>();
for (Menu entity : rawList) {
MenuItem current = nodeMap.get(entity.getId());
if (entity.getParentId() == null) {
rootNodes.add(current);
} else {
MenuItem parent = nodeMap.get(entity.getParentId());
if (parent != null) {
parent.addChild(current);
}
}
}
return rootNodes;
}
其中
MenuItem 是专为传输设计的轻量级类:
@Data
public class MenuItem {
private Long id;
private String label;
private List<MenuItem> children = new ArrayList<>();
public static MenuItem fromEntity(Menu entity) {
MenuItem item = new MenuItem();
item.id = entity.getId();
item.label = entity.getName();
return item;
}
public void addChild(MenuItem child) {
children.add(child);
}
}
此方案将数据库交互压缩至一次全表扫描,后续操作均在内存中以 O(n) 时间复杂度完成。测试结果显示,处理 3000 条数据时接口响应时间降至约 30 毫秒,性能提升近百倍。
规避序列化陷阱
最初尝试直接使用 JPA 实体返回 JSON,结果引发严重问题:
- 延迟加载代理对象导致额外查询
- 双向关联(如父节点持有子节点、子节点又引用父节点)造成 Jackson 序列化死循环
- 不必要的字段增加网络传输负担
引入独立的 DTO 类彻底解决了上述问题。仅保留前端所需字段,并确保结构无环,显著提升了序列化效率和安全性。
第二轮加速:引入分布式缓存
考虑到菜单数据具有"低频更新、高频读取"的特性,非常适合缓存。借助 Spring 的声明式缓存机制,添加一行注解即可实现自动缓存:
@Cacheable(value = "menuHierarchy", key = "'full_tree'")
public List<MenuItem> getCachedMenuTree() {
return buildFullTree();
}
配合 Redis 作为缓存后端,绝大多数请求无需访问数据库或执行任何计算,直接从缓存获取结果。压测表明,命中缓存的请求平均延迟降至 **5 毫秒以内**,系统吞吐能力大幅提升。
能否用数据库原生能力?CTE 的权衡
现代数据库如 MySQL 8.0 支持递归公用表表达式(Recursive CTE),理论上可直接返回有序树节点:
WITH RECURSIVE hierarchy AS (
SELECT id, parent_id, name FROM menu WHERE parent_id IS NULL
UNION ALL
SELECT m.id, m.parent_id, m.name
FROM menu m
INNER JOIN hierarchy h ON m.parent_id = h.id
)
SELECT * FROM hierarchy;
尽管语法上可行,但在实际项目中仍面临挑战:
- 老旧基础设施可能运行在不支持 CTE 的数据库版本
- SQL 复杂度上升,调试与维护成本高
- 深层嵌套下数据库递归性能不如内存遍历
因此,在大多数业务场景下,"全量拉取 + 内存构造 + 缓存"仍是更稳定、可控且高效的解决方案。
性能对比总结
对三种方案进行基准测试(数据量:3000 条):
| 方案 | 平均响应时间 | 数据库压力 |
| 递归查询(N+1) | ~3000 ms | 极高 |
| 内存组装 | ~30 ms | 低(单次查询) |
| 内存组装 + 缓存 | <5 ms | 几乎无影响 |
从用户视角看,体验已从"长时间等待"跃迁至"瞬时响应"。
经验小结
本次优化揭示了一个重要原则:合理划分职责边界。数据库擅长持久化和事务控制,而内存更适合做复杂结构运算。面对层级数据查询,优先考虑批量提取后由应用逻辑处理,往往比依赖数据库递归更高效。结合缓存策略,更能将静态或弱一致性数据的访问推向极致性能。