天天看點

Java高性能系統緩存的最佳實踐(下)緩存置換總結    手寫LRU緩存置換

同步更新 VS異步更新緩存

  • 如果同步,更新磁盤成功了,但更新緩存失敗了,你是不是要反複重試保證更新成功?如果多次重試都失敗,那這次更新是算成功還是失敗?
  • 如果是異步,怎麼保證更新時序?

比如,我先把一個檔案中某個資料設成0,然後又設為1,這時檔案中資料肯定是1,但緩存中資料不一定是1。因為把緩存中資料更新為0,和更新為1是兩個并發的異步操作,無法保證誰先執行。

這些問題都會導緻緩存資料和磁盤資料不一緻,而且,在下次更新這條資料前,這個不一緻問題一直存在。

當然,這些問題也不是不能解決,比如使用分布式事務,隻是犧牲性能、實作複雜度,代價很大。

另一種較簡單方法

定時刷盤

一般每次同步時直接全量更新,因為是在異步線程中更新,同步速度即使慢點也不是大問題。

如果緩存資料太大,更新慢到無法接受,也可選擇增量更新,每次隻更新從上次緩存同步至今這段時間内變化的資料,代價是實作起來會稍微有些複雜。

如果說,某次同步過程中發生了錯誤,等到下一個同步周期也會自動把資料糾正過來。這種定時同步緩存的方法,缺點是緩存更新不那麼及時,優點是實作起來非常簡單,魯棒性非常好。

更簡單的方法

TTL

從不更新緩存資料,而是給緩存中的每條資料設較短的過期時間,資料過期後即使還存在緩存,也認為不再有效,需從磁盤再次加載這資料,變相實作資料更新。

很多情況下,緩存資料更新不及時,系統也能夠接受。

比如你剛發了一封郵件,收件人過了一會兒才收到。或你改了自己頭像,在一段時間内,你的好友看到還是舊頭像,都可接受。

這種對資料一緻性沒有那麼敏感場景,一定要選擇後兩種方法。

而像交易系統,對資料一緻性敏感。

比如,你給别人轉了一筆錢,别人查詢自己餘額卻沒變化,這肯定無法接受。對這樣系統,一般都不使用緩存或使用提到的第一種方法,在更新資料時同時更新緩存。

緩存置換

除考慮資料一緻性,還需關注記憶體有限,要優先緩存哪些資料,讓緩存命中率最高。

當程式要通路某些資料時,如果這些資料在緩存,那直接通路緩存中資料,這次通路速度很快,稱為緩存命中;

如果這些資料不在緩存=》隻能去磁盤=》較慢=》“緩存穿透”。

顯然,緩存命中率越高,程式總體性能越好。

那用什麼政策選擇緩存的資料,能使緩存命中率盡量高?

如果你的系統是那種可預測未來通路哪些資料的,比如有的系統它會定期做資料同步,每次同步資料範圍都一樣,這樣的系統,緩存政策簡單,你要通路什麼資料,就緩存什麼資料,甚至可做到百分百命中。

但大部分系統沒辦法準确預測會有哪些資料會被通路,隻能使用一些政策盡可能地提高命中率。

一般都會在資料首次被通路時,順便把這條資料放到緩存。

随通路資料越來越多,總有把緩存占滿時,這時就需要把緩存中一些資料删除,以存放新資料,這過程稱為緩存置換。

問題就成了:當緩存滿,删除哪些資料,會使緩存命中率更高,采用什麼置換政策呢。

命中率最高的置換政策,一定是根據你的業務定制化的。

比如,你如果知道某些資料已删除,永遠不會再通路,那優先置換這些資料肯定沒問題。

再比如,有會話的系統,你知道現在哪些使用者是線上,哪些使用者已離線,那優先置換那些已離線使用者的資料,盡量保留線上使用者的資料也是好政策。

  • 另外就是使用通用置換算法LRU

    最近剛剛被通路的資料,它在将來被通路的可能性也很大,而很久都沒被通路過的資料,未來再被通路的幾率也不大。

LRU原理簡單,總把最長時間未被通路的資料置換出去。别看這麼簡單,效果非常非常好。

Kafka使用的PageCache,是由Linux核心實作,它的置換算法的就是一種LRU變種體:LRU 2Q。設計JMQ緩存政策時,也是采用一種改進LRU算法。

LRU淘汰最近最少使用的頁,JMQ根據消息這種流資料存儲的特點,在淘汰時增個考量次元:頁面位置與尾部的距離。因為越是靠近尾部的資料,被通路的機率越大。

綜合考慮下的淘汰算法,不僅命中率更高,還能有效地避免“挖墳”問題:例如某個用戶端正在從很舊的位置開始向後讀取批曆史資料,記憶體中緩存很快都會被替換成這些曆史資料,相當于大部分緩存資源都被消耗,這會導緻其他用戶端通路命中率下降。加入位置權重後,比較舊的頁面會很快被淘汰掉,減少“挖墳”對系統影響。是以經常看到很多挖墳貼不再提供任何服務功能,甚至還會被删除。

總結    

按讀寫性質,可分為讀寫緩存和隻讀緩存,讀寫緩存實作複雜,且隻在MQ等少數情況适用。

隻讀緩存适用的範圍更廣,實作更簡單。

在實作隻讀緩存的時候,你需要考慮的第一個問題是如何來更新緩存。這裡面有三種方法

  1. 在更新資料的同時去更新緩存
  2. 定期來更新全部緩存
  3. 給緩存中的每個資料設定一個有效期,讓它自然過期以達到更新的目的

這三種方法在更新的及時性上和實作的複雜度這兩方面,都是依次遞減的,你可以按需選擇。

對于緩存的置換政策,最優的政策一定是你根據業務來設計的定制化的置換政策,當然你也可以考慮LRU這樣通用的緩存置換算法。

手寫LRU緩存置換

/**
 * KV存儲抽象
 */
public interface Storage<K,V> {
    /**
     * 根據提供的key來通路資料
     * @param key 資料Key
     * @return 資料值
     */
    V get(K key);
}

/**
 * LRU緩存。你需要繼承這個抽象類來實作LRU緩存。
 * @param <K> 資料Key
 * @param <V> 資料值
 */
public abstract class LruCache<K, V> implements Storage<K,V>{
    // 緩存容量
    protected final int capacity;
    // 低速存儲,所有的資料都可以從這裡讀到
    protected final Storage<K,V> lowSpeedStorage;

    public LruCache(int capacity, Storage<K,V> lowSpeedStorage) {
        this.capacity = capacity;
        this.lowSpeedStorage = lowSpeedStorage;
    }
}      

需繼承LruCache,實作自己的LRU緩存。lowSpeedStorage是提供給你可用的低速存儲,你不需要實作它。

https://github.com/swgithub1006/mqlearning https://gist.github.com/imgaoxin/ed59397c895b5a8a9572408b98542015