本文是上週去技術沙龍聽了一下愛奇藝的Java緩存之路有感寫出來的。先簡單介紹一下愛奇藝的java緩存道路的發展吧。java
能夠看見圖中分爲幾個階段:redis
經過消息隊列進行數據同步至redis,而後Java應用直接去取緩存 這個階段優勢是:因爲是使用的分佈式緩存,因此數據更新快。缺點也比較明顯:依賴Redis的穩定性,一旦redis掛了,整個緩存系統不可用,形成緩存雪崩,全部請求打到DB。算法
這個階段使用進程內緩存做爲一級緩存,redis做爲二級。優勢:不受外部系統影響,其餘系統掛了,依然能使用。缺點:進程內緩存沒法像分佈式緩存那樣作到實時更新。因爲java內存有限,一定緩存得設置大小,而後有些緩存會被淘汰,就會有命中率的問題。數據庫
爲了解決上面的問題,利用Guava Cache能夠設置寫後刷新時間,進行刷新。解決了一直不更新的問題,可是依然沒有解決實時刷新。api
這個階段擴展了Guava Cache,利用redis做爲消息隊列通知機制,通知其餘java應用程序進行刷新。數組
這裏簡單介紹一下愛奇藝緩存發展的五個階段,固然還有一些其餘的優化,好比GC調優,緩存穿透,緩存覆蓋的一些優化等等。有興趣的同窗能夠關注公衆號,聯繫我進行交流。緩存
上面說的是愛奇藝的一個進化線路,可是在你們的通常開發過程當中,第一步通常都沒有redis,而是直接查庫。數據結構
在流量不大的時候,查數據庫或者讀取文件是最爲方便,也能徹底知足咱們的業務要求。app
當咱們應用有必定流量以後或者查詢數據庫特別頻繁,這個時候就能夠祭出咱們的java中自帶的HashMap或者ConcurrentHashMap。咱們能夠在代碼中這麼寫:框架
public class CustomerService { private HashMap<String,String> hashMap = new HashMap<>(); private CustomerMapper customerMapper; public String getCustomer(String name){ String customer = hashMap.get(name); if ( customer == null){ customer = customerMapper.get(name); hashMap.put(name,customer); } return customer; } }
可是這樣作就有個問題HashMap沒法進行數據淘汰,內存會無限制的增加,因此hashMap很快也被淘汰了。固然並非說他徹底就沒用,就像咱們古代社會也不是全部的東西都是過期的,好比咱們中華名族的傳統美德是永不過期的,就像這個hashMap同樣的能夠在某些場景下做爲緩存,當不須要淘汰機制的時候,好比咱們利用反射,若是咱們每次都經過反射去搜索Method,field,性能一定低效,這時咱們用HashMap將其緩存起來,性能能提高不少。
在古代社會中難住咱們的問題沒法進行數據淘汰,這樣會致使咱們內存無限膨脹,顯然咱們是不能夠接受的。有人就說我把一些數據給淘汰掉唄,這樣不就對了,可是怎麼淘汰呢?隨機淘汰嗎?固然不行,試想一下你剛把A裝載進緩存,下一次要訪問的時候就被淘汰了,那又會訪問咱們的數據庫了,那咱們要緩存幹嗎呢?
因此聰明的人們就發明了幾種淘汰算法,下面列舉下常見的三種FIFO,LRU,LFU(還有一些ARC,MRU感興趣的能夠自行搜索):
上面列舉了三種淘汰策略,對於這三種,實現成本是一個比一個高,一樣的命中率也是一個比一個好。而咱們通常來講選擇的方案居中便可,即實現成本不是過高,而命中率也還行的LRU,如何實現一個LRUMap呢?咱們能夠經過繼承LinkedHashMap,重寫removeEldestEntry方法,便可完成一個簡單的LRUMap。
class LRUMap extends LinkedHashMap { private final int max; private Object lock; public LRUMap(int max, Object lock) { //無需擴容 super((int) (max * 1.4f), 0.75f, true); this.max = max; this.lock = lock; } /** * 重寫LinkedHashMap的removeEldestEntry方法便可 * 在Put的時候判斷,若是爲true,就會刪除最老的 * @param eldest * @return */ @Override protected boolean removeEldestEntry(Map.Entry eldest) { return size() > max; } public Object getValue(Object key) { synchronized (lock) { return get(key); } } public void putValue(Object key, Object value) { synchronized (lock) { put(key, value); } } public boolean removeValue(Object key) { synchronized (lock) { return remove(key) != null; } } public boolean removeAll(){ clear(); return true; } }
在LinkedHashMap中維護了一個entry(用來放key和value的對象)鏈表。在每一次get或者put的時候都會把插入的新entry,或查詢到的老entry放在咱們鏈表末尾。 能夠注意到咱們在構造方法中,設置的大小特地設置到max*1.4,在下面的removeEldestEntry方法中只須要size>max就淘汰,這樣咱們這個map永遠也走不到擴容的邏輯了,經過重寫LinkedHashMap,幾個簡單的方法咱們實現了咱們的LruMap。
在近代社會中已經發明出來了LRUMap,用來進行緩存數據的淘汰,可是有幾個問題:
因此谷歌的大佬們對於這些問題,按捺不住了,發明了Guava cache,在Guava cache中你能夠以下面的代碼同樣,輕鬆使用:
public static void main(String[] args) throws ExecutionException { LoadingCache<String, String> cache = CacheBuilder.newBuilder() .maximumSize(100) //寫以後30ms過時 .expireAfterWrite(30L, TimeUnit.MILLISECONDS) //訪問以後30ms過時 .expireAfterAccess(30L, TimeUnit.MILLISECONDS) //20ms以後刷新 .refreshAfterWrite(20L, TimeUnit.MILLISECONDS) //開啓weakKey key 當啓動垃圾回收時,該緩存也被回收 .weakKeys() .build(createCacheLoader()); System.out.println(cache.get("hello")); cache.put("hello1", "我是hello1"); System.out.println(cache.get("hello1")); cache.put("hello1", "我是hello2"); System.out.println(cache.get("hello1")); } public static com.google.common.cache.CacheLoader<String, String> createCacheLoader() { return new com.google.common.cache.CacheLoader<String, String>() { @Override public String load(String key) throws Exception { return key; } }; }
我將會從guava cache原理中,解釋guava cache是如何解決LRUMap的幾個問題的。
guava cache採用了相似ConcurrentHashMap的思想,分段加鎖,在每一個段裏面各自負責本身的淘汰的事情。在Guava根據必定的算法進行分段,這裏要說明的是,若是段太少那競爭依然很嚴重,若是段太多會容易出現隨機淘汰,好比大小爲100的,給他分100個段,那也就是讓每一個數據都獨佔一個段,而每一個段會本身處理淘汰的過程,因此會出現隨機淘汰。在guava cache中經過以下代碼,計算出應該如何分段。
int segmentShift = 0; int segmentCount = 1; while (segmentCount < concurrencyLevel && (!evictsBySize() || segmentCount * 20 <= maxWeight)) { ++segmentShift; segmentCount <<= 1; }
上面segmentCount就是咱們最後的分段數,其保證了每一個段至少10個Entry。若是沒有設置concurrencyLevel這個參數,那麼默認就會是4,最後分段數也最多爲4,例如咱們size爲100,會分爲4段,每段最大的size是25。 在guava cache中對於寫操做直接加鎖,對於讀操做,若是讀取的數據沒有過時,且已經加載就緒,不須要進行加鎖,若是沒有讀到會再次加鎖進行二次讀,若是尚未須要進行緩存加載,也就是經過咱們配置的CacheLoader,我這裏配置的是直接返回Key,在業務中一般配置從數據庫中查詢。 以下圖所示:
相比於LRUMap多了兩種過時時間,一個是寫後多久過時expireAfterWrite,一個是讀後多久過時expireAfterAccess。頗有意思的事情是,在guava cache中對於過時的Entry並無立刻過時(也就是並無後臺線程一直在掃),而是經過進行讀寫操做的時候進行過時處理,這樣作的好處是避免後臺線程掃描的時候進行全局加鎖。看下面的代碼:
public static void main(String[] args) throws ExecutionException, InterruptedException { Cache<String, String> cache = CacheBuilder.newBuilder() .maximumSize(100) //寫以後5s過時 .expireAfterWrite(5, TimeUnit.MILLISECONDS) .concurrencyLevel(1) .build(); cache.put("hello1", "我是hello1"); cache.put("hello2", "我是hello2"); cache.put("hello3", "我是hello3"); cache.put("hello4", "我是hello4"); //至少睡眠5ms Thread.sleep(5); System.out.println(cache.size()); cache.put("hello5", "我是hello5"); System.out.println(cache.size()); } 輸出: 4 1
從這個結果中咱們知道,在put的時候才進行的過時處理。特別注意的是我上面concurrencyLevel(1)我這裏將分段最大設置爲1,否則不會出現這個實驗效果的,在上面一節中已經說過,咱們是以段位單位進行過時處理。在每一個Segment中維護了兩個隊列:
final Queue<ReferenceEntry<K, V>> writeQueue; final Queue<ReferenceEntry<K, V>> accessQueue;
writeQueue維護了寫隊列,隊頭表明着寫得早的數據,隊尾表明寫得晚的數據。 accessQueue維護了訪問隊列,和LRU同樣,用來咱們進行訪問時間的淘汰,若是當這個Segment超過最大容量,好比咱們上面所說的25,超過以後,就會把accessQueue這個隊列的第一個元素進行淘汰。
void expireEntries(long now) { drainRecencyQueue(); ReferenceEntry<K, V> e; while ((e = writeQueue.peek()) != null && map.isExpired(e, now)) { if (!removeEntry(e, e.getHash(), RemovalCause.EXPIRED)) { throw new AssertionError(); } } while ((e = accessQueue.peek()) != null && map.isExpired(e, now)) { if (!removeEntry(e, e.getHash(), RemovalCause.EXPIRED)) { throw new AssertionError(); } } }
上面就是guava cache處理過時Entries的過程,會對兩個隊列一次進行peek操做,若是過時就進行刪除。通常處理過時Entries能夠在咱們的put操做的先後,或者讀取數據時發現過時了,而後進行整個Segment的過時處理,又或者進行二次讀lockedGetOrLoad操做的時候調用。
void evictEntries(ReferenceEntry<K, V> newest) { ///... 省略無用代碼 while (totalWeight > maxSegmentWeight) { ReferenceEntry<K, V> e = getNextEvictable(); if (!removeEntry(e, e.getHash(), RemovalCause.SIZE)) { throw new AssertionError(); } } } /** **返回accessQueue的entry **/ ReferenceEntry<K, V> getNextEvictable() { for (ReferenceEntry<K, V> e : accessQueue) { int weight = e.getValueReference().getWeight(); if (weight > 0) { return e; } } throw new AssertionError(); }
上面是咱們驅逐Entry的時候的代碼,能夠看見訪問的是accessQueue對其隊頭進行驅逐。而驅逐策略通常是在對segment中的元素髮生變化時進行調用,好比插入操做,更新操做,加載數據操做。
自動刷新操做,在guava cache中實現相對比較簡單,直接經過查詢,判斷其是否知足刷新條件,進行刷新。
在Guava cache中還有一些其餘特性:
在Guava cache中,key和value都能進行虛引用的設定,在Segment中的有兩個引用隊列:
final @Nullable ReferenceQueue<K> keyReferenceQueue; final @Nullable ReferenceQueue<V> valueReferenceQueue;
這兩個隊列用來記錄被回收的引用,其中每一個隊列記錄了每一個被回收的Entry的hash,這樣回收了以後經過這個隊列中的hash值就能把之前的Entry進行刪除。
在guava cache中,當有數據被淘汰時,可是你不知道他究竟是過時,仍是被驅逐,仍是由於虛引用的對象被回收?這個時候你能夠調用這個方法removalListener(RemovalListener listener)添加監聽器進行數據淘汰的監聽,能夠打日誌或者一些其餘處理,能夠用來進行數據淘汰分析。
在RemovalCause記錄了全部被淘汰的緣由:被用戶刪除,被用戶替代,過時,驅逐收集,因爲大小淘汰。
細細品讀guava cache的源碼總結下來,其實就是一個性能不錯的,api豐富的LRU Map。愛奇藝的緩存的發展也是基於此之上,經過對guava cache的二次開發,讓其能夠進行java應用服務之間的緩存更新。
guava cache的功能的確是很強大,知足了絕大多數的人的需求,可是其本質上仍是LRU的一層封裝,因此在衆多其餘較爲優良的淘汰算法中就相形見絀了。而caffeine cache實現了W-TinyLFU(LFU+LRU算法的變種)。下面是不一樣算法的命中率的比較:
其中Optimal是最理想的命中率,LRU和其餘算法相比的確是個弟弟。而咱們的W-TinyLFU 是最接近理想命中率的。固然不只僅是命中率caffeine優於了guava cache,在讀寫吞吐量上面也是完爆guava cache。
這個時候你確定會好奇爲啥這麼caffeine這麼牛逼呢?彆着急下面慢慢給你道來。
上面已經說過了傳統的LFU是怎麼一回事。在LFU中只要數據訪問模式的機率分佈隨時間保持不變時,其命中率就能變得很是高。這裏我仍是拿愛奇藝舉例,好比有部新劇出來了,咱們使用LFU給他緩存下來,這部新劇在這幾天大概訪問了幾億次,這個訪問頻率也在咱們的LFU中記錄了幾億次。可是新劇總會過氣的,好比一個月以後這個新劇的前幾集其實已通過氣了,可是他的訪問量的確是過高了,其餘的電視劇根本沒法淘汰這個新劇,因此在這種模式下是有侷限性。因此各類LFU的變種出現了,基於時間週期進行衰減,或者在最近某個時間段內的頻率。一樣的LFU也會使用額外空間記錄每個數據訪問的頻率,即便數據沒有在緩存中也須要記錄,因此須要維護的額外空間很大。
能夠試想咱們對這個維護空間創建一個hashMap,每一個數據項都會存在這個hashMap中,當數據量特別大的時候,這個hashMap也會特別大。
再回到LRU,咱們的LRU也不是那麼一無可取,LRU能夠很好的應對突發流量的狀況,由於他不須要累計數據頻率。
因此W-TinyLFU結合了LRU和LFU,以及其餘的算法的一些特色。
首先要說到的就是頻率記錄的問題,咱們要實現的目標是利用有限的空間能夠記錄隨時間變化的訪問頻率。在W-TinyLFU中使用Count-Min Sketch記錄咱們的訪問頻率,而這個也是布隆過濾器的一種變種。以下圖所示: 若是須要記錄一個值,那咱們須要經過多種Hash算法對其進行處理hash,而後在對應的hash算法的記錄中+1,爲何須要多種hash算法呢?因爲這是一個壓縮算法一定會出現衝突,好比咱們創建一個Long的數組,經過計算出每一個數據的hash的位置。好比張三和李四,他們兩有可能hash值都是相同,好比都是1那Long[1]這個位置就會增長相應的頻率,張三訪問1萬次,李四訪問1次那Long[1]這個位置就是1萬零1,若是取李四的訪問評率的時候就會取出是1萬零1,可是李四命名只訪問了1次啊,爲了解決這個問題,因此用了多個hash算法能夠理解爲long[][]二維數組的一個概念,好比在第一個算法張三和李四衝突了,可是在第二個,第三個中很大的機率不衝突,好比一個算法大概有1%的機率衝突,那四個算法一塊兒衝突的機率是1%的四次方。經過這個模式咱們取李四的訪問率的時候取全部算法中,李四訪問最低頻率的次數。因此他的名字叫Count-Min Sketch。
這裏和之前的作個對比,簡單的舉個例子:若是一個hashMap來記錄這個頻率,若是我有100個數據,那這個HashMap就得存儲100個這個數據的訪問頻率。哪怕我這個緩存的容量是1,由於Lfu的規則我必須所有記錄這個100個數據的訪問頻率。若是有更多的數據我就有記錄更多的。
在Count-Min Sketch中,我這裏直接說caffeine中的實現吧(在FrequencySketch這個類中),若是你的緩存大小是100,他會生成一個long數組大小是和100最接近的2的冪的數,也就是128。而這個數組將會記錄咱們的訪問頻率。在caffeine中他規則頻率最大爲15,15的二進制位1111,總共是4位,而Long型是64位。因此每一個Long型能夠放16種算法,可是caffeine並無這麼作,只用了四種hash算法,每一個Long型被分爲四段,每段裏面保存的是四個算法的頻率。這樣作的好處是能夠進一步減小Hash衝突,原先128大小的hash,就變成了128X4。
一個Long的結構以下: 咱們的4個段分爲A,B,C,D,在後面我也會這麼叫它們。而每一個段裏面的四個算法我叫他s1,s2,s3,s4。下面舉個例子若是要添加一個訪問50的數字頻率應該怎麼作?咱們這裏用size=100來舉例。
這個時候有人會質疑頻率最大爲15的這個是否過小?不要緊在這個算法中,好比size等於100,若是他全局提高了1000次就會全局除以2衰減,衰減以後也能夠繼續增長,這個算法再W-TinyLFU的論文中證實了其能夠較好的適應時間段的訪問頻率。
在guava cache中咱們說過其讀寫操做中夾雜着過時時間的處理,也就是你在一次Put操做中有可能還會作淘汰操做,因此其讀寫性能會受到必定影響,能夠看上面的圖中,caffeine的確在讀寫操做上面完爆guava cache。主要是由於在caffeine,對這些事件的操做是經過異步操做,他將事件提交至隊列,這裏的隊列的數據結構是RingBuffer,不清楚的能夠看看這篇文章,你應該知道的高性能無鎖隊列Disruptor。而後經過會經過默認的ForkJoinPool.commonPool(),或者本身配置線程池,進行取隊列操做,而後在進行後續的淘汰,過時操做。
固然讀寫也是有不一樣的隊列,在caffeine中認爲緩存讀比寫多不少,因此對於寫操做是全部線程共享一個Ringbuffer。
對於讀操做比寫操做更加頻繁,進一步減小競爭,其爲每一個線程配備了一個RingBuffer:
在caffeine全部的數據都在ConcurrentHashMap中,這個和guava cache不一樣,guava cache是本身實現了個相似ConcurrentHashMap的結構。在caffeine中有三個記錄引用的LRU隊列:
Eden隊列:在caffeine中規定只能爲緩存容量的%1,若是size=100,那這個隊列的有效大小就等於1。這個隊列中記錄的是新到的數據,防止突發流量因爲以前沒有訪問頻率,而致使被淘汰。好比有一部新劇上線,在最開始實際上是沒有訪問頻率的,防止上線以後被其餘緩存淘汰出去,而加入這個區域。伊甸區,最舒服最安逸的區域,在這裏很難被其餘數據淘汰。
Probation隊列:叫作緩刑隊列,在這個隊列就表明你的數據相對比較冷,立刻就要被淘汰了。這個有效大小爲size減去eden減去protected。
Protected隊列:在這個隊列中,能夠稍微放心一下了,你暫時不會被淘汰,可是別急,若是Probation隊列沒有數據了或者Protected數據滿了,你也將會被面臨淘汰的尷尬局面。固然想要變成這個隊列,須要把Probation訪問一次以後,就會提高爲Protected隊列。這個有效大小爲(size減去eden) X 80% 若是size =100,就會是79。
這三個隊列關係以下:
對於發生數據淘汰的時候,會從Probation中進行淘汰,會把這個隊列中的數據隊頭稱爲受害者,這個隊頭確定是最先進入的,按照LRU隊列的算法的話那他其實他就應該被淘汰,可是在這裏只能叫他受害者,這個隊列是緩刑隊列,表明立刻要給他行刑了。這裏會取出隊尾叫候選者,也叫攻擊者。這裏受害者會和攻擊者作PK,經過咱們的Count-Min Sketch中的記錄的頻率數據有如下幾個判斷:
對於熟悉Guava的玩家來講若是擔憂有切換成本,那麼你徹底就多慮了,caffeine的api借鑑了Guava的api,能夠發現其基本如出一轍。
public static void main(String[] args) { Cache<String, String> cache = Caffeine.newBuilder() .expireAfterWrite(1, TimeUnit.SECONDS) .expireAfterAccess(1,TimeUnit.SECONDS) .maximumSize(10) .build(); cache.put("hello","hello"); }
順便一提的是,愈來愈多的開源框架都放棄了Guava cache,好比Spring5。在業務上我也本身曾經比較過Guava cache和caffeine最終選擇了caffeine,在線上也有不錯的效果。因此不用擔憂caffeine不成熟,沒人使用。
本文主要講了愛奇藝的緩存之路和本地緩存的一個發展歷史(從古至今到將來),以及每一種緩存的實現基本原理。固然要使用好緩存光是這些僅僅不夠,好比本地緩存如何在其餘地方更改了以後同步更新,分佈式緩存,多級緩存等等。後面也會專門寫一節介紹這個如何用好緩存。對於Guava cache和caffeine的原理後面也會專門抽出時間寫這兩個的源碼分析,若是感興趣的朋友能夠關注公衆號第一時間查閱更新文章。
最後打個廣告,若是你以爲這篇文章對你有文章,能夠關注個人技術公衆號,你的關注和轉發是對我最大的支持,O(∩_∩)O