基於Spring Cache實現二級快取(Caffeine+Redis)

雨點的名字發表於2022-03-22
基於Spring Cache實現二級快取(Caffeine+Redis)

一、聊聊什麼是硬編碼使用快取?

在學習Spring Cache之前,筆者經常會硬編碼的方式使用快取。

我們來舉個實際中的例子,為了提升使用者資訊的查詢效率,我們對使用者資訊使用了快取,示例程式碼如下:

    @Autowire
    private UserMapper userMapper;

    @Autowire
    private RedisCache redisCache;

    //查詢使用者
    public User getUserById(Long userId) {
        //定義快取key
        String cacheKey = "userId_" + userId;
        //先查詢redis快取
        User user = redisCache.get(cacheKey);
        //如果快取中有就直接返回,不再查詢資料庫
        if (user != null) {
            return user;
        }
        //沒有再查詢資料庫
        user = userMapper.getUserById(userId);

        //資料存入快取,這樣下次查詢就能到快取中獲取
        if (user != null) {
            stringCommand.set(cacheKey, user);
        }

        return user;
    }

相信很多同學都寫過類似風格的程式碼,這種風格符合程式導向的程式設計思維,非常容易理解。但它也有一些缺點:

程式碼不夠優雅。業務邏輯有四個典型動作:儲存,讀取,修改,刪除。每次操作都需要定義快取Key ,呼叫快取命令的API,產生較多的重複程式碼;

快取操作和業務邏輯之間的程式碼耦合度高,對業務邏輯有較強的侵入性。侵入性主要體現如下兩點:

  • 開發聯調階段,需要去掉快取,只能註釋或者臨時刪除快取操作程式碼,也容易出錯

  • 某些場景下,需要更換快取元件,每個快取元件有自己的API,更換成本頗高

如果說是下面這樣的,是不是就優雅多了。

@Mapper
public interface UserMapper  {
    
    /**
     * 根據使用者id獲取使用者資訊
     *
     * 如果快取中有直接返回快取資料,如果沒有那麼就去資料庫查詢,查詢完再插入快取中,這裡快取的key字首為cache_user_id_,+傳入的使用者ID
     */
    @Cacheable(key = "'cache_user_id_' + #userId")
    User getUserById(Long userId);
}

再看實現類

    @Autowire
    private UserMapper userMapper;

    //查詢使用者
    public User getUserById(Long userId) {
        return userMapper.getUserById(userId);
    }

這麼一看是不是完全和快取分離開來,如果開發聯調階段,需要去掉快取那麼直接註釋掉註解就好了,是不是非常完美。

而且這一整套實現都不要自己手動寫,Spring Cache就已經幫我定義好相關注解和介面,我們可以輕易實現上面的功能。


二、Spring Cache簡介

Spring Cache是Spring-context包中提供的基於註解方式使用的快取元件,定義了一些標準介面,通過實現這些介面,就可以通過在

方法上增加註解來實現快取。這樣就能夠避免快取程式碼與業務處理耦合在一起的問題。

Spring Cache核心的介面就兩個:CacheCacheManager

1、Cache介面

該介面定義提供快取的具體操作,比如快取的放入、讀取、清理:

package org.Springframework.cache;
import java.util.concurrent.Callable;

public interface Cache {

	// cacheName,快取的名字,預設實現中一般是CacheManager建立Cache的bean時傳入cacheName
	String getName();

	//得到底層使用的快取,如Ehcache
	Object getNativeCache();

	// 通過key獲取快取值,注意返回的是ValueWrapper,為了相容儲存空值的情況,將返回值包裝了一層,通過get方法獲取實際值
	ValueWrapper get(Object key);

	// 通過key獲取快取值,返回的是實際值,即方法的返回值型別
	<T> T get(Object key, Class<T> type);

	// 通過key獲取快取值,可以使用valueLoader.call()來調使用@Cacheable註解的方法。當@Cacheable註解的sync屬性配置為true時使用此方法。
	// 因此方法內需要保證回源到資料庫的同步性。避免在快取失效時大量請求回源到資料庫
	<T> T get(Object key, Callable<T> valueLoader);

	// 將@Cacheable註解方法返回的資料放入快取中
	void put(Object key, Object value);

	// 當快取中不存在key時才放入快取。返回值是當key存在時原有的資料
	ValueWrapper putIfAbsent(Object key, Object value);

	// 刪除快取
	void evict(Object key);

	// 清空快取
	void clear();

	// 快取返回值的包裝
	interface ValueWrapper {

	// 返回實際快取的物件
		Object get();
	}
}

2、CacheManager介面

主要提供Cache實現bean的建立,每個應用裡可以通過cacheName來對Cache進行隔離,每個cacheName對應一個Cache實現。

package org.Springframework.cache;
import java.util.Collection;

public interface CacheManager {

	// 通過cacheName建立Cache的實現bean,具體實現中需要儲存已建立的Cache實現bean,避免重複建立,也避免記憶體快取
        //物件(如Caffeine)重新建立後原來快取內容丟失的情況
	Cache getCache(String name);

	// 返回所有的cacheName
	Collection<String> getCacheNames();
}

3、常用註解說明

@Cacheable:主要應用到查詢資料的方法上。

public @interface Cacheable {

    // cacheNames,CacheManager就是通過這個名稱建立對應的Cache實現bean
	@AliasFor("cacheNames")
	String[] value() default {};

	@AliasFor("value")
	String[] cacheNames() default {};

    // 快取的key,支援SpEL表示式。預設是使用所有引數及其計算的hashCode包裝後的物件(SimpleKey)
	String key() default "";

	// 快取key生成器,預設實現是SimpleKeyGenerator
	String keyGenerator() default "";

	// 指定使用哪個CacheManager,如果只有一個可以不用指定
	String cacheManager() default "";

	// 快取解析器
	String cacheResolver() default "";

	// 快取的條件,支援SpEL表示式,當達到滿足的條件時才快取資料。在呼叫方法前後都會判斷
	String condition() default "";
        
    // 滿足條件時不更新快取,支援SpEL表示式,只在呼叫方法後判斷
	String unless() default "";

	// 回源到實際方法獲取資料時,是否要保持同步,如果為false,呼叫的是Cache.get(key)方法;如果為true,呼叫的是Cache.get(key, Callable)方法
	boolean sync() default false;

}

@CacheEvict:清除快取,主要應用到刪除資料的方法上。相比Cacheable多了兩個屬性

public @interface CacheEvict {

  // ...相同屬性說明請參考@Cacheable中的說明
	// 是否要清除所有快取的資料,為false時呼叫的是Cache.evict(key)方法;為true時呼叫的是Cache.clear()方法
	boolean allEntries() default false;

	// 呼叫方法之前或之後清除快取
	boolean beforeInvocation() default false;
}

@CachePut:放入快取,主要用到對資料有更新的方法上。屬性說明參考@Cacheable

@Caching:用於在一個方法上配置多種註解

@EnableCaching:啟用Spring cache快取,作為總的開關,在SpringBoot的啟動類或配置類上需要加上此註解才會生效


三、使用二級快取需要思考的一些問題?

我們知道關聯式資料庫(Mysql)資料最終儲存在磁碟上,如果每次都從資料庫裡去讀取,會因為磁碟本身的IO影響讀取速度,所以就有了

像redis這種的記憶體快取。

通過記憶體快取確實能夠很大程度的提高查詢速度,但如果同一查詢併發量非常的大,頻繁的查詢redis,也會有明顯的網路IO上的消耗,

那我們針對這種查詢非常頻繁的資料(熱點key),我們是不是可以考慮存到應用內快取,如:caffeine。

當應用內快取有符合條件的資料時,就可以直接使用,而不用通過網路到redis中去獲取,這樣就形成了兩級快取。

應用內快取叫做一級快取,遠端快取(如redis)叫做二級快取

整個流程如下

基於Spring Cache實現二級快取(Caffeine+Redis)

流程看著是很清新,但其實二級快取需要考慮的點還很多。

1.如何保證分散式節點一級快取的一致性?

我們說一級快取是應用內快取,那麼當你的專案部署在多個節點的時候,如何保證當你對某個key進行修改刪除操作時,使其它節點

的一級快取一致呢?

2.是否允許儲存空值?

這個確實是需要考慮的點。因為如果某個查詢快取和資料庫中都沒有,那麼就會導致頻繁查詢資料庫,導致資料庫Down,這也是我們

常說的快取穿透。

但如果儲存空值呢,因為可能會儲存大量的空值,導致快取變大,所以這個最好是可配置,按照業務來決定是否開啟。

3.是否需要快取預熱?

也就是說,我們會覺得某些key一開始就會非常的熱,也就是熱點資料,那麼我們是否可以一開始就先儲存到快取中,避免快取擊穿。

4.一級快取儲存數量上限的考慮?

既然一級快取是應用內快取,那你是否考慮一級快取儲存的資料給個限定最大值,避免儲存太多的一級快取導致OOM。

5.一級快取過期策略的考慮?

我們說redis作為二級快取,redis是淘汰策略來管理的。具體可參考redis的8種淘汰策略。那你的一級快取策略呢?就好比你設定一級快取

數量最大為5000個,那當第5001個進來的時候,你是怎麼處理呢?是直接不儲存,還是說自定義LRU或者LFU演算法去淘汰之前的資料?

6.一級快取過期瞭如何清除?

我們說redis作為二級快取,我們有它的快取過期策略(定時、定期、惰性),那你的一級快取呢,過期如何清除呢?

這裡4、5、6小點如果說用我們傳統的Map顯然實現是很費勁的,但現在有更好用的一級快取庫那就是Caffeine


四、Caffeine 簡介

Caffeine,一個用於Java的高效能快取庫。

快取和Map之間的一個根本區別是快取會清理儲存的專案

1、寫入快取策略

Caffeine有三種快取寫入策略:手動同步載入非同步載入

2、快取值的清理策略

Caffeine有三種快取值的清理策略:基於大小基於時間基於引用

基於容量:當快取大小超過配置的大小限制時會發生回收。

基於時間

  1. 寫入後到期策略。
  2. 訪問後過期策略。
  3. 到期時間由 Expiry 實現獨自計算。

基於引用:啟用基於快取鍵值的垃圾回收。

  • Java種有四種引用:強引用,軟引用,弱引用和虛引用,caffeine可以將值封裝成弱引用或軟引用。
  • 軟引用:如果一個物件只具有軟引用,則記憶體空間足夠,垃圾回收器就不會回收它;如果記憶體空間不足了,就會回收這些物件的記憶體。
  • 弱引用:在垃圾回收器執行緒掃描它所管轄的記憶體區域的過程中,一旦發現了只具有弱引用的物件,不管當前記憶體空間足夠與否,都會
    回收它的記憶體。

3、統計

Caffeine提供了一種記錄快取使用統計資訊的方法,可以實時監控快取當前的狀態,以評估快取的健康程度以及快取命中率等,方便後

續調整引數。

4、高效的快取淘汰演算法

快取淘汰演算法的作用是在有限的資源內,儘可能識別出哪些資料在短時間會被重複利用,從而提高快取的命中率。常用的快取淘汰演算法有

LRU、LFU、FIFO等。

FIFO:先進先出。選擇最先進入的資料優先淘汰。
LRU:最近最少使用。選擇最近最少使用的資料優先淘汰。
LFU:最不經常使用。選擇在一段時間內被使用次數最少的資料優先淘汰。

LRU(Least Recently Used)演算法認為最近訪問過的資料將來被訪問的機率也更高。

LRU通常使用連結串列來實現,如果資料新增或者被訪問到則把資料移動到連結串列的頭部,連結串列的頭部為熱資料,連結串列的尾部如冷資料,當

資料滿時,淘汰尾部的資料。

LFU(Least Frequently Used)演算法根據資料的歷史訪問頻率來淘汰資料,其核心思想是“如果資料過去被訪問多次,那麼將來被訪問

的頻率也更高”。根據LFU的思想,如果想要實現這個演算法,需要額外的一套儲存用來存每個元素的訪問次數,會造成記憶體資源的浪費。

Caffeine採用了一種結合LRU、LFU優點的演算法:W-TinyLFU,其特點:高命中率、低記憶體佔用。

5、其他說明

Caffeine的底層資料儲存採用ConcurrentHashMap。因為Caffeine面向JDK8,在jdk8中ConcurrentHashMap增加了紅黑樹,在hash衝突

嚴重時也能有良好的讀效能。


五、基於Spring Cache實現二級快取(Caffeine+Redis)

前面說了,使用了redis快取,也會存在一定程度的網路傳輸上的消耗,所以會考慮應用內快取,但有點很重要的要記住:

應用內快取可以理解成比redis快取更珍惜的資源,所以,caffeine 不適用於資料量大,並且快取命中率極低的業務場景,如使用者維度的快取。

當前專案針對應用都部署了多個節點,一級快取是在應用內的快取,所以當對資料更新和清除時,需要通知所有節點進行清理快取的操作。

可以有多種方式來實現這種效果,比如:zookeeper、MQ等,但是既然用了redis快取,redis本身是有支援訂閱/釋出功能的,所以就

不依賴其他元件了,直接使用redis的通道來通知其他節點進行清理快取的操作。

當某個key進行更新刪除操作時,通過釋出訂閱的方式通知其它節點進行刪除該key本地的一級快取就可以了。

具體具體專案程式碼這裡就不再貼上出來了,這樣只貼上如何引用這個starter包。

1、maven引入使用

   <dependency>
            <groupId>com.jincou</groupId>
            <artifactId>redis-caffeine-cache-starter</artifactId>
            <version>1.0.0</version>
   </dependency>

2、application.yml

新增二級快取相關配置

# 二級快取配置
# 注:caffeine 不適用於資料量大,並且快取命中率極低的業務場景,如使用者維度的快取。請慎重選擇。
l2cache:
  config:
    # 是否儲存空值,預設true,防止快取穿透
    allowNullValues: true
    # 組合快取配置
    composite:
      # 是否全部啟用一級快取,預設false
      l1AllOpen: false
      # 是否手動啟用一級快取,預設false
      l1Manual: true
      # 手動配置走一級快取的快取key集合,針對單個key維度
      l1ManualKeySet:
      - userCache:user01
      - userCache:user02
      - userCache:user03
      # 手動配置走一級快取的快取名字集合,針對cacheName維度
      l1ManualCacheNameSet:
      - userCache
      - goodsCache
    # 一級快取
    caffeine:
      # 是否自動重新整理過期快取 true 是 false 否
      autoRefreshExpireCache: false
      # 快取重新整理排程執行緒池的大小
      refreshPoolSize: 2
      # 快取重新整理的頻率(秒)
      refreshPeriod: 10
      # 寫入後過期時間(秒)
      expireAfterWrite: 180
      # 訪問後過期時間(秒)
      expireAfterAccess: 180
      # 初始化大小
      initialCapacity: 1000
      # 最大快取物件個數,超過此數量時之前放入的快取將失效
      maximumSize: 3000

    # 二級快取
    redis:
      # 全域性過期時間,單位毫秒,預設不過期
      defaultExpiration: 300000
      # 每個cacheName的過期時間,單位毫秒,優先順序比defaultExpiration高
      expires: {userCache: 300000,goodsCache: 50000}
      # 快取更新時通知其他節點的topic名稱 預設 cache:redis:caffeine:topic
      topic: cache:redis:caffeine:topic

3、啟動類上增加@EnableCaching

/**
 *  啟動類
 */
@EnableCaching
@SpringBootApplication
public class CacheApplication {

	public static void main(String[] args) {
		SpringApplication.run(CacheApplication.class, args);
	}

}

4、在需要快取的方法上增加@Cacheable註解

/**
 *  測試
 */
@Service
public class CaffeineCacheService {

    private final Logger logger = LoggerFactory.getLogger(CaffeineCacheService.class);

    /**
     * 用於模擬db
     */
    private static Map<String, UserDTO> userMap = new HashMap<>();

    {
        userMap.put("user01", new UserDTO("1", "張三"));
        userMap.put("user02", new UserDTO("2", "李四"));
        userMap.put("user03", new UserDTO("3", "王五"));
        userMap.put("user04", new UserDTO("4", "趙六"));
    }

    /**
     * 獲取或載入快取項
     */
    @Cacheable(key = "'cache_user_id_' + #userId", value = "userCache")
    public UserDTO queryUser(String userId) {
        UserDTO userDTO = userMap.get(userId);
        try {
            Thread.sleep(1000);// 模擬載入資料的耗時
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        logger.info("載入資料:{}", userDTO);
        return userDTO;
    }


    /**
     * 獲取或載入快取項
     * <p>
     * 注:因底層是基於caffeine來實現一級快取,所以利用的caffeine本身的同步機制來實現
     * sync=true 則表示併發場景下同步載入快取項,
     * sync=true,是通過get(Object key, Callable<T> valueLoader)來獲取或載入快取項,此時valueLoader(載入快取項的具體邏輯)會被快取起來,所以CaffeineCache在定時重新整理過期快取時,快取項過期則會重新載入。
     * sync=false,是通過get(Object key)來獲取快取項,由於沒有valueLoader(載入快取項的具體邏輯),所以CaffeineCache在定時重新整理過期快取時,快取項過期則會被淘汰。
     * <p>
     */
    @Cacheable(value = "userCache", key = "#userId", sync = true)
    public List<UserDTO> queryUserSyncList(String userId) {
        UserDTO userDTO = userMap.get(userId);
        List<UserDTO> list = new ArrayList();
        list.add(userDTO);
        logger.info("載入資料:{}", list);
        return list;
    }

    /**
     * 更新快取
     */
    @CachePut(value = "userCache", key = "#userId")
    public UserDTO putUser(String userId, UserDTO userDTO) {
        return userDTO;
    }

    /**
     * 淘汰快取
     */
    @CacheEvict(value = "userCache", key = "#userId")
    public String evictUserSync(String userId) {
        return userId;
    }
}

專案原始碼: https://github.com/yudiandemingzi/springboot-redis-caffeine-cache


推薦相關二級快取相關專案

1.阿里巴巴jetcache: https://github.com/alibaba/jetcache

2.J2Cache: https://gitee.com/ld/J2Cache

3.l2cache: https://github.com/ck-jesse/l2cache(感謝)

這幾個現在業界比較常用的二級快取專案,功能更加強大,而且效能更高效,使用也非常方便只要引入jar包,新增配置註解就可以。


相關文章