天天看點

Caffeine Cache-高性能Java本地緩存元件

前面剛說到Guava Cache,他的優點是封裝了get,put操作;提供線程安全的緩存操作;提供過期政策;提供回收政策;緩存監控。當緩存的資料超過最大值時,使用LRU算法替換。這一篇我們将要談到一個新的本地緩存架構:Caffeine Cache。它也是站在巨人的肩膀上-Guava Cache,借着他的思想優化了算法發展而來。

本篇博文主要介紹Caffine Cache 的使用方式,以及Caffine Cache在SpringBoot中的使用。

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

FIFO:先進先出,在這種淘汰算法中,先進入緩存的會先被淘汰,會導緻命中率很低。

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

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記錄我們的通路頻率,而這個也是布隆過濾器的一種變種。如下圖所示:

Caffeine Cache-高性能Java本地緩存元件

如果需要記錄一個值,那我們需要通過多種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。

Caffeine Cache 的github位址:點我。

目前的最新版本是:

Caffeine Cache提供了三種緩存填充政策:手動、同步加載和異步加載。

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

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

AsyncLoadingCache是繼承自LoadingCache類的,異步加載使用Executor去調用方法并傳回一個CompletableFuture。異步加載緩存使用了響應式程式設計模型。

如果要以同步方式調用時,應提供CacheLoader。要以異步表示時,應該提供一個AsyncCacheLoader,并傳回一個CompletableFuture。

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

基于大小的回收政策有兩種方式:一種是基于緩存大小,一種是基于權重。

maximumWeight與maximumSize不可以同時使用。

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

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

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

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

緩存的删除政策使用的是惰性删除和定時删除。這兩個删除政策的時間複雜度都是O(1)。

Java中四種引用類型

引用類型

被垃圾回收時間

用途

生存時間

強引用 Strong Reference

從來不會

對象的一般狀态

JVM停止運作時終止

軟引用 Soft Reference

在記憶體不足時

對象緩存

記憶體不足時終止

弱引用 Weak Reference

在垃圾回收時

gc運作後終止

虛引用 Phantom Reference

可以用虛引用來跟蹤對象被垃圾回收器回收的活動,當一個虛引用關聯的對象被垃圾收集器回收之前會收到一條系統通知

注意: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()不可以一起使用。

CacheWriter 方法可以将緩存中所有的資料寫入到第三方。

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

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

與Guava Cache的統計一樣。

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

SpringBoot 1.x版本中的預設本地cache是Guava Cache。在2.x(Spring Boot 2.0(spring 5) )版本中已經用Caffine Cache取代了Guava Cache。畢竟有了更優的緩存淘汰政策。

下面我們來說在SpringBoot2.x版本中如何使用cache。

添加@EnableCaching注解:

properties檔案

或Yaml檔案

如果使用refreshAfterWrite配置,必須指定一個CacheLoader.不用該配置則無需這個bean,如上所述,該CacheLoader将關聯被該緩存管理器管理的所有緩存,是以必須定義為CacheLoader<Object, Object>,自動配置将忽略所有泛型類型。

Caffeine常用配置說明:

需要說明的是,使用配置檔案的方式來進行緩存項配置,一般情況能滿足使用需求,但是靈活性不是很高,如果我們有很多緩存項的情況下寫起來會導緻配置檔案很長。是以一般情況下你也可以選擇使用bean的方式來初始化Cache執行個體。

下面的示範使用bean的方式來注入:

建立了一個<code>SimpleCacheManager</code>作為Cache的管理對象,然後初始化了兩個Cache對象,分别存儲user,dept類型的緩存。當然建構Cache的參數設定我寫的比較簡單,你在使用的時候酌情根據需要配置參數。

我們可以使用spring提供的 <code>@Cacheable</code>、<code>@CachePut</code>、<code>@CacheEvict</code>等注解來友善的使用caffeine緩存。

如果使用了多個cahce,比如redis、caffeine等,必須指定某一個CacheManage為@primary,在@Cacheable注解中沒指定 cacheManager 則使用标記為primary的那個。

cache方面的注解主要有以下5個:

@Cacheable 觸發緩存入口(這裡一般放在建立和擷取的方法上,<code>@Cacheable</code>注解會先查詢是否已經有緩存,有會使用緩存,沒有則會執行方法并緩存)

@CacheEvict 觸發緩存的eviction(用于删除的方法上)

@CachePut 更新緩存且不影響方法執行(用于修改的方法上,該注解下的方法始終會被執行)

@Caching 将多個緩存組合在一個方法上(該注解可以允許一個方法同時設定多個注解)

@CacheConfig 在類級别設定一些緩存相關的共同配置(與其它緩存配合使用)

說一下<code>@Cacheable</code> 和 <code>@CachePut</code>的差別:

@Cacheable:它的注解的方法是否被執行取決于Cacheable中的條件,方法很多時候都可能不被執行。

@CachePut:這個注解不會影響方法的執行,也就是說無論它配置的條件是什麼,方法都會被執行,更多的時候是被用到修改上。

簡要說一下Cacheable類中各個方法的使用:

基于注解的使用方法:

如果你不想使用注解的方式去操作緩存,也可以直接使用SimpleCacheManager擷取緩存的key進而進行操作。

注意到上面的key使用了spEL 表達式。Spring Cache提供了一些供我們使用的SpEL上下文資料,下表直接摘自Spring官方文檔:

名稱

位置

描述

示例

methodName

root對象

目前被調用的方法名

<code>#root.methodname</code>

method

目前被調用的方法

<code>#root.method.name</code>

target

目前被調用的目标對象執行個體

<code>#root.target</code>

targetClass

目前被調用的目标對象的類

<code>#root.targetClass</code>

args

目前被調用的方法的參數清單

<code>#root.args[0]</code>

caches

目前方法調用使用的緩存清單

<code>#root.caches[0].name</code>

Argument Name

執行上下文

目前被調用的方法的參數,如findArtisan(Artisan artisan),可以通過#artsian.id獲得參數

<code>#artsian.id</code>

result

方法執行後的傳回值(僅當方法執行後的判斷有效,如 unless cacheEvict的beforeInvocation=false)

<code>#result</code>

注意:

1.當我們要使用root對象的屬性作為key時我們也可以将“#root”省略,因為Spring預設使用的就是root對象的屬性。 如

2.使用方法參數時我們可以直接使用“#參數名”或者“#p參數index”。 如:

SpEL提供了多種運算符

類型

運算符

關系

&lt;,&gt;,&lt;=,&gt;=,==,!=,lt,gt,le,ge,eq,ne

算術

+,- ,* ,/,%,^

邏輯

&amp;&amp;,||,!,and,or,not,between,instanceof

條件

?: (ternary),?: (elvis)

正規表達式

matches

其他類型

?.,?[…],![…],^[…],$[…]