1. 程式人生 > 其它 >Caffeine Cache:高效能 Java 本地快取元件

Caffeine Cache:高效能 Java 本地快取元件

技術標籤:javaredishash快取資料庫

點選上方Java後端,選擇設為星標

優質文章,及時送達


作者:rickiyang

來源:cnblogs.com/rickiyang/p/11074158.html

Guava Cache 的優點是封裝了get,put操作;提供執行緒安全的快取操作;提供過期策略;提供回收策略;快取監控。當快取的資料超過最大值時,使用LRU演算法替換。

這一篇我們將要談到一個新的本地快取框架:Caffeine Cache。它也是站在巨人的肩膀上-Guava Cache,藉著它的思想優化了演算法發展而來。

本篇博文主要介紹Caffine Cache 的使用方式。關注微信公眾號 Java後端。關注後 回覆 666 下載技術博文。

1. Caffine Cache 在演算法上的優點-W-TinyLFU

說到優化,Caffine Cache到底優化了什麼呢?我們剛提到過LRU,常見的快取淘汰演算法還有FIFO,LFU:

  1. FIFO:先進先出,在這種淘汰演算法中,先進入快取的會先被淘汰,會導致命中率很低。

  2. LRU:最近最少使用演算法,每次訪問資料都會將其放在我們的隊尾,如果需要淘汰資料,就只需要淘汰隊首即可。仍然有個問題,如果有個資料在 1 分鐘訪問了 1000次,再後 1 分鐘沒有訪問這個資料,但是有其他的資料訪問,就導致了我們這個熱點資料被淘汰。

  3. LFU:最近最少頻率使用,利用額外的空間記錄每個資料的使用頻率,然後選出頻率最低進行淘汰。這樣就避免了 LRU 不能處理時間段的問題。

上面三種策略各有利弊,實現的成本也是一個比一個高,同時命中率也是一個比一個好。Guava Cache雖然有這麼多的功能,但是本質上還是對LRU的封裝,如果有更優良的演算法,並且也能提供這麼多功能,相比之下就相形見絀了。

LFU的侷限性:在 LFU 中只要資料訪問模式的概率分佈隨時間保持不變時,其命中率就能變得非常高。比如有部新劇出來了,我們使用 LFU 給他快取下來,這部新劇在這幾天大概訪問了幾億次,這個訪問頻率也在我們的 LFU 中記錄了幾億次。

但是新劇總會過氣的,比如一個月之後這個新劇的前幾集其實已經過氣了,但是他的訪問量的確是太高了,其他的電視劇根本無法淘汰這個新劇,所以在這種模式下是有侷限性。

LRU的優點和侷限性:LRU可以很好的應對突發流量的情況,因為他不需要累計資料頻率。但LRU通過歷史資料來預測未來是侷限的,它會認為最後到來的資料是最可能被再次訪問的,從而給與它最高的優先順序。

在現有演算法的侷限性下,會導致快取資料的命中率或多或少的受損,而命中略又是快取的重要指標。HighScalability網站刊登了一篇文章,由前Google工程師發明的W-TinyLFU——一種現代的快取 。

Caffine Cache就是基於此演算法而研發。Caffeine 因使用 Window TinyLfu 回收策略,提供了一個近乎最佳的命中率

當資料的訪問模式不隨時間變化的時候,LFU的策略能夠帶來最佳的快取命中率。然而LFU有兩個缺點:

首先,它需要給每個記錄項維護頻率資訊,每次訪問都需要更新,這是個巨大的開銷;

其次,如果資料訪問模式隨時間有變,LFU的頻率資訊無法隨之變化,因此早先頻繁訪問的記錄可能會佔據快取,而後期訪問較多的記錄則無法被命中。

因此,大多數的快取設計都是基於LRU或者其變種來進行的。相比之下,LRU並不需要維護昂貴的快取記錄元資訊,同時也能夠反應隨時間變化的資料訪問模式。然而,在許多負載之下,LRU依然需要更多的空間才能做到跟LFU一致的快取命中率。因此,一個“現代”的快取,應當能夠綜合兩者的長處。

TinyLFU維護了近期訪問記錄的頻率資訊,作為一個過濾器,當新記錄來時,只有滿足TinyLFU要求的記錄才可以被插入快取。如前所述,作為現代的快取,它需要解決兩個挑戰:

一個是如何避免維護頻率資訊的高開銷;

另一個是如何反應隨時間變化的訪問模式。

首先來看前者,TinyLFU藉助了資料流Sketching技術,Count-Min Sketch顯然是解決這個問題的有效手段,它可以用小得多的空間存放頻率資訊,而保證很低的False Positive Rate。

但考慮到第二個問題,就要複雜許多了,因為我們知道,任何Sketching資料結構如果要反應時間變化都是一件困難的事情,在Bloom Filter方面,我們可以有Timing Bloom Filter,但對於CMSketch來說,如何做到Timing CMSketch就不那麼容易了。

TinyLFU採用了一種基於滑動視窗的時間衰減設計機制,藉助於一種簡易的reset操作:每次新增一條記錄到Sketch的時候,都會給一個計數器上加1,當計數器達到一個尺寸W的時候,把所有記錄的Sketch數值都除以2,該reset操作可以起到衰減的作用 。

W-TinyLFU主要用來解決一些稀疏的突發訪問元素。在一些數目很少但突發訪問量很大的場景下,TinyLFU將無法儲存這類元素,因為它們無法在給定時間內積累到足夠高的頻率。因此W-TinyLFU就是結合LFU和LRU,前者用來應對大多數場景,而LRU用來處理突發流量。

在處理頻率記錄的方案中,你可能會想到用hashMap去儲存,每一個key對應一個頻率值。那如果資料量特別大的時候,是不是這個hashMap也會特別大呢。由此可以聯想到 Bloom Filter,對於每個key,用n個byte每個儲存一個標誌用來判斷key是否在集合中。原理就是使用k個hash函式來將key雜湊成一個整數。

在W-TinyLFU中使用Count-Min Sketch記錄我們的訪問頻率,而這個也是布隆過濾器的一種變種。如下圖所示:

如果需要記錄一個值,那我們需要通過多種Hash演算法對其進行處理hash,然後在對應的hash演算法的記錄中+1,為什麼需要多種hash演算法呢?

由於這是一個壓縮演算法必定會出現衝突,比如我們建立一個byte的陣列,通過計算出每個資料的hash的位置。

比如張三和李四,他們兩有可能hash值都是相同,比如都是1那byte[1]這個位置就會增加相應的頻率,張三訪問1萬次,李四訪問1次那byte[1]這個位置就是1萬零1,如果取李四的訪問評率的時候就會取出是1萬零1,但是李四命名只訪問了1次啊。

為了解決這個問題,所以用了多個hash演算法可以理解為long[][]二維陣列的一個概念,比如在第一個演算法張三和李四衝突了,但是在第二個,第三個中很大的概率不衝突,比如一個演算法大概有1%的概率衝突,那四個演算法一起衝突的概率是1%的四次方。通過這個模式我們取李四的訪問率的時候取所有演算法中,李四訪問最低頻率的次數。所以他的名字叫Count-Min Sketch。

2. 使用

Caffeine Cache 的github地址:

https://github.com/ben-manes/caffeine

目前的最新版本是:

<dependency>
<groupId>com.github.ben-manes.caffeine</groupId>
<artifactId>caffeine</artifactId>
<version>2.6.2</version>
</dependency>

2.1 快取填充策略

Caffeine Cache提供了三種快取填充策略:手動、同步載入和非同步載入。

1.手動載入

在每次get key的時候指定一個同步的函式,如果key不存在就呼叫這個函式生成一個值。

/**
*手動載入
*@paramkey
*@return
*/
publicObjectmanulOperator(Stringkey){
Cache<String,Object>cache=Caffeine.newBuilder()
.expireAfterWrite(1,TimeUnit.SECONDS)
.expireAfterAccess(1,TimeUnit.SECONDS)
.maximumSize(10)
.build();
//如果一個key不存在,那麼會進入指定的函式生成value
Objectvalue=cache.get(key,t->setValue(key).apply(key));
cache.put("hello",value);

//判斷是否存在如果不存返回null
ObjectifPresent=cache.getIfPresent(key);
//移除一個key
cache.invalidate(key);
returnvalue;
}

publicFunction<String,Object>setValue(Stringkey){
returnt->key+"value";
}
2. 同步載入

構造Cache時候,build方法傳入一個CacheLoader實現類。實現load方法,通過key載入value。

/**
*同步載入
*@paramkey
*@return
*/
publicObjectsyncOperator(Stringkey){
LoadingCache<String,Object>cache=Caffeine.newBuilder()
.maximumSize(100)
.expireAfterWrite(1,TimeUnit.MINUTES)
.build(k->setValue(key).apply(key));
returncache.get(key);
}

publicFunction<String,Object>setValue(Stringkey){
returnt->key+"value";
}
3. 非同步載入

AsyncLoadingCache是繼承自LoadingCache類的,非同步載入使用Executor去呼叫方法並返回一個CompletableFuture。非同步載入快取使用了響應式程式設計模型。

如果要以同步方式呼叫時,應提供CacheLoader。要以非同步表示時,應該提供一個AsyncCacheLoader,並返回一個CompletableFuture。

/**
*非同步載入
*
*@paramkey
*@return
*/
publicObjectasyncOperator(Stringkey){
AsyncLoadingCache<String,Object>cache=Caffeine.newBuilder()
.maximumSize(100)
.expireAfterWrite(1,TimeUnit.MINUTES)
.buildAsync(k->setAsyncValue(key).get());

returncache.get(key);
}

publicCompletableFuture<Object>setAsyncValue(Stringkey){
returnCompletableFuture.supplyAsync(()->{
returnkey+"value";
});
}

2.2 回收策略

Caffeine提供了3種回收策略:基於大小回收,基於時間回收,基於引用回收。

1. 基於大小的過期方式

基於大小的回收策略有兩種方式:一種是基於快取大小,一種是基於權重。

//根據快取的計數進行驅逐
LoadingCache<String,Object>cache=Caffeine.newBuilder()
.maximumSize(10000)
.build(key->function(key));


//根據快取的權重來進行驅逐(權重只是用於確定快取大小,不會用於決定該快取是否被驅逐)
LoadingCache<String,Object>cache1=Caffeine.newBuilder()
.maximumWeight(10000)
.weigher(key->function1(key))
.build(key->function(key));

maximumWeight與maximumSize不可以同時使用。

2.基於時間的過期方式
//基於固定的到期策略進行退出
LoadingCache<String,Object>cache=Caffeine.newBuilder()
.expireAfterAccess(5,TimeUnit.MINUTES)
.build(key->function(key));
LoadingCache<String,Object>cache1=Caffeine.newBuilder()
.expireAfterWrite(10,TimeUnit.MINUTES)
.build(key->function(key));

//基於不同的到期策略進行退出
LoadingCache<String,Object>cache2=Caffeine.newBuilder()
.expireAfter(newExpiry<String,Object>(){
@Override
publiclongexpireAfterCreate(Stringkey,Objectvalue,longcurrentTime){
returnTimeUnit.SECONDS.toNanos(seconds);
}

@Override
publiclongexpireAfterUpdate(@NonnullStrings,@NonnullObjecto,longl,longl1){
return0;
}

@Override
publiclongexpireAfterRead(@NonnullStrings,@NonnullObjecto,longl,longl1){
return0;
}
}).build(key->function(key));

Caffeine提供了三種定時驅逐策略:

expireAfterAccess(long, TimeUnit):在最後一次訪問或者寫入後開始計時,在指定的時間後過期。假如一直有請求訪問該key,那麼這個快取將一直不會過期。

expireAfterWrite(long, TimeUnit): 在最後一次寫入快取後開始計時,在指定的時間後過期。

expireAfter(Expiry): 自定義策略,過期時間由Expiry實現獨自計算。

快取的刪除策略使用的是惰性刪除和定時刪除。這兩個刪除策略的時間複雜度都是O(1)。

3. 基於引用的過期方式

Java中四種引用型別

引用型別被垃圾回收時間用途生存時間
強引用 Strong Reference從來不會物件的一般狀態JVM停止執行時終止
軟引用 Soft Reference在記憶體不足時物件快取記憶體不足時終止
弱引用 Weak Reference在垃圾回收時物件快取gc執行後終止
虛引用 Phantom Reference從來不會可以用虛引用來跟蹤物件被垃圾回收器回收的活動,當一個虛引用關聯的物件被垃圾收集器回收之前會收到一條系統通知JVM停止執行時終止

//當key和value都沒有引用時驅逐快取
LoadingCache<String,Object>cache=Caffeine.newBuilder()
.weakKeys()
.weakValues()
.build(key->function(key));

//當垃圾收集器需要釋放記憶體時驅逐
LoadingCache<String,Object>cache1=Caffeine.newBuilder()
.softValues()
.build(key->function(key));

注意:AsyncLoadingCache不支援弱引用和軟引用。

Caffeine.weakKeys():使用弱引用儲存key。如果沒有其他地方對該key有強引用,那麼該快取就會被垃圾回收器回收。由於垃圾回收器只依賴於身份(identity)相等,因此這會導致整個快取使用身份 (==) 相等來比較 key,而不是使用 equals()。

Caffeine.weakValues() :使用弱引用儲存value。如果沒有其他地方對該value有強引用,那麼該快取就會被垃圾回收器回收。由於垃圾回收器只依賴於身份(identity)相等,因此這會導致整個快取使用身份 (==) 相等來比較 key,而不是使用 equals()。

Caffeine.softValues() :使用軟引用儲存value。當記憶體滿了過後,軟引用的物件以將使用最近最少使用(least-recently-used ) 的方式進行垃圾回收。由於使用軟引用是需要等到記憶體滿了才進行回收,所以我們通常建議給快取配置一個使用記憶體的最大值。softValues() 將使用身份相等(identity) (==) 而不是equals() 來比較值。

Caffeine.weakValues()和Caffeine.softValues()不可以一起使用。

3. 移除事件監聽

Cache<String,Object>cache=Caffeine.newBuilder()
.removalListener((Stringkey,Objectvalue,RemovalCausecause)->
System.out.printf("Key%swasremoved(%s)%n",key,cause))
.build();

4. 寫入外部儲存

CacheWriter 方法可以將快取中所有的資料寫入到第三方。

LoadingCache<String,Object>cache2=Caffeine.newBuilder()
.writer(newCacheWriter<String,Object>(){
@Overridepublicvoidwrite(Stringkey,Objectvalue){
//寫入到外部儲存
}
@Overridepublicvoiddelete(Stringkey,Objectvalue,RemovalCausecause){
//刪除外部儲存
}
}).build(key->function(key));

如果你有多級快取的情況下,這個方法還是很實用。

注意:CacheWriter不能與弱鍵或AsyncLoadingCache一起使用。

5. 統計

與Guava Cache的統計一樣。

Cache<String,Object>cache=Caffeine.newBuilder()
.maximumSize(10_000)
.recordStats()
.build();

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

hitRate():返回快取命中率

evictionCount():快取回收數量

averageLoadPenalty():載入新值的平均時間