緩存作為一種高速交換數(shù)據(jù)的存儲器, 其目的是為了協(xié)調(diào)兩種硬件之間的傳輸差異或者避免多次重復計算耗時任務,.
緩存無處不在(無論是硬件還是軟件方面), 目前大熱的Redis數(shù)據(jù)庫就是緩存的一種實現(xiàn), 但除此之外, 還有很多的實現(xiàn)方式.
但是請注意一句真理: 請不要過早的優(yōu)化結構. 緩存是個香餑餑的東西, 但是過早的引用, 會導致大量的問題. 對于互聯(lián)網(wǎng)App來說, 我認為使用的緩存的前提是應用扛不住用戶的并發(fā)量時
緩存的讀寫
既然是緩存, 那就一定還會有實際的數(shù)據(jù)端, 比如數(shù)據(jù)庫. 當我們進行讀寫時, 就要考慮到兩者的讀寫情況, 因為我們不能把緩存和數(shù)據(jù)庫當作一個事務內(nèi)的操作(盡管可以, 但性能會大幅度的下降)
對于緩存的讀寫, 產(chǎn)生3種機制:
- Cache Asiden Pattern
- Write/Read Through
- Write Back
Cache Asiden Pattern
這個機制下, 對于讀, 我們是這么描述的
- 應用端發(fā)起讀請求, 查找緩存是否存在, 若存在, 命中且返回
- 緩存不存在, 應用端向數(shù)據(jù)庫發(fā)起請求, 獲取數(shù)據(jù), 并同時修改到緩存中
對于寫, 涉及到更新, 有4種可能的方案
- 更新緩存, 再更新數(shù)據(jù)庫
- 更新數(shù)據(jù)庫, 再更新緩存
- 刪除緩存, 再更新數(shù)據(jù)庫
- 更新數(shù)據(jù)庫, 再刪除庫
對于第一種, 在多線程的情況下:
- 假設A線程將key為cache的值更新為2, 先更新緩存, 此時線程切換
- 線程B將key為cache的值更新為3, 更新緩存成功后接著更新數(shù)據(jù)庫, 此時數(shù)據(jù)庫中值為3
- B完成后, A切換回來, 更新數(shù)據(jù)庫, 改成2. 數(shù)據(jù)丟失
第二種跟第一種差不多, 會造成數(shù)據(jù)不一致的問題. 出現(xiàn)的原因在于, 我們要同時更新兩個數(shù)據(jù)庫, 然后我們并沒有任何機制去保證它們的原子性, 當線程切換時, 就會產(chǎn)生意想不到的結果. 除此之外, 在某些數(shù)據(jù)不停被修改的場景下, 我們需要頻繁的覆蓋緩存值, 這是不合理的
解決方案是: 我們先更新數(shù)據(jù)庫,然后刪除緩存, 當相應的讀請求到來時, 再從數(shù)據(jù)庫讀取
第三種為什么也不行呢?
- 假設線程A要將k ey為cache的值更新為2, 先刪除緩存, 此時線程切換
- 線程B要讀取cache的值, 發(fā)現(xiàn)緩存中沒有, 讀取數(shù)據(jù)庫, 此時還為舊值
- 線程A更新數(shù)據(jù)庫, 數(shù)據(jù)產(chǎn)生不一致
第四種就完美了嗎?
- 假設線程A要讀取一個緩存中不存在的值, 向數(shù)據(jù)庫讀取, 在準備將該值寫入緩存時, 線程切換
- 線程B更新該值, 寫入數(shù)據(jù)庫, 刪除緩存中值.
- 線程A寫入緩存, 此時數(shù)據(jù)不一致
這樣一看, 既然第四種還有問題, 為什么人們還推薦使用? 因為這個case只有在理論上才會出現(xiàn), 原因是它的發(fā)生要剛好在緩存值失效的同時, 且有個并發(fā)寫請求, 并且讀所消耗的時間, 要比寫大(有點異想天開).
總結一下: Cashe Aside流程
- 對于讀, 緩存在, 命中返回. 不存在, 從數(shù)據(jù)庫讀取, 并更新到緩存上
- 對于寫, 先更新數(shù)據(jù)庫, 在更新緩存
補充幾個問題
有沒有別的場景適合使用其他寫方案?
比如用戶注冊后需要顯示用戶資料, 所以按照上文機制, 更新完數(shù)據(jù)庫, 刪除緩存(沒有信息). 然后接下來的讀請求, 因為讀寫分離, 在讀從庫的時候很有可能因為讀寫延遲, 而導致讀出的數(shù)據(jù)為空
針對這個場景, 我們想一想. 注冊用戶可能是不存在并發(fā)的, 如果我們是用更新數(shù)據(jù)庫, 在更新緩存的策略, 是不是能完美解決讀寫延遲問題緩存對數(shù)據(jù)的態(tài)度是, 盡可能“保持數(shù)據(jù)一致性”, 也就是最終一致性. 那有什么辦法, 能強行保證一致性嗎?
使用更新數(shù)據(jù)庫, 在更新緩存的方案, 同時設置分布式鎖, 對于并發(fā)同一熱點的數(shù)據(jù)上鎖
Read/Write Through
它的核心在于: Cache Aside 模式是應用分別和緩存, 數(shù)據(jù)庫打交道. 而Read/Write Through是緩存替應用來打交到
- 對于讀請求, 命中返回數(shù)據(jù). 未命中, 緩存去向數(shù)據(jù)庫索取數(shù)據(jù), 并存放到緩存中后, 返回
- 對于寫請求, 命中. 更新緩存, 再由緩存去更新數(shù)據(jù)庫(這是個同步操作)
- 若寫未命中, 應用直接更新數(shù)據(jù)庫(減少一次寫入, 提升寫性能)
注意Redis, Memcached不提供與數(shù)據(jù)庫打交道, 或自動加載數(shù)據(jù)庫的功能. 所以分布式緩存一般使用的是Cache Aside模式, 而一般的本地緩存(Guava Cache)則使用的是Read/Write Through模式
但是對于寫機制來說, 如果緩存命中, 每次都要同步更新數(shù)據(jù)庫, 造成的延遲會比較高. 有沒有什么異步寫入的辦法?
Write Back
它的思想在于, 我們不需要每次進來寫請求, 就更新數(shù)據(jù)庫, 磁盤. 同時將它們攢起來, 做一些合并操作, 待到一個時機再一起寫入. (能大大的提升I/O性能)
它的弊端在于, 攢這個過程如果電腦突然掉電, 那數(shù)據(jù)就都沒了.
寫回機制在Unix系統(tǒng)上很多地方都用到, 比如人們熟知的“page cache”, 或者 CPU 與 L1,L2,L3緩存的寫入機制. CPU寫入時, 會把數(shù)據(jù)只寫到緩存中, 當該行數(shù)據(jù)需要被替換出去時, 才會正式寫入內(nèi)存.
總結
緩存并不是這幾年才流行的產(chǎn)物, 它的應用隨處可見. 理解了緩存, 你就擁有了互聯(lián)網(wǎng)開發(fā)三大神奇之一, 其他兩個為(MQ和RPC)
本篇中我們圍繞著緩存的讀寫, 引出了幾種機制. 而最近大火的Redis和Memcached使用的都是Cache Aside. Cache Aside的主要問題是因為并發(fā),而產(chǎn)生的不確定性. 所以在使用的時候, 圍繞著并發(fā)和你的應用場景去選擇才是最好的.
在理解緩存中, 心中一定要有個trade-off, 即數(shù)據(jù)一致性和 查詢性能的選擇.
參考: 緩存更新的套路