@cacheable 是否缓存成功_缓存策略:如何使用缓存来减少磁盘IO?
現代的消息隊列,都使用磁盤文件來存儲消息。因為磁盤是一個持久化的存儲,即使服務器掉電也不會丟失數據。絕大多數用于生產系統的服務器,都會使用多塊兒磁盤組成磁盤陣列,這樣不僅服務器掉電不會丟失數據,即使其中的一塊兒磁盤發生故障,也可以把數據從其他磁盤中恢復出來。
使用磁盤的另外一個原因是,磁盤很便宜,這樣我們就可以用比較低的成本,來存儲海量的消息。所以,不僅僅是消息隊列,幾乎所有的存儲系統的數據,都需要保存到磁盤上。
但是,磁盤它有一個致命的問題,就是讀寫速度很慢。它有多慢呢?一般來說 SSD(固態硬盤)每秒鐘可以讀寫幾千次,如果說我們的程序在處理業務請求的時候直接來讀寫磁盤,假設處理每次請求需要讀寫 3~5 次,即使每次請求的數據量不大,你的程序最多每秒也就能處理 1000 次左右的請求。
而內存的隨機讀寫速度是磁盤的 10 萬倍!所以,使用內存作為緩存來加速應用程序的訪問速度,是幾乎所有高性能系統都會采用的方法。
緩存的思想很簡單,就是把低速存儲的數據,復制一份副本放到高速的存儲中,用來加速數據的訪問。緩存使用起來也非常簡單,很多同學在做一些業務系統的時候,在一些執行比較慢的方法上加上一個 @Cacheable 的注解,就可以使用緩存來提升它的訪問性能了。
但是,你是否考慮過,采用 @Cacheable 注解的方式緩存的命中率如何?或者說怎樣才能提高緩存的命中率?緩存是否總能返回最新的數據?如果緩存返回了過期的數據該怎么辦?接下來,我們一起來通過學習設計、使用緩存的最佳實踐,找到這些問題的答案。
選擇只讀緩存還是讀寫緩存?
使用緩存,首先你就會面臨選擇讀緩存還是讀寫緩存的問題。他們唯一的區別就是,在更新數據的時候,是否經過緩存。
我們之前的課中講到 Kafka 使用的 PageCache,它就是一個非常典型的讀寫緩存。操作系統會利用系統空閑的物理內存來給文件讀寫做緩存,這個緩存叫做 PageCache。應用程序在寫文件的時候,操作系統會先把數據寫入到 PageCache 中,數據在成功寫到 PageCache 之后,對于用戶代碼來說,寫入就結束了。
然后,操作系統再異步地把數據更新到磁盤的文件中。應用程序在讀文件的時候,操作系統也是先嘗試從 PageCache 中尋找數據,如果找到就直接返回數據,找不到會觸發一個缺頁中斷,然后操作系統把數據從文件讀取到 PageCache 中,再返回給應用程序。
我們可以看到,在數據寫到 PageCache 中后,它并不是同時就寫到磁盤上了,這中間是有一個延遲的。操作系統可以保證,即使是應用程序意外退出了,操作系統也會把這部分數據同步到磁盤上。但是,如果服務器突然掉電了,這部分數據就丟失了。
你需要知道,讀寫緩存的這種設計,它天然就是不可靠的,是一種犧牲數據一致性換取性能的設計。當然,應用程序可以調用 sync 等系統調用,強制操作系統立即把緩存數據同步到磁盤文件中去,但是這個同步的過程是很慢的,也就失去了緩存的意義。
另外,寫緩存的實現是非常復雜的。應用程序不停地更新 PageCache 中的數據,操作系統需要記錄哪些數據有變化,同時還要在另外一個線程中,把緩存中變化的數據更新到磁盤文件中。在提供并發讀寫的同時來異步更新數據,這個過程中要保證數據的一致性,并且有非常好的性能,實現這些真不是一件容易的事兒。
所以說,一般情況下,不推薦你來使用讀寫緩存。
那為什么 Kafka 可以使用 PageCache 來提升它的性能呢?這是由消息隊列的一些特點決定的。
首先,消息隊列它的讀寫比例大致是 1:1,因為,大部分我們用消息隊列都是一收一發這樣使用。這種讀寫比例,只讀緩存既無法給寫加速,讀的加速效果也有限,并不能提升多少性能。
另外,Kafka 它并不是只靠磁盤來保證數據的可靠性,它更依賴的是,在不同節點上的多副本來解決數據可靠性問題,這樣即使某個服務器掉電丟失一部分文件內容,它也可以從其他節點上找到正確的數據,不會丟消息。
而且,PageCache 這個讀寫緩存是操作系統實現的,Kafka 只要按照正確的姿勢來使用就好了,不涉及到實現復雜度的問題。所以,Kafka 其實在設計上,充分利用了 PageCache 這種讀寫緩存的優勢,并且規避了 PageCache 的一些劣勢,達到了一個非常好的效果。
和 Kafka 一樣,大部分其他的消息隊列,同樣也會采用讀寫緩存來加速消息寫入的過程,只是實現的方式都不一樣。
不同于消息隊列,我們開發的大部分業務類應用程序,讀寫比都是嚴重不均衡的,一般讀的數據的頻次會都會遠高于寫數據的頻次。從經驗值來看,讀次數一般都是寫次數的幾倍到幾十倍。這種情況下,使用只讀緩存來加速系統才是非常明智的選擇。
接下來,我們一起來看一下,在構建一個只讀緩存時,應該側重考慮哪些問題。
保持緩存數據新鮮
對于只讀緩存來說,緩存中的數據來源只有一個途徑,就是從磁盤上來。當數據需要更新的時候,磁盤中的數據和緩存中的副本都需要進行更新。我們知道,在分布式系統中,除非是使用事務或者一些分布式一致性算法來保證數據一致性,否則,由于節點宕機、網絡傳輸故障等情況的存在,我們是無法保證緩存中的數據和磁盤中的數據是完全一致的。
如果出現數據不一致的情況,數據一定是以磁盤上的那份拷貝為準。我們需要解決的問題就是,盡量讓緩存中的數據與磁盤上的數據保持同步。
那選擇什么時候來更新緩存中的數據呢?比較自然的想法是,我在更新磁盤中數據的同時,更新一下緩存中的數據不就可以了?這個想法是沒有任何問題的,緩存中的數據會一直保持最新。但是,在并發的環境中,實現起來還是不太容易的。
你是選擇同步還是異步來更新緩存呢?如果是同步更新,更新磁盤成功了,但是更新緩存失敗了,你是不是要反復重試來保證更新成功?如果多次重試都失敗,那這次更新是算成功還是失敗呢?如果是異步更新緩存,怎么保證更新的時序?
比如,我先把一個文件中的某個數據設置成 0,然后又設為 1,這個時候文件中的數據肯定是 1,但是緩存中的數據可不一定就是 1 了。因為把緩存中的數據更新為 0,和更新為 1 是兩個并發的異步操作,不一定誰會先執行。
這些問題都會導致緩存的數據和磁盤中的數據不一致,而且,在下次更新這條數據之前,這個不一致的問題它是一直存在的。當然,這些問題也不是不能解決的,比如,你可以使用分布式事務來解決,只是付出的性能、實現復雜度等代價比較大。
另外一種比較簡單的方法就是,定時將磁盤上的數據同步到緩存中。一般的情況下,每次同步時直接全量更新就可以了,因為是在異步的線程中更新數據,同步的速度即使慢一些也不是什么大問題。如果緩存的數據太大,更新速度慢到無法接受,也可以選擇增量更新,每次只更新從上次緩存同步至今這段時間內變化的數據,代價是實現起來會稍微有些復雜。
如果說,某次同步過程中發生了錯誤,等到下一個同步周期也會自動把數據糾正過來。這種定時同步緩存的方法,缺點是緩存更新不那么及時,優點是實現起來非常簡單,魯棒性非常好。
還有一種更簡單的方法,我們從來不去更新緩存中的數據,而是給緩存中的每條數據設置一個比較短的過期時間,數據過期以后即使它還存在緩存中,我們也認為它不再有效,需要從磁盤上再次加載這條數據,這樣就變相地實現了數據更新。
很多情況下,緩存的數據更新不那么及時,我們的系統也是能夠接受的。比如說,你剛剛發了一封郵件,收件人過了一會兒才收到?;蛘哒f,你改了自己的微信頭像,在一段時間內,你的好友看到的你還是舊的頭像,這些都是可以接受的。這種對數據一致性沒有那么敏感的場景下,你一定要選擇后面兩種方法。
而像交易類的系統,它對數據的一致性非常敏感。比如,你給別人轉了一筆錢,別人查詢自己余額卻沒有變化,這種情況肯定是無法接受的。對于這樣的系統,一般來說,都不使用緩存或者使用我們提到的第一種方法,在更新數據的時候同時來更新緩存。
緩存置換策略
在使用緩存的過程中,除了要考慮數據一致性的問題,你還需要關注的另一個重要的問題是,在內存有限的情況下,要優先緩存哪些數據,讓緩存的命中率最高。
當應用程序要訪問某些數據的時候,如果這些數據在緩存中,那直接訪問緩存中的數據就可以了,這次訪問的速度是很快的,這種情況我們稱為一次緩存命中;如果這些數據不在緩存中,那只能去磁盤中訪問數據,就會比較慢。這種情況我們稱為“緩存穿透”。顯然,緩存的命中率越高,應用程序的總體性能就越好。
那用什么樣的策略來選擇緩存的數據,能使得緩存的命中率盡量高一些呢?
如果你的系統是那種可以預測未來訪問哪些數據的系統,比如說,有的系統它會定期做數據同步,每次同步的數據范圍都是一樣的,像這樣的系統,緩存策略很簡單,就是你要訪問什么數據,就緩存什么數據,甚至可以做到百分之百的命中。
但是,大部分系統,它并沒有辦法準確地預測未來會有哪些數據會被訪問到,所以只能使用一些策略來盡可能地提高緩存命中率。
一般來說,我們都會在數據首次被訪問的時候,順便把這條數據放到緩存中。隨著訪問的數據越來越多,總有把緩存占滿的時刻,這個時候就需要把緩存中的一些數據刪除掉,以便存放新的數據,這個過程稱為緩存置換。
到這里,問題就變成了:當緩存滿了的時候,刪除哪些數據,才能會使緩存的命中率更高一些,也就是采用什么置換策略的問題。
命中率最高的置換策略,一定是根據你的業務邏輯,定制化的策略。比如,你如果知道某些數據已經刪除了,永遠不會再被訪問到,那優先置換這些數據肯定是沒問題的。再比如,你的系統是一個有會話的系統,你知道現在哪些用戶是在線的,哪些用戶已經離線,那優先置換那些已經離線用戶的數據,盡量保留在線用戶的數據也是一個非常好的策略。
另外一個選擇,就是使用通用的置換算法。一個最經典也是最實用的算法就是 LRU 算法,也叫最近最少使用算法。這個算法它的思想是,最近剛剛被訪問的數據,它在將來被訪問的可能性也很大,而很久都沒被訪問過的數據,未來再被訪問的幾率也不大。
基于這個思想,LRU 的算法原理非常簡單,它總是把最長時間未被訪問的數據置換出去。你別看這個 LRU 算法這么簡單,它的效果是非常非常好的。
Kafka 使用的 PageCache,是由 Linux 內核實現的,它的置換算法的就是一種 LRU 的變種算法
:LRU 2Q。我在設計 JMQ 的緩存策略時,也是采用一種改進的 LRU 算法。LRU 淘汰最近最少使用的頁,JMQ 根據消息這種流數據存儲的特點,在淘汰時增加了一個考量維度:頁面位置與尾部的距離。因為越是靠近尾部的數據,被訪問的概率越大。
這樣綜合考慮下的淘汰算法,不僅命中率更高,還能有效地避免“挖墳”問題:例如某個客戶端正在從很舊的位置開始向后讀取一批歷史數據,內存中的緩存很快都會被替換成這些歷史數據,相當于大部分緩存資源都被消耗掉了,這樣會導致其他客戶端的訪問命中率下降。加入位置權重后,比較舊的頁面會很快被淘汰掉,減少“挖墳”對系統的影響。
小結
這節課我們主要聊了一下,如何使用緩存來加速你的系統,減少磁盤 IO。按照讀寫性質,可以分為讀寫緩存和只讀緩存,讀寫緩存實現起來非常復雜,并且只在消息隊列等少數情況下適用。只讀緩存適用的范圍更廣,實現起來也更簡單。
在實現只讀緩存的時候,你需要考慮的第一個問題是如何來更新緩存。這里面有三種方法,第一種是在更新數據的同時去更新緩存,第二種是定期來更新全部緩存,第三種是給緩存中的每個數據設置一個有效期,讓它自然過期以達到更新的目的。這三種方法在更新的及時性上和實現的復雜度這兩方面,都是依次遞減的,你可以按需選擇。
對于緩存的置換策略,最優的策略一定是你根據業務來設計的定制化的置換策略,當然你也可以考慮 LRU 這樣通用的緩存置換算法。
思考題
課后來寫點兒代碼吧,實現一個采用 LRU 置換算法的緩存。
/**
* KV 存儲抽象
*/
public interface Storage {
/**
* 根據提供的 key 來訪問數據
* @param key 數據 Key
* @return 數據值
*/
V get(K key);
}
/**
* LRU 緩存。你需要繼承這個抽象類來實現 LRU 緩存。
* @param 數據 Key
* @param 數據值
*/
public abstract class LruCache implements Storage{
// 緩存容量
protected final int capacity;
// 低速存儲,所有的數據都可以從這里讀到
protected final Storage lowSpeedStorage;
public LruCache(int capacity, Storage lowSpeedStorage) {
this.capacity = capacity;
this.lowSpeedStorage = lowSpeedStorage;
}
}
復制代碼
你需要繼承 LruCache 這個抽象類,實現你自己的 LRU 緩存。lowSpeedStorage 是提供給你可用的低速存儲,你不需要實現它。
歡迎你把代碼上傳到 GitHub 上,然后在評論區給出訪問鏈接。大家來比一下,誰的算法性能更好。如果你有任何問題,也可以在評論區留言與我交流。
感謝閱讀,如果你覺得這篇文章對你有幫助的話,也歡迎把它分享給你的朋友。
總結
以上是生活随笔為你收集整理的@cacheable 是否缓存成功_缓存策略:如何使用缓存来减少磁盘IO?的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 数据追加用什么函数_RL用算法发现算法:
- 下一篇: 小波滤波器与其他滤波器的区别_滤波器国产