偷偷摘套内射激情视频,久久精品99国产国产精,中文字幕无线乱码人妻,中文在线中文a,性爽19p

3s → 30ms!SpringBoot樹形結(jié)構(gòu)“開掛”實(shí)錄:一次查詢提速100倍

開發(fā) 前端
產(chǎn)品經(jīng)理拿著電腦跑過來的時(shí)候,我仿佛看到了他頭頂?shù)臑踉疲骸斑@要是上線給客戶用,客戶不得以為咱們系統(tǒng)是用算盤寫的?

兄弟們,今天跟大家聊個(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é)。

  1. 第一步:從 “多次查庫” 到 “一次查庫”:把遞歸查庫改成一次性查全所有數(shù)據(jù),在內(nèi)存里組裝樹形結(jié)構(gòu),耗時(shí)從 3s 降到 300ms,快了 10 倍。
  2. 第二步:加 Redis 緩存:把組裝好的樹形結(jié)構(gòu)存到 Redis 里,下次直接拿,耗時(shí)從 300ms 降到 30ms 以內(nèi),又快了 10 倍。
  3. 第三步:進(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ù),先定位到瓶頸(比如這里的多次查庫),然后用最簡單的方法解決,往往效果最好。

責(zé)任編輯:武曉燕 來源: 石杉的架構(gòu)筆記
相關(guān)推薦

2025-08-18 03:00:22

Spring樹形結(jié)構(gòu)分類樹

2025-09-16 09:27:33

2013-02-28 10:35:59

hadoop大數(shù)據(jù)Hortonworks

2024-05-07 14:09:54

Meta模型token

2022-08-09 09:10:31

TaichiPython

2014-08-29 09:09:33

2020-02-23 17:15:29

SQL分析查詢

2018-03-28 14:10:10

GoPython代碼

2017-02-08 14:16:17

C代碼終端

2019-01-21 11:17:13

CPU優(yōu)化定位

2020-07-31 17:30:26

騰訊黑鯊游戲手機(jī)

2021-03-18 15:29:10

人工智能機(jī)器學(xué)習(xí)技術(shù)

2023-03-16 16:18:09

PyTorch程序人工智能

2022-10-10 09:10:07

命令磁盤排查

2020-08-10 11:00:02

Python優(yōu)化代碼

2025-09-12 16:45:51

SQL數(shù)據(jù)庫

2016-03-29 21:46:50

騰訊

2018-07-06 10:49:01

數(shù)據(jù)

2018-02-13 14:56:24

戴爾
點(diǎn)贊
收藏

51CTO技術(shù)棧公眾號(hào)