一、本地緩存:Caffeine
1、簡介
Caffeine是一種高性能、高命中率、內存占用低的本地緩存庫,簡單來說它是 Guava Cache 的優化加強版,是當下最流行、最佳(最優)緩存框架。
Spring5 即將放棄掉 Guava Cache 作為緩存機制,而改用 Caffeine 作為新的本地 Cache 的組件,這對于 Caffeine 來說是一個很大的肯定。為什么 Spring 會這樣做呢?其實在 Caffeine 的Benchmarks[3]里給出了極具說服力的數據,對于讀和寫的場景,和其他幾個緩存工具進行了比較,Caffeine 的性能都表現很突出。
https://zhuanlan.zhihu.com/p/610410926
從上圖可以看出Caffine性能遠超其他本地緩存框架,所以本地緩存用它準沒錯~~
Caffeine其性能突出表現得益于采用了W-TinyLFU(LUR和LFU的優點結合)開源的緩存技術,緩存性能接近理論最優,同時借鑒了 Guava Cache 大部分的概念(諸如核心概念Cache、LoadingCache、CacheLoader、CacheBuilder等等,幾乎可以保證開發人員從Guava Cache 到Caffeine的無縫切換,Caffeine可謂是站在巨人肩膀上呱呱落地的,并且做到了精益求精。
2、用法
Caffeine借鑒了 Guava Cache 大部分的概念(諸如核心概念Cache、LoadingCache、CacheLoader、CacheBuilder)等等,所以數據加載方式都是一個套路
1)緩存類型
Caffeine提供了多種緩存類型:
從同步、異步的角度來說有
# 1、同步加載的緩存:Cache
Cache<Object, Object> cache = Caffeine.newBuilder().maximumSize(10).expireAfterWrite(1, TimeUnit.SECONDS).build();cache.put("1","張三");
System.out.println(cache.getIfPresent("1"));# 2、異步加載的緩存:AsnyncCache
AsyncCache<String, User> asyncCache = Caffeine.newBuilder().maximumSize(100).expireAfterWrite(5, TimeUnit.MINUTES).buildAsync();// 手動提交異步加載任務
CompletableFuture<User> future = asyncCache.get("user1", key -> userDao.getUserAsync(key));
future.thenAccept(user -> System.out.println("異步加載完成:" + user));
從手動加載、自動加載的角度來說有
# 1、手動加載的:Cache、AsnyncCache# 2、自動加載的:LoadingCache、LoadingAsnyncCache
LoadingCache<String, String> dictionaryCache = Caffeine.newBuilder().maximumSize(500).expireAfterWrite(60 * 12, TimeUnit.MINUTES).removalListener(new DictionaryRemovalListener()).recordStats().build(new DictionaryLoader());
-- 或者 --
AsyncLoadingCache<String, String> asyncLoadingCache = Caffeine.newBuilder().expireAfterWrite(1, TimeUnit.SECONDS).maximumSize(10).buildAsync(key -> {Thread.sleep(1000);return new Date().toString();});//異步緩存返回的是CompletableFuture
CompletableFuture<String> future = asyncLoadingCache.get("1");
future.thenAccept(System.out::println);
2)常用的緩存屬性
緩存初始容量
initialCapacity :整數,表示能存儲多少個緩存對象。
為什么要設置初始容量呢?
因為如果提前能預估緩存的使用大小,那么可以設置緩存的初始容量,以免緩存不斷地進行擴容,致使效率不高。
最大容量
maximumSize :最大容量,如果緩存中的數據量超過這個數值,Caffeine 會有一個異步線程來專門負責清除緩存,按照指定的清除策略來清除掉多余的緩存
注意,清除多余緩存不會立即執行,需要時間;比如最大容量為3,當添加第4個緩存后立即獲取緩存數量可能發現是4,因為此時Caffeine的異步線程還沒來得及根據策略清除多余的緩存,等待一段時間再查就變成3了。
最大權重(比較少用)
maximumWeight :最大權重,可以為存入緩存的每個元素都設置一個權重值,當緩存中所有元素的權重值超過最大權重時,就會觸發異步清除。
static class Student{Integer age;String name;
}Caffeine<String, Student> caffeine = Caffeine.newBuilder().maximumWeight(30).weigher((String key, Student value)-> value.getAge()).build();cache.put("one", new Student(12, "one"));
cache.put("two", new Student(18, "two"));
cache.put("three", new Student(1, "three"));
TimeUnit.SECONDS.sleep(10);
System.out.println(cache.estimatedSize()); // 2
System.out.println(cache.getIfPresent("two")); // null# 如上示例,以緩存對象的age為權重值,并設置最大權重為30
# 當放入緩存對象的age之和超過30時,會執行異步清除(需要時間)
# 應該是依次清除占比最大的,直到低于最大權重值
過期策略
expireAfterAccess: 根據最后一次訪問時間和當前時間間隔,超過指定值觸發清除,注意:這里訪問包括讀取和寫入
expireAfterWrite: 某個數據在多久沒有被更新后,就過期。
refreshAfterWrite: 寫操作完成后多久才將數據刷新進緩存中,即通過LoadingCache.refresh(K)進行異步刷新, 如果想覆蓋默認的刷新行為, 可以實現CacheLoader.reload(K, V)方法
上面是基于時間實現過期清除的,Cadfeine也提供基于軟/弱引用實現過期,但是比較復雜、我沒搞懂
清除、更新監聽
removalListener: 當緩存中的數據發送更新,或者被清除時,就會觸發監聽器:
removalListener 方法的參數是一個 RemovalListener 對象,但是可以函數式傳參,當數據被更新或者清除時,會給監聽器提供三個內容,(鍵,值,原因)分別對應代碼中的三個參數,(鍵,值)都是更新前,清除前的舊值, 這樣可以了解到清除的詳細了
清除的原因有 5 個,存儲在枚舉類 RemovalCause 中:
EXPLICIT : 表示顯式地調用刪除操作,直接將某個數據刪除。
REPLACED:表示某個數據被更新。
EXPIRED:表示因為生命周期結束(過期時間到了),而被清除。
SIZE:表示因為緩存空間大小受限,總權重受限,而被清除。
COLLECTED : 英文意思“冷靜”,這個不明白。
public class CaffeineCacheRemovalListener implements RemovalListener<Object, Object> {@Overridepublic void onRemoval(@Nullable Object k, @Nullable Object v, @NonNull RemovalCause cause) {log.info("[移除緩存] key:{} reason:{}", k, cause.name());// 超出最大緩存if (cause == RemovalCause.SIZE) {
?}// 超出過期時間if (cause == RemovalCause.EXPIRED) {// do something}// 顯式移除if (cause == RemovalCause.EXPLICIT) {// do something}// 舊數據被更新if (cause == RemovalCause.REPLACED) {// do something}}
}
緩存狀態與統計
默認情況下,緩存的狀態會用一個 CacheStats 對象記錄下來,通過訪問 CacheStats 對象就可以知道當前緩存的各種狀態指標,指標如下所示:
totalLoadTime :總共加載時間。
loadFailureRate :加載失敗率,= 總共加載失敗次數 / 總共加載次數
averageLoadPenalty :平均加載時間,單位-納秒
evictionCount :被淘汰出緩存的數據總個數
evictionWeight :被淘汰出緩存的那些數據的總權重
hitCount :命中緩存的次數
hitRate :命中緩存率
loadCount :加載次數
loadFailureCount :加載失敗次數
loadSuccessCount :加載成功次數
missCount :未命中次數
missRate :未命中率
requestCount :用戶請求查詢總次數
3、實例
思路:
1)如果是mvc架構,就直接定義一個共用的util或者配置類加載緩存即可;
2)如果是ddd設計模式,建議直接在基礎層新建一個配置類,配置類加載的時候初始化;
然后在repo調用這個緩存配置類對外暴露的查詢方法獲取緩存;
不同領域的app層的ervice調用repo獲取緩存、使用。
@Component
@Slf4j
public class DictionaryCache {// 定義默認值,用于兜底private static final Map<String, String> defaultDictionaryMap = new HashMap<>(10);static {defaultDictionaryMap.put(CommonConstants.Dictionary.KEY_ELECTRICITY_ACTP, CommonConstants.Dictionary.VAL_ELECTRICITY_ACTP);defaultDictionaryMap.put(CommonConstants.Dictionary.KEY_NO_ACTP_MONIT_TIME, CommonConstants.Dictionary.VAL_NO_ACTP_MONIT_TIME);}// 定義緩存LoadingCache<String, String> dictionaryCache;// 注入查詢實例,例如dao、delegate等@Resourceprivate SystemDelegate systemDelegate;// 初始化@PostConstructpublic void init() {dictionaryCache = Caffeine.newBuilder().maximumSize(500) // 容量.expireAfterWrite(12 * 60, TimeUnit.MINUTES) // 過期時間.removalListener(new DictionaryRemovalListener()) // 清楚or更新監聽.recordStats() // 統計.build(new DictionaryLoader());}// 加載class DictionaryLoader implements CacheLoader<String, String> {@Overridepublic String load(@NotNull String dicCode) {try {DictionaryQuery query = new DictionaryQuery();query.setDicParentCode("dic_type");List<SysDictionary> dictionaryList = systemDelegate.getDictionaryList(query);if(dictionaryList.isEmpty()) {// 查詢字典失敗處理log.error("dic list is empty, use default val");return StringUtils.isBlank(defaultDictionaryMap.get(dicCode)) ? null : defaultDictionaryMap.get(dicCode);}List<SysDictionary> resultList = dictionaryList.stream().filter(obj -> dicCode.equals(obj.getDicCode())).collect(Collectors.toList());if(resultList.isEmpty()) {// 字典不存在處理log.error("dicCode does not match value, use default val");return StringUtils.isBlank(defaultDictionaryMap.get(dicCode)) ? null : defaultDictionaryMap.get(dicCode);}return resultList.get(0).getDicExtValue1();} catch (Exception e) {log.error("load system dictionary from systemDelegate error, dicCode:{}, e:{}", dicCode, e.getMessage());return StringUtils.isBlank(defaultDictionaryMap.get(dicCode)) ? null : defaultDictionaryMap.get(dicCode);}}}// 清除or更新監聽實現class DictionaryRemovalListener implements RemovalListener<Object, Object> {@Overridepublic void onRemoval(@Nullable Object key, @Nullable Object val, @NonNull RemovalCause cause) {// 可以打印要更新的緩存key,更新的原因log.info("DictionaryCache remove cache, key:{}, reason:{}", key, cause.name());// 可以順帶打印緩存的各種狀態統計指標log.info("DictionaryCache hitCount:{}, hitRate:{}, missCount:{}, missRate:{}, loadCount:{}, loadSuccessCount:{}, totalLoadTime:{}",dictionaryCache.stats().hitCount(),dictionaryCache.stats().hitRate(),dictionaryCache.stats().missCount(),dictionaryCache.stats().missRate(),dictionaryCache.stats().loadCount(),dictionaryCache.stats().loadSuccessCount(),dictionaryCache.stats().totalLoadTime());}}// 對外暴露一個查詢緩存的方法public String getDicValByDicCode(String dicCode) {return dictionaryCache.get(dicCode);}
}
4、補充:Caffeine高性能實現
判斷一個緩存的好壞最核心的指標就是命中率,影響緩存命中率有很多因素,包括業務場景、淘汰策略、清理策略、緩存容量等等。如果作為本地緩存, 它的性能的情況,資源的占用也都是一個很重要的指標。下面
我們來看看 Caffeine 在這幾個方面是怎么著手的,如何做優化的。
W-TinyLFU 整體設計
上面說到淘汰策略是影響緩存命中率的因素之一,一般比較簡單的緩存就會直接用到 LFU(Least Frequently Used,即最不經常使用) 或者LRU(Least Recently Used,即最近最少使用) ,而 Caffeine 就是使用了 W-TinyLFU 算法。
W-TinyLFU 看名字就能大概猜出來,它是 LFU 的變種,也是一種緩存淘汰算法。那為什么要使用 W-TinyLFU 呢?
LRU 和 LFU 的缺點
LRU 實現簡單,在一般情況下能夠表現出很好的命中率,是一個“性價比”很高的算法,平時也很常用。雖然 LRU 對突發性的稀疏流量(sparse bursts)表現很好,但同時也會產生緩存污染,舉例來說,如果偶然性的要對全量數據進行遍歷,那么“歷史訪問記錄”就會被刷走,造成污染。
如果數據的分布在一段時間內是固定的話,那么 LFU 可以達到最高的命中率。但是 LFU 有兩個缺點,第一,它需要給每個記錄項維護頻率信息,每次訪問都需要更新,這是個巨大的開銷;第二,對突發性的稀疏流量無力,因為前期經常訪問的記錄已經占用了緩存,偶然的流量不太可能會被保留下來,而且過去的一些大量被訪問的記錄在將來也不一定會使用上,這樣就一直把“坑”占著了。
無論 LRU 還是 LFU 都有其各自的缺點,不過,現在已經有很多針對其缺點而改良、優化出來的變種算法。
TinyLFU
TinyLFU 就是其中一個優化算法,它是專門為了解決 LFU 上述提到的兩個問題而被設計出來的。
解決第一個問題是采用了 Count–Min Sketch 算法。
解決第二個問題是讓記錄盡量保持相對的“新鮮”(Freshness Mechanism),并且當有新的記錄插入時,可以讓它跟老的記錄進行“PK”,輸者就會被淘汰,這樣一些老的、不再需要的記錄就會被剔除。