一文深入了解史上最強的Java堆內緩存框架Caffeine


它提供了一個近乎最佳的命中率。從性能上秒殺其他一堆進程內緩存框架,Spring5更是為了它放棄了使用多年的GuavaCache

緩存,在我們的日常開發中用的非常多,是我們應對各種性能問題支持高並發的一大利器。我們熟知的緩存有堆緩存(Ehcache3.x、Guava Cache等)、堆外緩存(Ehcache3.x、MapDB等)、分布式緩存(Redis、 memcached等)等等。今天要上場的主角是Caffeine,它其實是Google基於Java8對GuavaCache的重寫升級版本,支持豐富的緩存過期策略,尤其是TinyLfu 淘汰算法,提供了一個近乎最佳的命中率。從性能上(讀、寫、讀/寫)也足以秒殺其他一堆進程內緩存框架。Spring5更是直接放棄了使用了多年的Guava,而采用了Caffeine。
在這里插入圖片描述(以上數據來自官方讀寫性能測試結果,更多測試結果詳見 https://github.com/ben-manes/caffeine/wiki/Benchmarks)

當然在實際使用中基本會涉及中多個緩存的組合使用,比如二級緩存(Caffeine+Redis)、多級緩存等等,這個以后再講。接下來我們分【基礎實戰】、【高階用法】、【理論概述】三個部分來聊一聊史上最強的Java堆內緩存框架。
(在“碼大叔”公眾號回復數字136即可獲取演示源碼及牛逼的TinyLfu論文。論文版權歸原作者所有,向大神學習致敬)

基礎實戰

接下來我們通過一些例子來演示Caffeine的基礎用法,首先我們通springboot新建一個mds-caffeine-demo的Gradle工程。

一、基礎配置

1、添加依賴

需要使用到 spring-boot-starter-cache和caffeine兩個包

implementation 'org.springframework.boot:spring-boot-starter-cache'
implementation 'com.github.ben-manes.caffeine:caffeine'

2、在applicationyml文件中添加配置

spring:
   cache:
       type: caffeine

3、添加注解

在啟動類上添加@EnableCaching
在這里插入圖片描述
就是這么地 so easy,Caffeine就已經集成到我們的項目中來了。

二、實戰演示

假設我們數據庫中有一張User表,里面有【碼大叔和小九九】2條數據

id name birdhtday
1 碼大叔 2012-05-12
2 小九九 1999-09-19

場景1:添加及使用緩存

只需要使用@Cacheable注解即可自動將數據添加到緩存中,后續直接從緩存中讀取數據。
value:表示緩存的名稱,這個參數value還是比較誤導人的,不是緩存的值,所以官方還提供了一種寫法:cacheNames。
key:表示緩存的key,可以為空。如果指定需要按照SpEL表達式編寫

方法1、將用戶對象以ID作為key存放到緩存中。

在這里插入圖片描述
我們訪問頁面:
在這里插入圖片描述
第一次:打印了數據庫操作的日志
在這里插入圖片描述 第二次:沒有打印,表示緩存添加成功。

方法2、將滿足條件的數據存放到緩存中

@Cacheable有一個參數叫做condition,該條件為true時則放到緩存到。該參數同樣需使用SpEL表達式。
在這里插入圖片描述
接下來我們分別進行用戶1、用戶2、用戶1、用戶2 四次查詢。我們看到只打印了3條數據,第二次訪問用戶1從緩存中讀取數據,用戶2每次都是從數據庫中讀取數據,沒進入緩存。
在這里插入圖片描述
【敲黑板】

  • 還有一個條件參數unless,與condition的用法恰好相反。
  • 使用了條件式緩存后,哪怕哪怕緩存里已經有數據了,也依然會跳過緩存。比如我們在其他方法中將“小九九”添加到了緩存中,但通過該方法獲取小九九的數據時,依然是從數據庫中取值。
  • @Cacheable注解不僅僅可以標記在一個方法上,還可以標記在一個類上,表示該類所有的方法都是支持緩存的。
  • 我們除了使用參數作為key之外,Spring還為我們提供了一個root對象可以用來生成key,比如 #root.methodName(當前方法名), #root.target(當前被調用的對象), #root.args[0]( #root.args[0])等等。

場景2:更新緩存

使用@CachePut,添加了該注解后每次都會觸發真實方法的調用
在這里插入圖片描述
我們覺得碼大叔的年齡可能造假了,怎么可能是2012年,把它更新為真實的年齡。
在這里插入圖片描述
我們看到數據庫層面打印了日志。
在這里插入圖片描述
此時我們再訪問獲取用戶信息方法,已經獲取到了最新的數據,但服務端卻沒有任何日志。
在這里插入圖片描述
這表明該注解已幫我們把最新的信息更新到了緩存中。

【敲黑板】

  • 在方法上使用了@CachePut注解如果方法返回了void或者null,也會同步更新緩存,緩存的對象為空,所以使用時務必要注意。緩存默認是支持存儲nul的,這也符合我們使用緩存的訴求。如果在某些特殊的場景下不希望緩存null對象,可以使用condition條件:condition = "#result != null" 即可。

場景3:刪除緩存

使用@CacheEvict注解,可以手動將對象從緩存中刪除。
在這里插入圖片描述
比如上面的方法,表示將指定id的用戶從緩存中刪除。如果期望將USER的所有緩存刪除,則可以使用參數 allEntries = true(默認為false) 即可。
【敲黑板】

  • 如果方法里有代碼邏輯,那么是先刪除緩存還是先執行方法呢?答案是先執行方法,后清除緩存。如果期望先清除緩存后執行方法,則添加參數 beforeInvocation = true即可。

高階用法

1:線程鎖定

前面我們提到了@Cacheable可以添加緩存,當緩存過期之后如果多個線程同時請求過來,而該方法執行較慢時可能會導致大量請求堆積,甚至導致緩存瞬間被擊穿,所有請求同時去到數據庫,數據庫瞬間負荷增高。所以該注解還提供了一個參數 sync:默認為false,如果為true時表示多個線程同時調用此時只有一個線程能夠成功調用,其他線程直接取這次調用的返回值。不過它在代碼注釋上也寫了,這僅僅是個hint,具體還是要看緩存提供者。
在這里插入圖片描述
不管sync設置是true還是false,Caffeine默認使用的都是單線程 :只允許一個線程去加載數據,其余線程阻塞。這樣其實也會導致效率低下,用戶等待。因此建議配合refreshAfterWrite一起使用:只阻塞加載數據的線程,其余線程返回舊數據。

2:緩存失效

初始化緩存時,我們還可以設置3個參數:expireAfterAccess、expireAfterWrite、refreshAfterWrite。千萬不要被這三個單詞的表面意思誤導,網上很多寫法也是錯的。比如expireAfterAccess,不是表示訪問完多長時間就過期,而是多長時間沒有訪問就失效。

  • expireAfterAccess=[duration]:指在指定時間內沒有被讀或寫就回收
  • expireAfterWrite=[duration]: 指在指定時間內沒有被創建或覆蓋就回收
  • refreshAfterWrite=[duration]:指在指定時間內沒有被創建/覆蓋,則指定時間過后再次訪問時會去刷新該緩存,在新值沒有到來之前,始終返回舊值

我們以expireAfterWrite為例,配置如下,然后不停地訪問,我們看到每隔5秒后就自動更新一次緩存。
在這里插入圖片描述在這里插入圖片描述
【敲黑板】

  • 如果是yml文件要注意寫法,這幾個都是spec的value值,caffeine會自行解析,不要像下面這種寫法,是錯誤的。
    在這里插入圖片描述
  • 以expireAfterWrite為例,假設設置的是5秒,並不是指5秒后自動更新,而是在5秒后的下一次訪問時才更新
  • 如果expireAfterWrite和expireAfterAccess同時存在,以expireAfterWrite為准。

3:refreshAfterWrite

這個參數在前面也提到了在日常使用中用的比較多,尤其是對於互聯網高並發的場景,所以額外再補充講幾點。
1、使用了refreshAfterWrite后,啟動項目會報如下的錯誤,

2020-03-08 13:51:51,144|o.s.boot.SpringApplication|reportFailure|Application run failed
org.springframework.beans.factory.BeanCreationException: Error creating bean with name 'cacheManager' defined in class path resource [org/springframework/boot/autoconfigure/cache/CaffeineCacheConfiguration.class]: Bean instantiation via factory method failed; nested exception is org.springframework.beans.BeanInstantiationException: Failed to instantiate [org.springframework.cache.caffeine.CaffeineCacheManager]: Factory method 'cacheManager' threw exception; nested exception is java.lang.IllegalStateException: refreshAfterWrite requires a LoadingCache
   at org.springframework.beans.factory.support.ConstructorResolver.instantiate(ConstructorResolver.java:656)
   at com.qiaojs.mds.MDSApplication.main(MDSApplication.java:16)
Caused by: org.springframework.beans.BeanInstantiationException: Failed to instantiate [org.springframework.cache.caffeine.CaffeineCacheManager]: Factory method 'cacheManager' threw exception; nested exception is java.lang.IllegalStateException: refreshAfterWrite requires a LoadingCache
   ... 19 common frames omitted
Caused by: java.lang.IllegalStateException: refreshAfterWrite requires a LoadingCache
   ... 20 common frames omitted

這需要我們去實現一個CacheLoader,再重啟就OK了。

@Bean
public CacheLoader<Object, Object> cacheLoader() {
CacheLoader<Object, Object> cacheLoader = new CacheLoader<Object, Object>() {
  @Override
  public Object load(Object key) throws Exception {
    log.info("load key:{}", key);
    return null;
  }
  @Override
  public Object reload(Object key, Object oldValue) throws Exception {
    log.info("reload key:{},oldValue:{}", key, oldValue);
    return oldValue;
  }
};
return cacheLoader;
}

2、前面也提到了Caffeine在緩存過期時默認只有一個線程去加載數據,配置了refreshAfterWrite后當大量請求過來時,可以確保其他用戶快速獲取響應。但refreshAfterWrite本身默認刷新也是同步的,也就意味着該調用者的線程還會處於等待狀態,如有對於響應要求比較高時,可以改寫reaload方法讓它也異步去執行。

// 1、定義一個線程
private static ListeningExecutorService service = MoreExecutors.listeningDecorator(Executors.newFixedThreadPool(10));
//2、異步加載
 private static LoadingCache<String, String> cache = CacheBuilder.newBuilder().refreshAfterWrite(1, TimeUnit.SECONDS)
           .build(new CacheLoader<String, String>() {
               ……
               @Override
               public ListenableFuture<String> reload(String key, String oldValue) throws Exception {
                    log.info("......后台線程池異步刷新:" + key);
                   return service.submit(callable);
               }

這樣就非常地完美了。

4:公共配置

如果一個類里有很多的緩存方法,可以使用@CacheConfig注解。
在這里插入圖片描述

5、制定多個緩存規則

有時候我們可能需要配置多個緩存規則,以用戶為例,假設用戶名為唯一的,我們既要設置id為緩存的key,也要設置userName作為緩存的key,這個時候就可以用@Caching。當然,更新和刪除時也都可以使用,我們先看一下它的定義:
在這里插入圖片描述
使用舉例:
在這里插入圖片描述

6、使用Java類配置

在實際使用中,我們很少使用yml或porperties來配置緩存的一些定義,除非緩存的場景或者規則很少,一般都是使用java類來配置。這個就不做多講,大家可以直接在碼大叔公眾號回復136獲取演示代碼

@Bean(name = "caffeineCacheManager")
@Primary
public CacheManager caffeineCacheManager() {
  SimpleCacheManager cacheManager = new SimpleCacheManager();
  ArrayList<CaffeineCache> caches = new ArrayList<CaffeineCache>();
  //方法1:通過枚舉定義
  // for (CacheDefineEnum cacheDefine : CacheDefineEnum.values()) {
  // Caffeine<Object, Object> caffeine = Caffeine.newBuilder();
  // if (-1 != cacheDefine.getTtl()) {
  // caffeine.expireAfterWrite(cacheDefine.getTtl(), cacheDefine.getTimeUnit());
  // }
  // Cache<Object, Object> cache = caffeine.maximumSize(cacheDefine.getMaxSize()).build();
  // caches.add(new CaffeineCache(cacheDefine.name(), cache));
  // }
  //方法二:通過
  caches.add(new CaffeineCache("USER",
  Caffeine.newBuilder()
    .expireAfterAccess(5, TimeUnit.SECONDS)
    .build()));
    cacheManager.setCaches(caches);
  return cacheManager;
}

7、查看緩存信息

在開發過程中,如果需要驗證緩存是否生效或者我們的配置是否正確,除了看系統的運行行為,我們還可以直接去查看緩存的信息。

private CacheManager cacheManager;   
@GetMapping("/cache/info")
public Object cacheData(String id) {
  Cache cache = cacheManager.getCache("USER");
  if (null == cache.get(id)) {
    return "cache is null";
  }
  Object obj = cache.get(id).get();
  if (null == obj) {
    return "null obj";
  } else {
    return "Object Info:" + obj.toString();
  }
}

8:統計監控

通過使用Caffeine.recordStats(),可以轉化成一個統計的集合. 通過 Cache.stats() 返回一個CacheStats。CacheStats提供以下統計方法

  • hitRate(): 返回緩存命中率
  • evictionCount(): 緩存回收數量
  • averageLoadPenalty(): 加載新值的平均時間

9、其他配置參數

  • initialCapacity=[integer]: 初始的緩存空間大小
  • maximumSize=[long]: 緩存的最大條數
  • maximumWeight=[long]: 緩存的最大權重
  • weakKeys: 打開key的弱引用
  • weakValues:打開value的弱引用
  • softValues:打開value的軟引用
  • recordStats:開發統計功能

注意:

  • maximumSize和maximumWeight不可以同時使用
  • weakValues和softValues不可以同時使用

理論概述

1、驅逐策略(Eviction)

  • 基於大小
    -- 基於緩存容量
    -- 基於權重
  • 基於時間
  • 基於引用

2、基於引用

java有四種引用:強引用,軟引用,弱引用和虛引用,caffeine可以將值封裝成弱引用或軟引用。

  • 軟引用:如果一個對象只具有軟引用,則內存空間足夠,垃圾回收器就不會回收它;如果內存空間不足了,就會回收這些對象的內存。
  • 弱引用:弱引用的對象擁有更短暫的生命周期。在垃圾回收器線程掃描它所管轄的內存區域的過程中,一旦發現了只具有弱引用的對象,不管當前內存空間足夠與否,都會回收它的內存

3、淘汰算法

這一塊就不做多講了,大家可以直接下載關於TinyLFU的論文。
理論部分就不做多講了,網上文章很多,再推薦一篇比較經典的文章:
http://highscalability.com/blog/2016/1/25/design-of-a-modern-cache.html

【結尾】

關於Java相關的緩存標准,一個是JSR107,一個是Spring Cache。目前Spring Cache基本已經成為了現實中的標准(Spring Cache它也是支持JSR107規范的,可謂非常的友好。(請導入spring-contextr-support包)),所以市面上它的實現產品非常豐富,這些產品間使用起來基本可以無縫切換。整個流程走下來,除了基本配置外,沒有引入其他的代碼依賴。
在這里插入圖片描述
所以無論你現在使用的Ehcache還是GuavaCache,基本都可以直接切換到Caffeine上面來。
在“碼大叔”公眾號回復數字136即可獲取演示源碼及牛逼的TinyLfu論文。論文版權歸原作者所有,向大神學習致敬)

參考:

https://github.com/ben-manes/caffeine(官方)
https://www.jianshu.com/p/d3bca89b56f7
https://segmentfault.com/a/1190000016091569?utm_source=tag-newest

推薦閱讀:
SpringCloud第二代實戰系列(一):使用Nacos實現服務注冊與發現

感謝各位大佬關注公眾號“碼大叔”,我們一起交流學習!
微信公眾號:碼大叔 十年戎“碼”,老“叔”開花


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM