論獲取緩存值的正確姿式

論獲取緩存值的正確姿式

cache

時至今日,你們對緩存想必不在陌生。咱們身邊各類系統中或多或少的都存在緩存,自從有個緩存,咱們能夠減小不少計算壓力,提升應用程序的QPS。java

你將某些須要大量計算或查詢的結果,設置過時時間後放入緩存。下次須要使用的時候,先去緩存處查詢是否存在緩存,沒有就直接計算/查詢,並將結果塞入緩存中。git

Object result = cache.get(CACHE_KEY);
if(result == null){
    //從新獲取緩存
    result = xxxx(xxx);
    cache.put(CACHE_KEY,CACHE_TTL,result); 
}
return result;

Bingo~~,一切都在掌握之中,程序如此完美,能夠支撐更大的訪問壓力了。github

不過,這樣的獲取緩存的邏輯,真的沒有問題嗎?redis


高併發下暴露問題

你的程序一直正常運行,直到某一日,運營的同事急匆匆的跑來找到你,你的程序掛了,多是XXX在大量抓你的數據。咱們重啓了應用也沒用,沒幾秒程序又掛了。數據庫

機智的你經過簡單的排查,得出數據庫頂不住訪問壓力,順利的將鍋甩走。 不過仔細一想,咱們不是有緩存嗎,怎麼緩存沒起做用? 查看下緩存,一切正常,也沒發現什麼問題啊?緩存

進過各類debug、查日誌、測試環境模擬,花了整整一下午,你終於找到罪魁禍首,緣由很簡單,正是咱們沒有使用正確的姿式使用緩存~~~多線程


問題分析

這裏咱們排除熔斷、限流等外部措施,單純討論緩存問題。併發

假設你的應用須要訪問某個資源(數據庫/服務),其能支撐的最大QPS爲100。爲了提升應用QPS,咱們加入緩存,並將緩存過時時間設置爲X秒。此時,有個200併發的請求訪問咱們系統中某一路徑,這些請求對應的都是同一個緩存KEY,可是這個鍵已通過期了。此時,則會瞬間產生200個線程訪問下游資源,下游資源便有可能瞬間就奔潰了~~~異步

咱們有什麼更好的方法獲取緩存嗎?固然有,這裏經過guava cache來看下google是怎麼處理獲取緩存的。async


guava 和 guava cache

guava是一個google發佈的一個開源java工具庫,其中guava cacha提供了一個輕量級的本地緩存實現機制,經過guava cache,咱們能夠輕鬆實現本地緩存。其中,guava cacha對緩存不存在或者過時狀況下,獲取緩存值得過程稱之爲Loading。

直接上代碼,看看guava cache是如何get一個緩存的。

V get(K key, int hash, CacheLoader<? super K, V> loader) throws ExecutionException {
            ...
            try {
                if(this.count != 0) {
                    LocalCache.ReferenceEntry ee = this.getEntry(key, hash);
                    if(ee != null) {
                        long cause1 = this.map.ticker.read();
                        Object value = this.getLiveValue(ee, cause1);
                        if(value != null) {
                            this.recordRead(ee, cause1);
                            this.statsCounter.recordHits(1);
                            Object valueReference1 = this.scheduleRefresh(ee, key, hash, value, cause1, loader);
                            return valueReference1;
                        }

                        LocalCache.ValueReference valueReference = ee.getValueReference();
                        if(valueReference.isLoading()) {
                            Object var9 = this.waitForLoadingValue(ee, key, valueReference);
                            return var9;
                        }
                    }
                }

                Object ee1 = this.lockedGetOrLoad(key, hash, loader);
                return ee1;
            } catch (ExecutionException var13) {
                ...
            } finally {
                ...
            }
        }

可見,核心邏輯主要在scheduleRefresh(...)和lockedGetOrLoad(...)中。

先看和lockedGetOrLoad,

V lockedGetOrLoad(K key, int hash, CacheLoader<? super K, V> loader) throws ExecutionException {
            LocalCache.ValueReference valueReference = null;
            LocalCache.LoadingValueReference loadingValueReference = null;
            boolean createNewEntry = true;
            //先加鎖
            this.lock();

            LocalCache.ReferenceEntry e;
            try {
                long now = this.map.ticker.read();
                this.preWriteCleanup(now);
                int newCount = this.count - 1;
                AtomicReferenceArray table = this.table;
                int index = hash & table.length() - 1;
                LocalCache.ReferenceEntry first = (LocalCache.ReferenceEntry)table.get(index);

                for(e = first; e != null; e = e.getNext()) {
                    Object entryKey = e.getKey();
                    if(e.getHash() == hash && entryKey != null && this.map.keyEquivalence.equivalent(key, entryKey)) {
                        valueReference = e.getValueReference();
                        //判斷是否有其餘線程正在執行loading動做
                        if(valueReference.isLoading()) {
                            createNewEntry = false;
                        } else {
                            Object value = valueReference.get();
                            if(value == null) { 
                                this.enqueueNotification(entryKey, hash, valueReference, RemovalCause.COLLECTED);
                            } else {
                                //有值且沒有過時,直接返回
                                if(!this.map.isExpired(e, now)) {
                                    this.recordLockedRead(e, now);
                                    this.statsCounter.recordHits(1);
                                    Object var16 = value;
                                    return var16;
                                }   
                                this.enqueueNotification(entryKey, hash, valueReference, RemovalCause.EXPIRED);
                            }

                            this.writeQueue.remove(e);
                            this.accessQueue.remove(e);
                            this.count = newCount;
                        }
                        break;
                    }
                }
                
                //建立一個LoadingValueReference
                if(createNewEntry) {
                    loadingValueReference = new LocalCache.LoadingValueReference();
                    if(e == null) {
                        e = this.newEntry(key, hash, first);
                        e.setValueReference(loadingValueReference);
                        table.set(index, e);
                    } else {
                        e.setValueReference(loadingValueReference);
                    }
                }
            } finally {
               ...
            }

            if(createNewEntry) {
                Object var9;
                try {
                    //沒有其餘線程在loading狀況下,同步Loading獲取值
                    synchronized(e) {
                        var9 = this.loadSync(key, hash, loadingValueReference, loader);
                    }
                } finally {
                    this.statsCounter.recordMisses(1);
                }

                return var9;
            } else {
                //等待其餘線程返回值
                return this.waitForLoadingValue(e, key, valueReference);
            }
        }

可見正常狀況下,guava會單線程處理回源動做,其餘併發的線程等待處理線程Loading完成後直接返回其結果。這樣也就避免了多線程同時對同一資源併發Loading的狀況發生。

不過,這樣雖然只有一個線程去執行loading動做,可是其餘線程會等待loading線程接受後才能一同返回接口。此時,guava cache經過刷新策略,直接返回舊的緩存值,並生成一個線程去處理loading,處理完成後更新緩存值和過時時間。guava 稱之爲異步模式。

V scheduleRefresh(LocalCache.ReferenceEntry<K, V> entry, K key, int hash, V oldValue, long now, CacheLoader<? super K, V> loader) {
        if(this.map.refreshes() && now - entry.getWriteTime() > this.map.refreshNanos && !entry.getValueReference().isLoading()) {
            Object newValue = this.refresh(key, hash, loader, true);
            if(newValue != null) {
                return newValue;
            }
        }

        return oldValue;
    }

Refreshing is not quite the same as eviction. As specified in LoadingCache.refresh(K), refreshing a key loads a new value for the key, possibly asynchronously. The old value (if any) is still returned while the key is being refreshed, in contrast to eviction, which forces retrievals to wait until the value is loaded anew.

此外guava還提供了同步模式,相對於異步模式,惟一的區別是有一個請求線程去執行loading,其餘線程返回過時值。


總結

看似簡單的獲取緩存值的業務邏輯沒想到還暗藏玄機。固然,這裏guava cache只是本地緩存,若是依葫蘆畫瓢用在redis等分佈式緩存時,勢必還要考慮更多的地方。

最後,若是喜歡本文,請點贊~~~~

相關文章
相關標籤/搜索