論獲取快取值的正確姿勢

二胡嘈子發表於2016-10-08

論獲取快取值的正確姿勢

cache

時至今日,大家對快取想必不在陌生。我們身邊各種系統中或多或少的都存在快取,自從有個快取,我們可以減少很多計算壓力,提高應用程式的QPS。

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

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

Bingo~~,一切都在掌握之中,程式如此完美,可以支撐更大的訪問壓力了。

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


高併發下暴露問題

你的程式一直正常執行,直到某一日,運營的同事急匆匆的跑來找到你,你的程式掛了,可能是XXX在大量抓你的資料。我們重啟了應用也沒用,沒幾秒程式又掛了。

機智的你通過簡單的排查,得出資料庫頂不住訪問壓力,順利的將鍋甩走。 不過仔細一想,我們不是有快取嗎,怎麼快取沒起作用? 檢視下快取,一切正常,也沒發現什麼問題啊?

進過各種debug、查日誌、測試環境模擬,花了整整一下午,你終於找到罪魁禍首,原因很簡單,正是我們沒有使用正確的姿勢使用快取~~~


問題分析

這裡我們排除熔斷、限流等外部措施,單純討論快取問題。

假設你的應用需要訪問某個資源(資料庫/服務),其能支撐的最大QPS為100。為了提高應用QPS,我們加入快取,並將快取過期時間設定為X秒。此時,有個200併發的請求訪問我們系統中某一路徑,這些請求對應的都是同一個快取KEY,但是這個鍵已經過期了。此時,則會瞬間產生200個執行緒訪問下游資源,下游資源便有可能瞬間就奔潰了~~~

論獲取快取值的正確姿勢

我們有什麼更好的方法獲取快取嗎?當然有,這裡通過guava cache來看下google是怎麼處理獲取快取的。


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等分散式快取時,勢必還要考慮更多的地方。

最後,如果喜歡本文,請點贊~~~~

相關文章