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





























