《最佳化介面設計的思路》系列:第九篇—用好快取,讓你的介面速度飛起來

sum墨發表於2024-03-18

一、前言

大家好!我是sum墨,一個一線的底層碼農,平時喜歡研究和思考一些技術相關的問題並整理成文,限於本人水平,如果文章和程式碼有表述不當之處,還請不吝賜教。

作為一名從業已達六年的老碼農,我的工作主要是開發後端Java業務系統,包括各種管理後臺和小程式等。在這些專案中,我設計過單/多租戶體系系統,對接過許多開放平臺,也搞過訊息中心這類較為複雜的應用,但幸運的是,我至今還沒有遇到過線上系統由於程式碼崩潰導致資損的情況。這其中的原因有三點:一是業務系統本身並不複雜;二是我一直遵循某大廠程式碼規約,在開發過程中儘可能按規約編寫程式碼;三是經過多年的開發經驗積累,我成為了一名熟練工,掌握了一些實用的技巧。

前面的文章都是寫介面如何設計、介面怎麼驗權以及一些介面常用的元件,這篇寫點介面效能相關的。介面效能最佳化有很多途徑,比如表建索引、SQL最佳化、加快取、重構程式碼等等,本篇文章主要講一下我是怎麼在專案中使用快取來提高介面響應速度的。我覺得快取的使用主要有下面幾個方面:

  • 快取預熱
    • 定時任務預熱:定時任務在系統低峰期預載入資料到快取中。
    • 啟動預熱:系統啟動時預載入必要的資料到快取中。
  • 快取層次化
    • 多級快取:實現本地快取和分散式快取相結合,例如,先在本地快取中查詢,如果沒有再查詢Redis等分散式快取,最後才查詢資料庫。
    • 熱點資料快取:對頻繁訪問的資料進行快取,如使用者會話、熱門商品資訊、高頻訪問的內容等。

快取提高介面響應速度主要是上面這些思路,不過我不是來講概念的,那是面試要用的東西。我要講的是如何用程式碼實現這些思路,把它們真正用到專案中來,水平有限,我盡力說,不喜勿噴。

由於文章經常被抄襲,開源的程式碼甚至被當成收費項,所以原始碼裡面不是全部程式碼,有需要的同學可以留個郵箱,我給你單獨發!

二、快取預熱:手擼一個快取處理器

上面說了快取預熱主要是定時任務預熱、啟動預熱,那麼我們實現這個功能的時候,一般使用ConcurrentHashMapRedis來暫存資料,然後加上SpringBoot自帶的@Scheduled定時重新整理快取就夠了。雖然這樣可以實現快取預熱,但缺陷很多,一旦需要預熱的東西多起來就會變得越來越複雜,那麼如何實現一個好的快取處理器呢?接著看!

1、快取處理器設計

(1)一個好的快取處理器應該是這樣搭建的

  1. DAL實現,產出DAO和DO物件,定義快取領域模型
  2. 定義快取名稱,特別關注快取的初始化順序
  3. 編寫資料倉儲,透過模型轉換器實現資料模型到快取模型的轉化
  4. 編寫快取管理器,推薦繼承抽象管理器
  5. 根據業務需求,設計快取資料介面(putAll,get,getCacheInfo等基礎API)
  6. 完成bean配置,最好是可插拔的註冊方式,快取管理器和資料倉儲、擴充套件點服務

(2)思路分析

2、程式碼實現

a. 每個處理器都有快取名字、描述資訊、快取初始化順序等資訊,所以應該定義一個介面,名字為CacheNameDomain;

CacheNameDomain.java

package com.summo.demo.cache;

public interface CacheNameDomain {

    /**
     * 快取初始化順序,級別越低,越早被初始化
     * <p>
     * 如果快取的載入存在一定的依賴關係,透過快取級別控制初始化或者重新整理時快取資料的載入順序<br>
     * 級別越低,越早被初始化<br>
     * <p>
     * 如果快取的載入沒有依賴關係,可以使用預設順序<code>Ordered.LOWEST_PRECEDENCE</code>
     *
     * @return 初始化順序
     * @see org.springframework.core.Ordered
     */
    int getOrder();

    /**
     * 快取名稱,推薦使用英文大寫字母表示
     *
     * @return 快取名稱
     */
    String getName();

    /**
     * 快取描述資訊,用於列印日誌
     *
     * @return 快取描述資訊
     */
    String getDescription();
}

b. 可以使用一個列舉類將不同的快取處理器分開,有利於管理,取名為CacheNameEnum;

CacheNameEnum.java

package com.summo.demo.cache;

import org.springframework.core.Ordered;

/**
 * @description 快取列舉
 */
public enum CacheNameEnum implements CacheNameDomain {
    /**
     * 系統配置快取
     */
    SYS_CONFIG("SYS_CONFIG", "系統配置快取", Ordered.LOWEST_PRECEDENCE),
    ;

    private String name;

    private String description;

    private int order;

    CacheNameEnum(String name, String description, int order) {
        this.name = name;
        this.description = description;
        this.order = order;
    }

    @Override
    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

    @Override
    public String getDescription() {
        return description;
    }

    public void setDescription(String description) {
        this.description = description;
    }

    @Override
    public int getOrder() {
        return order;
    }

    public void setOrder(int order) {
        this.order = order;
    }
}

c. 快取資訊轉換工具,以便dump出更友好的快取資訊,取名為CacheMessageUtil;

CacheMessageUtil.java

package com.summo.demo.cache;


import java.util.Iterator;
import java.util.List;
import java.util.Map;

/**
 * @description 快取資訊轉換工具,以便dump出更友好的快取資訊
 */
public final class CacheMessageUtil {

    /** 換行符 */
    private static final char ENTERSTR  = '\n';

    /** Map 等於符號 */
    private static final char MAP_EQUAL = '=';

    /**
     * 禁用建構函式
     */
    private CacheMessageUtil() {
        // 禁用建構函式
    }

    /**
     * 快取資訊轉換工具,以便dump出更友好的快取資訊<br>
     * 對於List<?>的型別轉換
     *
     * @param cacheDatas 快取資料列表
     * @return 快取資訊
     */
    public static String toString(List<?> cacheDatas) {
        StringBuilder builder = new StringBuilder();
        for (int i = 0; i < cacheDatas.size(); i++) {
            Object object = cacheDatas.get(i);
            builder.append(object);

            if (i != cacheDatas.size() - 1) {
                builder.append(ENTERSTR);
            }
        }

        return builder.toString();
    }

    /**
     * 快取資訊轉換工具,以便dump出更友好的快取資訊<br>
     * 對於Map<String, Object>的型別轉換
     *
     * @param map 快取資料
     * @return 快取資訊
     */
    public static String toString(Map<?, ?> map) {
        StringBuilder builder = new StringBuilder();
        int count = map.size();
        for (Iterator<?> i = map.keySet().iterator(); i.hasNext();) {
            Object name = i.next();
            count++;

            builder.append(name).append(MAP_EQUAL);
            builder.append(map.get(name));

            if (count != count - 1) {
                builder.append(ENTERSTR);
            }
        }

        return builder.toString();
    }

}

d. 每個處理器都有生命週期,如初始化、重新整理、獲取處理器資訊等操作,這應該也是一個介面,處理器都應該宣告這個介面,名字為CacheManager;

CacheManager.java

package com.summo.demo.cache;

import org.springframework.core.Ordered;

public interface CacheManager extends Ordered {

    /**
     * 初始化快取
     */
    public void initCache();

    /**
     * 重新整理快取
     */
    public void refreshCache();

    /**
     * 獲取快取的名稱
     *
     * @return 快取名稱
     */
    public CacheNameDomain getCacheName();

    /**
     * 列印快取資訊
     */
    public void dumpCache();

    /**
     * 獲取快取條數
     *
     * @return
     */
    public long getCacheSize();
}

e. 定義一個快取處理器生命週期的處理器,會宣告CacheManager,做第一次的處理,也是所有處理器的父類,所以這應該是一個抽象類,名字為AbstractCacheManager;

AbstractCacheManager.java

package com.summo.demo.cache;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.InitializingBean;

/**
 * @description 快取管理抽象類,快取管理器都要整合這個抽象類
 */
public abstract class AbstractCacheManager implements CacheManager, InitializingBean {

    /**
     * LOGGER
     */
    protected static final Logger LOGGER = LoggerFactory.getLogger(AbstractCacheManager.class);

    /**
     * 獲取可讀性好的快取資訊,用於日誌列印操作
     *
     * @return 快取資訊
     */
    protected abstract String getCacheInfo();

    /**
     * 查詢資料倉儲,並載入到快取資料
     */
    protected abstract void loadingCache();

    /**
     * 查詢快取大小
     *
     * @return
     */
    protected abstract long getSize();

    /**
     * @see InitializingBean#afterPropertiesSet()
     */
    @Override
    public void afterPropertiesSet() {
        CacheManagerRegistry.register(this);
    }

    @Override
    public void initCache() {

        String description = getCacheName().getDescription();

        LOGGER.info("start init {}", description);

        loadingCache();

        afterInitCache();

        LOGGER.info("{} end init", description);
    }

    @Override
    public void refreshCache() {

        String description = getCacheName().getDescription();

        LOGGER.info("start refresh {}", description);

        loadingCache();

        afterRefreshCache();

        LOGGER.info("{} end refresh", description);
    }

    /**
     * @see org.springframework.core.Ordered#getOrder()
     */
    @Override
    public int getOrder() {
        return getCacheName().getOrder();
    }

    @Override
    public void dumpCache() {

        String description = getCacheName().getDescription();

        LOGGER.info("start print {} {}{}", description, "\n", getCacheInfo());

        LOGGER.info("{} end print", description);
    }

    /**
     * 獲取快取條目
     *
     * @return
     */
    @Override
    public long getCacheSize() {
        LOGGER.info("Cache Size Count: {}", getSize());
        return getSize();
    }

    /**
     * 重新整理之後,其他業務處理,比如監聽器的註冊
     */
    protected void afterInitCache() {
        //有需要後續動作的快取實現
    }

    /**
     * 重新整理之後,其他業務處理,比如快取變通通知
     */
    protected void afterRefreshCache() {
        //有需要後續動作的快取實現
    }
}

f. 當有很多快取處理器的時候,那麼需要一個統一註冊、統一管理的的地方,可以實現對分散在各處的快取管理器統一維護,名字為CacheManagerRegistry;

CacheManagerRegistry.java

package com.summo.demo.cache;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.InitializingBean;
import org.springframework.core.OrderComparator;
import org.springframework.stereotype.Component;

import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;

/**
 * @description 快取管理器集中註冊介面,可以實現對分散在各處的快取管理器統一維護
 */
@Component
public final class CacheManagerRegistry implements InitializingBean {

    /**
     * LOGGER
     */
    private static final Logger logger = LoggerFactory.getLogger(CacheManagerRegistry.class);

    /**
     * 快取管理器
     */
    private static Map<String, CacheManager> managerMap = new ConcurrentHashMap<String, CacheManager>();

    /**
     * 註冊快取管理器
     *
     * @param cacheManager 快取管理器
     */
    public static void register(CacheManager cacheManager) {
        String cacheName = resolveCacheName(cacheManager.getCacheName().getName());
        managerMap.put(cacheName, cacheManager);
    }

    /**
     * 重新整理特定的快取
     *
     * @param cacheName 快取名稱
     */
    public static void refreshCache(String cacheName) {
        CacheManager cacheManager = managerMap.get(resolveCacheName(cacheName));
        if (cacheManager == null) {
            logger.warn("cache manager is not exist,cacheName=", cacheName);
            return;
        }

        cacheManager.refreshCache();
        cacheManager.dumpCache();
    }

    /**
     * 獲取快取總條數
     */
    public static long getCacheSize(String cacheName) {
        CacheManager cacheManager = managerMap.get(resolveCacheName(cacheName));
        if (cacheManager == null) {
            logger.warn("cache manager is not exist,cacheName=", cacheName);
            return 0;
        }
        return cacheManager.getCacheSize();
    }

    /**
     * 獲取快取列表
     *
     * @return 快取列表
     */
    public static List<String> getCacheNameList() {
        List<String> cacheNameList = new ArrayList<>();
        managerMap.forEach((k, v) -> {
            cacheNameList.add(k);
        });
        return cacheNameList;
    }

    public void startup() {
        try {

            deployCompletion();

        } catch (Exception e) {

            logger.error("Cache Component Init Fail:", e);

            // 系統啟動時出現異常,不希望啟動應用
            throw new RuntimeException("啟動載入失敗", e);
        }
    }

    /**
     * 部署完成,執行快取初始化
     */
    private void deployCompletion() {

        List<CacheManager> managers = new ArrayList<CacheManager>(managerMap.values());

        // 根據快取級別進行排序,以此順序進行快取的初始化
        Collections.sort(managers, new OrderComparator());

        // 列印系統啟動日誌
        logger.info("cache manager component extensions:");
        for (CacheManager cacheManager : managers) {
            String beanName = cacheManager.getClass().getSimpleName();
            logger.info(cacheManager.getCacheName().getName(), "==>", beanName);
        }

        // 初始化快取
        for (CacheManager cacheManager : managers) {
            cacheManager.initCache();
            cacheManager.dumpCache();
        }
    }

    /**
     * 解析快取名稱,大小寫不敏感,增強重新整理的容錯能力
     *
     * @param cacheName 快取名稱
     * @return 轉換大寫的快取名稱
     */
    private static String resolveCacheName(String cacheName) {
        return cacheName.toUpperCase();
    }

    @Override
    public void afterPropertiesSet() throws Exception {
        startup();
    }
}

3、使用方式

專案結構如下:

這是完整的專案結構圖,具體的使用步驟如下:
step1、在CacheNameEnum中加一個業務列舉,如 SYS_CONFIG("SYS_CONFIG", "系統配置快取", Ordered.LOWEST_PRECEDENCE)
step2、自定義一個CacheManager繼承AbstractCacheManager,如public class SysConfigCacheManager extends AbstractCacheManager
step3、實現loadingCache()方法,這裡將你需要快取的資料查詢出來,但注意不要將所有的資料都放在一個快取處理器中,前面CacheNameEnum列舉類的作用就是希望按業務分開處理;
step4、在自定義的CacheManager類中寫自己的查詢資料方法,因為不同業務的場景不同,查詢引數、資料大小、格式、型別都不一致,所以AbstractCacheManager並沒有定義統一的取數方法,沒有意義;

下面是一個完整的例子
SysConfigCacheManager.java

package com.summo.demo.cache.manager;

import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;

import com.summo.demo.cache.AbstractCacheManager;
import com.summo.demo.cache.CacheMessageUtil;
import com.summo.demo.cache.CacheNameDomain;
import com.summo.demo.cache.CacheNameEnum;
import org.springframework.stereotype.Component;

/**
 * 系統配置管理器
 */
@Component
public class SysConfigCacheManager extends AbstractCacheManager {

    /**
     * 加個鎖,防止出現併發問題
     */
    private static final Lock LOCK = new ReentrantLock();

    /**
     * 底層快取元件,可以使用ConcurrentMap也可以使用Redis,推薦使用Redis
     */
    private static ConcurrentMap<String, Object> CACHE;

    @Override
    protected String getCacheInfo() {
        return CacheMessageUtil.toString(CACHE);
    }

    @Override
    protected void loadingCache() {
        LOCK.lock();
        try {
            //儲存資料,這裡就模擬一下了
            CACHE = new ConcurrentHashMap<>();
            CACHE.put("key1", "value1");
            CACHE.put("key2", "value2");
            CACHE.put("key3", "value3");
        } finally {
            LOCK.unlock();
        }

    }

    @Override
    protected long getSize() {
        return null == CACHE ? 0 : CACHE.size();
    }

    @Override
    public CacheNameDomain getCacheName() {
        return CacheNameEnum.SYS_CONFIG;
    }

    /**
     * 自定義取數方法
     *
     * @param key
     * @return
     */
    public static Object getConfigByKey(String key) {
        return CACHE.get(key);
    }
}

三、快取層次化:使用函數語言程式設計實現

1、先舉個例子

現有一個使用商品名稱查詢商品的需求,要求先查詢快取,查不到則去資料庫查詢;從資料庫查詢到之後加入快取,再查詢時繼續先查詢快取。

(1)思路分析

可以寫一個條件判斷,虛擬碼如下:

//先從快取中查詢
String goodsInfoStr = redis.get(goodsName);
if(StringUtils.isBlank(goodsInfoStr)){
	//如果快取中查詢為空,則去資料庫中查詢
	Goods goods = goodsMapper.queryByName(goodsName);
	//將查詢到的資料存入快取
	goodsName.set(goodsName,JSONObject.toJSONString(goods));
	//返回商品資料
	return goods;
}else{
	//將查詢到的str轉換為物件並返回
	return JSON.parseObject(goodsInfoStr, Goods.class);
}

流程圖如下

上面這串程式碼也可以實現查詢效果,看起來也不是很複雜,但是這串程式碼是不可複用的,只能用在這個場景。假設在我們的系統中還有很多類似上面商品查詢的需求,那麼我們需要到處寫這樣的if(...)else{...}。作為一個程式設計師,不能把類似的或者重複的程式碼統一起來是一件很難受的事情,所以需要對這種場景的程式碼進行最佳化。

上面這串程式碼的問題在於:入參不固定、返回值也不固定,如果僅僅是引數不固定,使用泛型即可。但最關鍵的是查詢方法也是不固定的,比如查詢商品和查詢使用者肯定不是一個查詢方法吧。

所以如果我們可以把一個方法(即上面的各種查詢方法)也能當做一個引數傳入一個統一的判斷方法就好了,類似於:

/**
 * 這個方法的作用是:先執行method1方法,如果method1查詢或執行不成功,再執行method2方法
 */
public static<T> T selectCacheByTemplate(method1,method2)

想要實現上面的這種效果,就不得不提到Java8的新特性:函數語言程式設計

2、什麼是函數語言程式設計

在Java中有一個package:java.util.function ,裡面全部是介面,並且都被@FunctionalInterface註解所修飾。

Function分類

  • Consumer(消費):接受引數,無返回值
  • Function(函式):接受引數,有返回值
  • Operator(操作):接受引數,返回與引數同型別的值
  • Predicate(斷言):接受引數,返回boolean型別
  • Supplier(供應):無引數,有返回值

具體我就不再贅述了,可以參考:https://blog.csdn.net/hua226/article/details/124409889

3、程式碼實現

核心程式碼非常簡單,如下

/**
  * 快取查詢模板
  *
  * @param cacheSelector    查詢快取的方法
  * @param databaseSelector 資料庫查詢方法
  * @return T
  */
public static <T> T selectCacheByTemplate(Supplier<T> cacheSelector, Supplier<T> databaseSelector) {
  try {
    log.info("query data from redis ······");
    // 先查 Redis快取
    T t = cacheSelector.get();
    if (t == null) {
      // 沒有記錄再查詢資料庫
      return databaseSelector.get();
    } else {
      return t;
    }
  } catch (Exception e) {
    // 快取查詢出錯,則去資料庫查詢
    log.info("query data from database ······");
    return databaseSelector.get();
    }
}

這裡的Supplier 就是一個加了@FunctionalInterface註解的介面。

4、使用方式

使用方式也非常簡單,如下

@Component
public class UserManager {

    @Autowired
    private CacheService cacheService;

    public Set<String> queryAuthByUserId(Long userId) {
        return BaseUtil.selectCacheByTemplate(
            //從快取中查詢
            () -> this.cacheService.queryUserFromRedis(userId),
            //從資料庫中查詢
            () -> this.cacheService.queryUserFromDB(userId));
    }
}

這樣就可以做到先查詢Redis,查詢不到再查詢資料庫,非常簡單也非常好用,我常用於查詢一些實體資訊的場景。不過這裡有一個注意的點:快取一致性。因為有時候底層資料會變化,需要做好一致性,否則會出問題。

四、小結一下

首先,快取確實可以提高API查詢效率,這點大家應該不會質疑,但快取並不是萬能的,不應該將所有資料都快取起來,應當評估資料的訪問頻率和更新頻率,以決定是否快取。
其次,在實施快取策略時,需要平衡快取的開銷、複雜性和所帶來的效能提升。此外,快取策略應該根據實際業務需求和資料特徵進行定製,不斷調整最佳化以適應業務發展。
最後,快取雖好,但不要亂用哦,否則會出現令你驚喜的BUG!😇

相關文章