3s → 30ms!SpringBoot樹形結構“開掛”實錄:一次查詢提速100倍
兄弟們,今天跟大家聊個咱們后端開發繞不開的坑 —— 樹形結構查詢。別慌,不是來勸退的,是來分享我上周剛踩完坑、把查詢耗時從 3 秒干到 30 毫秒的 “開掛” 經歷,相當于給系統裝了個火箭推進器,看完保準你也能抄作業。
先跟大家還原下當時的 “災難現場”:公司最近在做一個權限管理系統,里面的菜單結構是典型的樹形 —— 一級菜單下面掛二級,二級下面還有三級,像棵倒過來的圣誕樹。一開始測試環境數據少,沒覺得有啥問題,結果上周灰度發布給運營部門用,好家伙,運營同學點 “菜單管理” 按鈕,咖啡都沖好了,頁面還在那兒轉圈圈,控制臺一看,接口響應時間:3021ms!
產品經理拿著電腦跑過來的時候,我仿佛看到了他頭頂的烏云:“這要是上線給客戶用,客戶不得以為咱們系統是用算盤寫的?” 得,加班是跑不了了,接下來就是我跟這個樹形結構死磕的三天,最終把耗時壓到了 30ms 以內。下面就把整個優化過程拆解開,用大白話跟大家嘮明白,每個步驟都帶實操代碼,小白也能看懂。
一、先搞懂:為啥樹形結構查詢這么 “慢”?
在說優化之前,咱們得先弄明白一個事兒:樹形結構到底難在哪兒?平時咱們查個列表,select * from table where id = ? 一下就出來了,為啥到樹形這兒就卡殼了?
其實核心問題就一個:樹形結構是 “父子嵌套” 的,而數據庫是 “平面存儲” 的。就像你把一棵大樹砍成一節節的木頭堆在地上,想重新拼成樹,就得知道每節木頭的爹是誰、兒子是誰,這就需要不斷 “找關系”。
咱們先看看最初的 “爛代碼” 是咋寫的 —— 當時圖省事,直接用了遞歸查數據庫,代碼長這樣:
// 最初的爛代碼:遞歸查詢數據庫
@Service
public class MenuServiceImpl implements MenuService {
@Autowired
private MenuMapper menuMapper;
// 獲取所有菜單樹形結構
@Override
public List<MenuVO> getMenuTree() {
// 1. 先查一級菜單(parent_id = 0)
List<MenuDO> rootMenus = menuMapper.selectByParentId(0);
// 2. 遞歸給每個一級菜單查子菜單
return rootMenus.stream().map(this::buildMenuTree).collect(Collectors.toList());
}
// 遞歸構建子菜單
private MenuVO buildMenuTree(MenuDO parentMenu) {
MenuVO menuVO = new MenuVO();
BeanUtils.copyProperties(parentMenu, menuVO);
// 致命操作:每次遞歸都查一次數據庫!
List<MenuDO> childMenus = menuMapper.selectByParentId(parentMenu.getId());
if (!childMenus.isEmpty()) {
menuVO.setChildren(childMenus.stream().map(this::buildMenuTree).collect(Collectors.toList()));
}
return menuVO;
}
}現在回頭看這段代碼,我自己都想抽自己兩嘴巴子。當時覺得 “遞歸多優雅啊”,結果忽略了一個致命問題:每遞歸一次,就查一次數據庫!咱們算筆賬:如果一級菜單有 5 個,每個一級菜單下面有 10 個二級菜單,每個二級下面又有 10 個三級菜單,那總共要查多少次數據庫?1(查一級)+5(查二級)+5*10(查三級)=56 次!這還只是菜單不多的情況,要是菜單層級再多、數量再大,數據庫直接就被這輪番查詢 “干懵了”,響應時間能不高嗎?
而且數據庫的 “IO 操作” 本身就是個慢家伙 —— 內存操作是毫秒級甚至微秒級,而數據庫查詢要走網絡、要讀磁盤,一次查詢幾百毫秒,幾十次疊加下來,3 秒真不算夸張。
二、第一波優化:把數據庫 “拉黑名單”,內存里拼樹!
既然問題出在 “頻繁查數據庫”,那解決思路就很明確:先把所有數據一次性從數據庫撈出來,再在內存里拼樹形結構。就像你要拼樂高,先把所有零件都倒在桌子上,再慢慢拼,總比拼一步去抽屜里拿一次零件快吧?
說干就干,咱們先改 Service 層代碼,核心就兩步:1. 一次性查全所有菜單數據;2. 用內存遞歸(或者循環)拼出樹形結構。
第一步:改寫 Mapper,查全所有數據
先給 MenuMapper 加個查詢所有菜單的方法,就一句 SQL:
// MenuMapper.java
public interface MenuMapper {
// 原來的根據parentId查
List<MenuDO> selectByParentId(Long parentId);
// 新增:查所有菜單
List<MenuDO> selectAllMenus();
}對應的 XML 也簡單,不用加任何條件:
<!-- MenuMapper.xml -->
<select id="selectAllMenus" resultType="com.example.demo.entity.MenuDO">
select id, parent_id, menu_name, menu_url, icon, sort from sys_menu
</select>第二步:內存里拼樹形結構
這一步是關鍵,咱們要把查出來的所有菜單,在內存里按 parentId 的關系組裝成樹。這里有兩種方式:遞歸和循環,遞歸寫起來簡單,但如果菜單層級特別深(比如超過 100 層),可能會棧溢出,所以我這里用循環的方式,更穩妥。
先定義個工具類,專門用來組裝樹形結構,以后其他樹形需求也能復用:
// 樹形結構組裝工具類
public class TreeUtils {
/**
* 組裝樹形結構
* @param allNodes 所有節點列表
* @param rootParentId 根節點的parentId(這里菜單根節點是0)
* @return 組裝好的樹形結構
*/
public static <T extends TreeNode> List<T> buildTree(List<T> allNodes, Long rootParentId) {
// 1. 先把所有節點存到Map里,key是節點ID,value是節點對象,方便快速查找
Map<Long, T> nodeMap = new HashMap<>();
for (T node : allNodes) {
nodeMap.put(node.getId(), node);
}
// 2. 遍歷所有節點,給每個節點找爸爸,把自己加到爸爸的children里
List<T> rootNodes = new ArrayList<>();
for (T node : allNodes) {
Long parentId = node.getParentId();
// 如果是根節點,直接加入根節點列表
if (rootParentId.equals(parentId)) {
rootNodes.add(node);
continue;
}
// 不是根節點,找自己的父節點
T parentNode = nodeMap.get(parentId);
if (parentNode != null) {
// 父節點的children如果為空,初始化一下
if (parentNode.getChildren() == null) {
parentNode.setChildren(new ArrayList<>());
}
// 把當前節點加到父節點的children里
parentNode.getChildren().add(node);
}
}
return rootNodes;
}
}
// 注意:這里需要一個TreeNode接口,讓MenuVO實現,統一規范
public interface TreeNode {
Long getId();
Long getParentId();
List<? extends TreeNode> getChildren();
void setChildren(List<? extends TreeNode> children);
}
// MenuVO實現TreeNode接口
public class MenuVO implements TreeNode {
private Long id;
private Long parentId;
private String menuName;
private String menuUrl;
private String icon;
private Integer sort;
// 子菜單列表
private List<MenuVO> children;
// getter和setter省略...
// 實現TreeNode接口的方法
@Override
public Long getId() {
return this.id;
}
@Override
public Long getParentId() {
return this.parentId;
}
@Override
public List<? extends TreeNode> getChildren() {
return this.children;
}
@Override
public void setChildren(List<? extends TreeNode> children) {
this.children = (List<MenuVO>) children;
}
}然后改寫 Service 層,用這個工具類來組裝樹形:
@Service
public class MenuServiceImpl implements MenuService {
@Autowired
private MenuMapper menuMapper;
@Override
public List<MenuVO> getMenuTree() {
// 1. 一次性查全所有菜單數據(只查一次數據庫!)
List<MenuDO> allMenus = menuMapper.selectAllMenus();
// 2. 把MenuDO轉成MenuVO(DO是數據庫實體,VO是返回給前端的視圖對象,解耦)
List<MenuVO> allMenuVOs = allMenus.stream().map(menuDO -> {
MenuVO menuVO = new MenuVO();
BeanUtils.copyProperties(menuDO, menuVO);
return menuVO;
}).collect(Collectors.toList());
// 3. 用工具類組裝樹形結構,根節點parentId是0
return TreeUtils.buildTree(allMenuVOs, 0L);
}
}改完之后,咱們測一下耗時 —— 原來的 3 秒直接降到了 300ms 左右!足足快了 10 倍!這一步的核心就是減少數據庫 IO,把最耗時的 “多次查庫” 變成 “一次查庫”,剩下的操作都在內存里完成,速度自然就上來了。不過別著急慶祝,300ms 雖然比 3 秒好太多,但離 “優秀” 還有距離。產品經理雖然不催了,但我自己知道,還能再優化!
三、第二波優化:給數據 “裝個緩存”,直接從內存讀!
咱們再想想,300ms 的耗時主要花在哪兒了?雖然只查一次數據庫,但數據庫查詢還是要走網絡、讀磁盤,比如這次查所有菜單,數據庫可能要花 200ms 左右,剩下的 100ms 是內存組裝的時間。那能不能把 “查數據庫 + 內存組裝” 的結果直接存起來,下次要的時候直接拿?
當然可以!這就是咱們后端開發的 “萬金油”——緩存。這里我用 Redis 做緩存,因為 Redis 是內存數據庫,查數據比 MySQL 快好幾個數量級,而且 SpringBoot 整合 Redis 也特別簡單。
第一步:整合 Redis 依賴
先在 pom.xml 里加 Redis 的依賴,SpringBoot 有現成的 starter:
<!-- SpringBoot Redis依賴 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<!-- 阿里巴巴的FastJSON,用來序列化對象 -->
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>fastjson2</artifactId>
<version>2.0.32</version>
</dependency>然后在 application.yml 里配置 Redis:
spring:
redis:
host: 127.0.0.1 # 你的Redis地址
port: 6379 # 端口
password: 123456 # 密碼(沒設的話可以不寫)
database: 0 # 數據庫索引,默認0
timeout: 3000ms # 連接超時時間
lettuce:
pool:
max-active: 8 # 最大連接數
max-idle: 8 # 最大空閑連接
min-idle: 2 # 最小空閑連接第二步:配置 Redis 序列化
Redis 默認的序列化方式是 JDK 序列化,會把對象轉成一堆亂碼,而且占空間大,所以咱們用 FastJSON 來序列化,這樣存到 Redis 里的是 JSON 字符串,可讀性高,也省空間。
寫個 RedisConfig 配置類:
@Configuration
@EnableCaching // 開啟緩存支持
public class RedisConfig {
@Bean
public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory redisConnectionFactory) {
RedisTemplate<String, Object> redisTemplate = new RedisTemplate<>();
redisTemplate.setConnectionFactory(redisConnectionFactory);
// 配置FastJSON序列化器
GenericFastJsonRedisSerializer fastJsonSerializer = new GenericFastJsonRedisSerializer();
// key用String序列化
redisTemplate.setKeySerializer(new StringRedisSerializer());
redisTemplate.setHashKeySerializer(new StringRedisSerializer());
// value用FastJSON序列化
redisTemplate.setValueSerializer(fastJsonSerializer);
redisTemplate.setHashValueSerializer(fastJsonSerializer);
redisTemplate.afterPropertiesSet();
return redisTemplate;
}
// 配置緩存管理器,設置默認緩存過期時間
@Bean
public CacheManager cacheManager(RedisConnectionFactory redisConnectionFactory) {
RedisCacheConfiguration config = RedisCacheConfiguration.defaultCacheConfig()
// 緩存過期時間:30分鐘(根據業務調整,菜單不常變,設長點沒問題)
.entryTtl(Duration.ofMinutes(30))
// 序列化方式
.serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer()))
.serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(new GenericFastJsonRedisSerializer()))
// 允許為空
.disableCachingNullValues();
// 初始化緩存管理器
return RedisCacheManager.builder(redisConnectionFactory)
.cacheDefaults(config)
.build();
}
}第三步:給 Service 方法加緩存
這一步最簡單,只需要在 getMenuTree 方法上加個@Cacheable注解,指定緩存的 key,就能自動把方法的返回結果存到 Redis 里,下次調用的時候直接從緩存拿,不執行方法體了。
@Service
publicclass MenuServiceImpl implements MenuService {
@Autowired
private MenuMapper menuMapper;
// value:緩存的名稱,key:緩存的鍵(這里用"menu:tree",好識別)
@Cacheable(value = "menuCache", key = "'menu:tree'", unless = "#result == null")
@Override
public List<MenuVO> getMenuTree() {
// 下面的代碼跟之前一樣,但是只有第一次會執行,之后從緩存拿
List<MenuDO> allMenus = menuMapper.selectAllMenus();
List<MenuVO> allMenuVOs = allMenus.stream().map(menuDO -> {
MenuVO menuVO = new MenuVO();
BeanUtils.copyProperties(menuDO, menuVO);
return menuVO;
}).collect(Collectors.toList());
return TreeUtils.buildTree(allMenuVOs, 0L);
}
}加完緩存之后,咱們再測一次耗時 —— 第一次調用的時候,還是 300ms 左右(因為要查庫、組裝、存緩存),第二次調用直接跳到了30ms 以內!有時候甚至能到 10 幾 ms!這就是緩存的威力,直接把 “查庫 + 組裝” 的步驟跳過了,從 Redis 里拿現成的 JSON 字符串,反序列化成對象就返回,速度能不快嗎?不過這里有個坑要跟大家說一下:緩存更新。如果菜單數據改了(比如新增、刪除、修改菜單),緩存里的數據就會變成 “臟數據”,前端看到的還是舊的菜單。所以咱們得在修改菜單的方法里,把緩存清掉,讓下次查詢重新走查庫 + 組裝的流程,更新緩存。
比如新增菜單的方法:
@Service
publicclass MenuServiceImpl implements MenuService {
@Autowired
private MenuMapper menuMapper;
// 新增菜單:加@CacheEvict,清除緩存
@CacheEvict(value = "menuCache", key = "'menu:tree'")
@Override
public void addMenu(MenuDTO menuDTO) {
MenuDO menuDO = new MenuDO();
BeanUtils.copyProperties(menuDTO, menuDO);
menuMapper.insert(menuDO);
}
// 修改菜單:同樣清除緩存
@CacheEvict(value = "menuCache", key = "'menu:tree'")
@Override
public void updateMenu(MenuDTO menuDTO) {
MenuDO menuDO = new MenuDO();
BeanUtils.copyProperties(menuDTO, menuDO);
menuMapper.updateById(menuDO);
}
// 刪除菜單:也清除緩存
@CacheEvict(value = "menuCache", key = "'menu:tree'")
@Override
public void deleteMenu(Long id) {
menuMapper.deleteById(id);
}
// getMenuTree方法不變...
}@CacheEvict注解的作用就是 “清除緩存”,當執行 add、update、delete 方法的時候,會自動把 key 為 “menu:tree” 的緩存刪掉,下次調用 getMenuTree 的時候,就會重新查庫、組裝、存新的緩存,這樣數據就不會臟了。
四、進階優化:數據庫也能 “拼樹”,CTE 了解一下?
到這里,30ms 的耗時已經完全滿足業務需求了,但作為一個有追求的 Java 程序員,咱們得知道還有沒有其他玩法。比如,能不能讓數據庫直接返回樹形結構,不用在 Java 代碼里組裝?
答案是可以的,用數據庫的CTE(公共表表達式) ,也就是 “遞歸查詢”。不同數據庫的語法有點不一樣,我這里用 MySQL 8.0 為例(MySQL 5.7 及以下不支持 CTE,得用自連接,麻煩一點)。
用 CTE 在數據庫層面查樹形結構
先寫個 CTE 的 SQL,直接查詢出所有菜單的樹形結構,包括層級、父菜單名稱這些信息:
WITH RECURSIVE menu_tree AS (
-- 1. 錨點成員:查詢根節點(parent_id = 0)
SELECT
id,
parent_id,
menu_name,
menu_url,
icon,
sort,
1ASlevel, -- 層級,根節點是1級
menu_name AS full_name -- 完整名稱,根節點就是自己的名稱
FROM sys_menu
WHERE parent_id = 0
UNION ALL
-- 2. 遞歸成員:查詢子節點,跟錨點成員關聯
SELECT
m.id,
m.parent_id,
m.menu_name,
m.menu_url,
m.icon,
m.sort,
mt.level + 1ASlevel, -- 子節點層級比父節點+1
CONCAT(mt.full_name, ' > ', m.menu_name) AS full_name -- 完整名稱拼接父節點名稱
FROM sys_menu m
INNERJOIN menu_tree mt ON m.parent_id = mt.id -- 關聯父節點
)
-- 3. 查詢遞歸結果
SELECT * FROM menu_tree ORDERBYlevel, sort;這個 SQL 的邏輯跟咱們在 Java 里組裝樹形結構差不多:先查根節點,然后遞歸查子節點,把父節點的信息帶過來,還能順便計算層級、拼接完整名稱,特別方便。
在 MyBatis 里用 CTE
咱們把這個 SQL 寫到 MenuMapper 里,直接讓數據庫返回帶層級的列表,然后在 Java 里只需要簡單處理一下,不用再循環組裝了:
// MenuMapper.java
publicinterface MenuMapper {
// 原來的方法...
// 新增:用CTE查詢樹形結構
List<MenuTreeVO> selectMenuTreeByCTE();
}
// 對應的MenuTreeVO,多了level和fullName字段
publicclass MenuTreeVO {
private Long id;
private Long parentId;
privateString menuName;
privateString menuUrl;
privateString icon;
private Integer sort;
private Integer level; // 層級
privateString fullName; // 完整名稱(如:系統管理 > 菜單管理 > 新增菜單)
// getter和setter省略...
}XML 文件里寫 CTE 的 SQL:
<!-- MenuMapper.xml -->
<select id="selectMenuTreeByCTE" resultType="com.example.demo.vo.MenuTreeVO">
WITH RECURSIVE menu_tree AS (
SELECT
id,
parent_id,
menu_name,
menu_url,
icon,
sort,
1 AS level,
menu_name AS full_name
FROM sys_menu
WHERE parent_id = 0
UNION ALL
SELECT
m.id,
m.parent_id,
m.menu_name,
m.menu_url,
m.icon,
m.sort,
mt.level + 1 AS level,
CONCAT(mt.full_name, ' > ', m.menu_name) AS full_name
FROM sys_menu m
INNER JOIN menu_tree mt ON m.parent_id = mt.id
)
SELECT
id,
parent_id,
menu_name,
menu_url,
icon,
sort,
level,
full_name
FROM menu_tree
ORDER BY level, sort;
</select>然后在 Service 里調用這個方法,加緩存:
@Service
public class MenuServiceImpl implements MenuService {
@Autowired
private MenuMapper menuMapper;
// 用CTE查詢的方法,同樣加緩存
@Cacheable(value = "menuCache", key = "'menu:tree:cte'", unless = "#result == null")
@Override
public List<MenuTreeVO> getMenuTreeByCTE() {
// 直接返回數據庫查詢的結果,不用在Java里組裝了
returnmenuMapper.selectMenuTreeByCTE();
}
// 原來的方法...
}這種方式的優點是Java 代碼更簡潔,把組裝樹形的邏輯交給了數據庫,而且數據庫在處理這類遞歸查詢的時候,優化做得也不錯。不過缺點是數據庫耦合度高,如果以后換數據庫(比如從 MySQL 換成 Oracle),CTE 的語法可能要改,而且如果菜單數據量特別大(比如 10 萬級以上),數據庫遞歸查詢也可能會有性能問題。所以在實際項目里,到底用 “Java 內存組裝” 還是 “數據庫 CTE”,要看你的具體場景:數據量不大、想降低數據庫耦合度,就用 Java 內存組裝;數據量中等、想簡化 Java 代碼,就用 CTE。
五、再榨一榨:那些能再快 10ms 的小技巧
到這里,咱們的查詢耗時已經降到 30ms 以內了,但還有一些小細節能再優化一下,雖然提升可能只有幾毫秒,但積少成多,而且能體現咱們的專業性。
1. 數據庫索引優化
不管是原來的遞歸查庫,還是現在的查全表,parent_id這個字段都是高頻查詢字段,所以給parent_id加個索引,能讓數據庫查得更快。
給 sys_menu 表的 parent_id 字段建索引:
ALTER TABLE sys_menu ADD INDEX idx_sys_menu_parent_id (parent_id);建完索引之后,原來的selectByParentId方法和 CTE 里的關聯查詢,速度都會快一點,尤其是數據量比較大的時候,效果更明顯。
2. 減少返回字段
咱們之前的 SQL 里用的是select *,會把表所有字段都查出來,但前端可能只需要 id、parentId、menuName、menuUrl、icon、sort 這幾個字段,像創建時間、修改時間這些字段,前端用不上,查出來就是浪費帶寬和內存。
所以把 SQL 里的select *改成具體的字段:
<!-- 原來的selectAllMenus -->
<select id="selectAllMenus" resultType="com.example.demo.entity.MenuDO">
select id, parent_id, menu_name, menu_url, icon, sort from sys_menu
</select>這樣查出來的數據量更小,網絡傳輸更快,內存占用也更少,組裝樹形結構的時候也能快一點。
3. 用并行流代替普通流(謹慎用)
在把 MenuDO 轉成 MenuVO 的時候,咱們用的是普通流stream(),如果菜單數量特別多(比如 1 萬以上),可以試試并行流parallelStream(),利用多核 CPU 的優勢,加快轉換速度。
// 普通流
List<MenuVO> allMenuVOs = allMenus.stream().map(...).collect(...);
// 并行流
List<MenuVO> allMenuVOs = allMenus.parallelStream().map(...).collect(...);不過要注意,并行流會占用更多的 CPU 資源,而且如果流操作里有線程不安全的代碼(比如用了非線程安全的集合),會出問題,所以要謹慎使用,先測試再上線。
4. 緩存預熱
咱們的緩存是 “懶加載” 的,第一次調用接口的時候才會生成緩存,所以第一次調用的耗時還是比較高(300ms 左右)。如果想讓用戶每次調用都很快,可以做緩存預熱—— 項目啟動的時候,就主動調用 getMenuTree 方法,把緩存生成好。
寫個啟動類,實現 CommandLineRunner 接口:
@Component
public class CacheWarmUpRunner implements CommandLineRunner {
@Autowired
private MenuService menuService;
@Override
public void run(String... args) throws Exception {
// 項目啟動時,主動調用getMenuTree,生成緩存
menuService.getMenuTree();
System.out.println("菜單緩存預熱完成!");
}
}這樣項目一啟動,緩存就有了,用戶第一次調用接口的時候,直接從緩存拿,耗時也是 30ms 以內,體驗更好。
六、總結:從 3s 到 30ms,到底做了什么?
最后,咱們來回顧一下整個優化過程,其實核心思路就三個:減少數據庫 IO、利用緩存、優化細節。
- 第一步:從 “多次查庫” 到 “一次查庫”:把遞歸查庫改成一次性查全所有數據,在內存里組裝樹形結構,耗時從 3s 降到 300ms,快了 10 倍。
- 第二步:加 Redis 緩存:把組裝好的樹形結構存到 Redis 里,下次直接拿,耗時從 300ms 降到 30ms 以內,又快了 10 倍。
- 第三步:進階優化:用 CTE 讓數據庫直接返回樹形結構,給 parent_id 加索引,減少返回字段,做緩存預熱,讓速度再快一點。
整個過程沒有用什么特別高深的技術,都是咱們平時工作中能用到的基礎知識點,但就是這些基礎知識點的組合,讓查詢速度提升了 100 倍。這也告訴咱們,做性能優化不用一開始就上高大上的技術,先定位到瓶頸(比如這里的多次查庫),然后用最簡單的方法解決,往往效果最好。




























