一個快取使用的思考:Spring Cache VS Caffeine 原生 API
最近在學習本地快取發現,在 Spring 技術棧的開發中,既可以使用 Spring Cache 的註解形式操作快取,也可用各種快取方案的原生 API。那麼是否 Spring 官方提供的就是最合適的方案呢?那麼本文將通過一個案例來為你揭曉。
Spring Cache
Since version 3.1,the Spring Framework provides support for transparently adding caching to an existing Spring application. The caching abstraction allows consistent use of various caching solutions with minimal impact on the code.
Spring Cache 和 slf4j、jdbc 類似,是由 Spring Framwork 提供的一個快取抽象層,可以接入各種快取解決方案來進行使用,通過 Spring Cache 的整合,我們只需要通過一組註解來操作快取就可以了。目前支援的有 Generic、JCache (JSR-107) 、EhCache 2.x、Hazelcast、Infinispan、Couchbase、Redis、Caffeine、Simple,幾乎包含了主流的本地快取方案。
其主要的原理就是向 Spring Context 中注入 Cache 和 CacheManager 這兩個 bean,再通過 Spring Boot 的自動裝配技術,會根據專案中的配置檔案自動注入合適的 Cache 和 CacheManager 實現。
本地快取方案
Java 技術棧中成熟的本地快取方案已經有很多了,有大而全的 ehcache,也有後起之秀 Google Guava Cache。下面是常用的三大本地快取方案的對比,引用自部落格 如何優雅的設計和使用快取?
專案 | Ehcache | Guava Cache | Caffeine |
---|---|---|---|
讀寫效能 | 好 | 好,需要做淘汰操作 | 很好 |
淘汰演演算法 | 支援多種淘汰演演算法,LRU,LFU,FIFO | LRU,一般 | W-TinyLFU,很好 |
功能豐富程度 | 功能很豐富 | 功能很豐富,支援重新整理和虛引用等 | 功能和 Guava Cache 類似 |
工具大小 | 很大,最新版本 1.4MB | 是 Guava 工具類中的一個小部分,較小 | 一般,最新版本 644KB |
是否持久化 | 是 | 否 | 否 |
是否支援叢集 | 是 | 否 | 否 |
目前比較推薦的是 Caffeine,淘汰演演算法比較先進,並且得到 Spring Cache 的支援(新版的 Spring Cache 不再支援 Guava Cache)。下文的程式碼也是使用 Caffeine 的原生 API 的。
案例
使用過 Spring Cache 的人應該會發現,通過幾個註解就能夠輕鬆實現快取的 CRUD 操作,並且替換其他的快取方案不需要對程式碼進行改動嗎,同時也不需要寫例如下文的樣板程式碼:
{
// 快取命中
if(cache.getIfPresent(key) != null){
// todo
}else{
// 快取未命中,IO 獲取資料,結果存入快取
Object value = repo.getFromDB(key);
cache.put(key,value);
}
}
複製程式碼
那學到這裡,我就產生了疑惑,既然 Spring 出了快取的註解化開發,並且大量的部落格也都在往 Spring Cache 上引,那還是否需要用原生 API 呢?畢竟在 Spring Data JPA 出現後,我們的確很少關注後端 ORM 框架,也不再直接使用 Hibernate 了。
當我實現了專案中的一個需求,這個問題好像就豁然開朗了。
其實需求很簡單,原本在本地 HashMap 中維護的一個對映表,由於後期需要頻繁改動而放到了資料庫中。但由於資料量並不大且不配置對映表時,資料保持不變,因此既然在學習快取,就想把它加進去。那麼現在需要做的就是:
- 一個讀取對映表全表的方法
aliasMap()
。並快取資料到 Caffeine。 - 一個支援對映記錄 CRUD 操作的頁面,且修改對映表時,更新快取。
@Cacheable(value = "default",key = "#root.methodName")
@Override
public Map<String,String> aliasMap() {
return getMapFromDB();
}
複製程式碼
由於 Spring Cache 的註解一般是新增在類或者方法上的,換而言之,快取的是方法返回的物件。顯然,通過某個方法來觸發另一個快取中的物件的更新是行不通的。這樣是否意味著 Spring Cache 無法實現了呢?仔細去看一下 Spring Cache 的原理,其實還是可行的。
Spring Cache 會向 Spring Context 中注入 Cache 和 CacheManager 這兩個 bean,再通過 Spring Boot 的自動裝配技術,根據專案中的配置檔案自動注入合適的 Cache 和 CacheManager 實現。再看到 CaffeineCacheManager 的原始碼:
public class CaffeineCacheManager implements CacheManager {
private final ConcurrentMap<String,Cache> cacheMap = new ConcurrentHashMap(16);
private boolean dynamic = true;
private Caffeine<Object,Object> cacheBuilder = Caffeine.newBuilder();
@Nullable
private CacheLoader<Object,Object> cacheLoader;
private boolean allowNullValues = true;
}
複製程式碼
顯然,快取是存在 cacheMap 這樣一個 ConcurrentHashMap 中,那隻要我們能夠手動去獲取到這個 bean 的例項去操作它,那麼這個需求就可以實現了,程式碼如下:
@Autowired
private CacheManager cacheManager;
@Cacheable(value = "default",String> aliasMap() {
return getMapFromDB();
}
private Map<String,String> getMapFromDB() {
Map<String,String> map = new HashMap<>();
List<PartAlias> list = repository.findAll();
list.forEach(x -> map.put(x.getAlias(),x.getName()));
return map;
}
@Override
public PartAlias saveOrUpdateWithCache(PartAlias obj) {
PartAlias partAlias = repository.saveAndFlush(obj);
Cache cache = cacheManager.getCache("default");
cache.clear();
cache.put("aliasMap",getMapFromDB());
return partAlias;
}
複製程式碼
經過測試,上面的程式碼是可行的。顯然,遇到一些稍微複雜的需求,僅僅依靠 Spring Cache 的註解是遠遠不夠的,我們需要自己去操作 cache 物件。如果使用原生 API 就非常簡單了,能應對不同的需求。
What's More
上面的需求,Spring Cache 尚且還是能夠處理的,但是如果要實現資料的自動載入和重新整理呢?現在 Spring Cache 並不能夠很好的支援。
spring:
cache:
type: caffeine
caffeine:
spec: maximumSize=1024
cache-names: cache1,cache2
複製程式碼
上面的程式碼是用來配置 cache 的,結合上文 CaffeineCacheManager 的原始碼,我們可以知道,Spring Cache 的配置是全域性的,也就是說例如最大條數、過期時間等引數是為全體快取進行設定的,無法單獨為某個快取設定。而在 Caffeine 中用於資料載入和重新整理的 CacheLoader
也是 CaffeineCacheManager
這個 bean 共有的,因此也就失去存在的意義,畢竟每個快取的載入和資料重新整理的方式是不可能相同的。
因此,在遇到複雜場景下, 還是得上原生 API 的,Spring Cache 就顯得心有餘而力不足了。筆者也寫個一個工具類,可以全域性使用快取。
@Component
public class CaffeineCacheManager {
private final ConcurrentMap<String,Cache> cacheMap = new ConcurrentHashMap<>(16);
/**
* 快取建立
*
* @param cacheName
* @param cache
*/
public void createCache(String cacheName,Cache cache) {
cacheMap.put(cacheName,cache);
}
/**
* 快取獲取
*
* @param name
* @return
*/
public synchronized Cache getCache(String name) {
Cache cache = this.cacheMap.get(name);
if (cache == null) {
throw new IllegalArgumentException("No this cache.");
}
return cache;
}
@Autowired
private static CaffeineCacheManager manager;
public static void main(String[] args) {
manager.createCache("default",Caffeine.newBuilder()
.maximumSize(1024)
.build());
Cache<String,Object> cache = manager.getCache("default");
// TODO
}
}
複製程式碼
當然,再來提一提,既然是 Spring 的套路,總是會給開發者留一條後路的,如果願意折騰的,可以閱讀 CacheManager 的程式碼,再根據自己需求重新實現,從而管理自己的 cache 例項。
總結
本文不是一篇介紹 Spring Cache 和 Caffeine 用法的文章(有需要可以閱讀參考文獻),而是在探討 Spring Cache 和 Caffeine 的原生 API 的使用場景。顯然,Spring 全家桶有時未必是最優的解決方案(有能力重寫的另當別論了)!所以也希望網上有更多的部落格可以 focus on 框架本身的使用,而不是千篇一律的各種整合到 Spring xxx。
附錄
yaml 配置
initialCapacity: # 初始的快取空間大小
maximumSize: # 快取的最大條數
maximumWeight: # 快取的最大權重
expireAfterAccess: # 最後一次寫入或訪問後經過固定時間過期
expireAfterWrite: # 最後一次寫入後經過固定時間過期
refreshAfterWrite: # 建立快取或者最近一次更新快取後經過固定的時間間隔,重新整理快取
weakKeys: # 開啟 key 的弱引用
weakValues: # 開啟 value 的弱引用
softValues: # 開啟 value 的軟引用
recordStats: # 開發統計功能
複製程式碼
原理篇
合理使用快取
快取的目的主要是為了降低主主資料庫的壓力,服務可以直接從快取中獲取資料,從而提高響應速度,讓原本有限的資源可以服務更多的使用者。
從工程的角度來說,快取的引入並不是盲目的,如果主資料庫壓力不大的情況,並不需要新增快取。多新增一個資料中介軟體顯然也會增加維護的成本,而且在實際使用過程中還會存在一些,例如快取擊穿、快取雪崩等問題。
基本概念
- 命中率。 返回正確結果數 / 請求快取次數, 命中率越高,表明快取的使用率越高。
- 最大元素。快取中可以存放元素的最大數目, 一旦超過,會通過合適的策略進行清空操作。
- 清空策略:FIFO、LFU、LRU
快取型別
快取根據儲存的方式可以分成本地快取和分散式快取。
- 本地快取:本地快取一般指的是快取在應用程式內部的快取。以 Java 技術棧為例,可是自己實現一個 HashMap 作為資料快取,也可以直接使用現成的快取方案,例如 ehcache、caffeine 等。
- 分散式快取:快取和應用環境分離,會單獨存放在自己的伺服器或叢集裡,且多個應用可直接的共享快取。 常見的快取方案有 MemCache 和 Redis 等。
這一節主要是讓大家對快取有一個基本的認識,快取不是一種具體的技術,而是一種通用的技術方案,如何選擇合適的快取方案整合到自己的專案中去並且如何解決引入快取後產生的一些經典問題,不是本文討論的重點。有關於快取的詳細介紹和選型可以參考: