快取一致性的三個障礙
- 當對主資料庫的更改未反映在快取中時
- 更新快取結果時出現延遲
- 當快取節點之間不一致時
如何設計快取更新模式?首先,提出我閱讀相關文章時遇到的疑惑。
當我使用搜尋引擎(百度 or Google)去搜尋快取與
資料庫的資料一致性如何保證
時,我會看到三種結果:
- 僅講解先運算元據庫 or 先操作快取(最多)
- 講解四種快取更新策略模式
- 將上述兩者結合起來(較少)
在分別閱讀完一些前兩者的文章後,我就一直在思考
- 四種快取更新策略模式與操作順序的關係是什麼?(因為看到了類似“四種快取更新策略廣泛用於分散式系統和高效能應用中”,但是又有很多實踐操作順序的文章,比如先寫資料庫,再更新快取的“延時雙刪”策略)
- 為什麼很多文章推薦我們去閱讀另一種呢?(eg:我看見了一些寫操作順序的文章推薦我去閱讀快取更新策略模式相關文章)
帶著上面的兩個問題,我去搜尋,去嘗試寫出下面的部落格,並在介紹兩者後,總結出我對它們的理解。
快取更新設計模式
1. Cache-Aside
應用程式從快取中讀取資料
- 如果命中,直接返回
- 沒有命中,改為從資料庫讀取,並寫回快取
ps:我在一些部落格上會在這裡加上寫入的設計
- 有的部落格說先把資料存到資料庫,成功後,再讓快取失效
- 有的部落格說僅僅將資料存到資料庫(即後面提到的Write-Around)
- 還看見了變體,先淘汰快取,再寫資料庫…
在看完這些部落格之後,我的理解是:Cache-Aside更多的定義讀請求的更新方案,它可以結合不同的寫入策略,來保證資料一致性。依據是,在亞馬遜的文件和微軟的文件中,均只提到了它讀請求的更新方案,並未提及必須要有固定的寫入策略。
優點
- 使用最少的記憶體(至少在理論上),因為快取的專案僅在需要時才獲取(延遲載入)
- Memcached 和 Redis 被廣泛使用。使用Cache-Aside的系統可以適應快取故障。如果快取叢集當機,系統仍然可以透過直接訪問資料庫來操作。 (儘管如此,如果快取在峰值負載期間出現故障,也沒有多大幫助。響應時間可能會變得很糟糕,在最壞的情況下,資料庫可能會停止工作。)
缺點
- 快取未命中時,延遲較高,因為需要從資料庫獲取資料。如果快取未命中太多,會影響系統效能(快取穿透?)。
- 當使用cache-aside時,寫入策略之一是直接將資料寫入資料庫(Write-Around)。發生這種情況時,快取可能會與資料庫變得不一致。為了解決這個問題,開發人員通常使用生存時間 (TTL) 並繼續提供過時的資料,直到 TTL 過期。如果必須保證資料新鮮度,開發人員要麼使快取失效,要麼使用適當的寫入策略,我們稍後將對此進行探討(新鮮度也會稍後探討)。
何時使用
-
最適合讀取密集型工作時使用。
-
資源需求是不可預測的。這種模式使應用程式能夠按需載入資料。它不會預先假設應用程式需要哪些資料。
-
快取不提供Read-Through和Write-Through功能。
2. Read-Through
應用程式查詢資料時
- 當快取未命中時,由快取去查詢資料庫,並且將結果寫入到快取中,最後返回結果給應用程式。(也是延遲載入)
- 如果命中,直接返回
優點
適合讀取密集型工作
缺點
- 缺點是當第一次請求資料時,總是會導致快取未命中,並帶來將資料載入到快取的額外開銷。開發人員透過手動發出查詢來“加熱”或“預熱”快取來處理此問題。
- 就像cache-aside一樣,快取和資料庫之間的資料也可能不一致,解決方案在於寫入策略,我們接下來會看到。
何時使用
- 當多次請求相同資料時,最適合讀取密集型工作。例如,一則新聞報導。
- 快取產品應該能夠透過配置從資料庫執行讀取。
3. Write-Through
在Write-Through模式下,當發生寫入時,它會在同一事務中傳播到快取和資料庫。
應用程式更新快取,然後快取同步更新主資料庫,而不是更新主資料庫並刪除快取。換句話說,快取不依賴主資料庫來啟動任何更新,而是負責維護其自身的一致性並將其所做的任何更改傳遞迴主資料庫。當寫入完成時,快取和資料庫都具有相同的值,並且快取始終保持一致。
優點
- 就其本身而言,直寫式快取似乎沒有多大作用,事實上,它們引入了額外的寫入延遲,因為資料首先寫入快取,然後寫入主資料庫(兩個寫入操作)。但是當與Read-Through配對使用時,我們可以獲取到Read-Through的所有好處
- 確保快取和底層資料儲存之間的一致性
缺點
- 使操作更加負責,需要考慮失敗情況。
- 寫入更慢,因為需要更新兩個位置。
何時使用
直寫式快取非常適合需要強資料一致性且無法提供過時資料的應用程式。它通常用於資料寫入後必須立即準確且最新的環境中。
4. Write-Around
此策略填充底層儲存,但不填充快取本身。該技術可以與 Cache-Aside 結合使用。
優點
減少快取汙染,因為快取不會在每次寫入時填充。
缺點
- 如果某些記錄經常被讀取,那麼效能會受到影響,因此主動載入到快取中以防止在第一次命中時訪問資料庫會受益。
- 因為寫操作繞過了快取,只更新資料庫,這會導致快取中的資料在寫操作後變得不一致。具體來說,在資料被寫入資料庫之後,快取中的資料仍然是舊的,直到快取失效或被刪除為止。這可能會導致讀取到過期資料,影響資料的一致性和準確性。
何時使用
當寫入量很大但讀取量明顯較低時,通常會使用此方法。但是這沒有充分發揮快取的優勢呀(請各位思考一下為什麼使用快取)。
5. Write-Behind
這裡,應用程式將資料寫入快取,快取儲存資料並立即嚮應用程式確認。然後,快取將資料寫回資料庫。
它透過先僅更新快取然後非同步更新主資料庫來避免此問題。當然,主資料庫也需要更新,而且越早越好,但這樣的話使用者就不必付出兩次寫入的“成本”。對主資料庫的第二次寫入是在幕後非同步發生的(因此得名“Write-Behind”),此時不太可能影響效能。
優點
- 可以提高寫入效能,適合寫密集型工作。在與Read-Through配合使用時,非常適合混合工作負載(將兩者優勢結合)。
- 它對資料庫故障具有彈性,並且可以容忍一些資料庫停機。如果支援批處理或合併,則可以減少對資料庫的總體寫入,從而減少負載並降低成本(如果資料庫提供商按請求數收費,例如請求數)
缺點
- 如果快取出現故障,資料可能會永久丟失。
- 可能會出現不一致,因為資料庫和快取將變得不同步,直到資料庫收到新的更改為止。
何時使用
當寫效能很關鍵,並且資料庫中的資料暫時與快取稍微不同步是可以接受的時候,可以使用Write-behind快取。適合寫入量大但一致性要求不太嚴格的應用。可以使用此功能的一個示例是 CDN(內容交付網路),用於快速更新快取的內容,然後將這些內容同步到記錄系統。
大多數關聯式資料庫儲存引擎(即 InnoDB)在其內部預設啟用回寫式快取。查詢首先寫入記憶體,最終重新整理到磁碟。
Cache Invalidation 快取失效
現在我們瞭解了更新快取的不同方法,我們還需要了解如何保持它與資料庫系統同步。
當談到快取失效時,兩種主要方法是基於時間和基於事件。基於時間的失效方法可以透過大多數快取產品中可用的生存時間 (TTL) 設定進行控制。基於事件的方法需要應用程式或其他東西將新資料傳送到快取。
關於資料快取的問題是,它幾乎總是與底層資料儲存(記錄系統)至少稍微不同步。換句話說——它變得陳舊了。為了保持快取與記錄系統儘可能同步,我們需要實現某種快取失效策略。換句話說,我們需要確保快取內資料的“新鮮度”。
快取失效會導致從資料庫查詢資料並將其放入快取中。因此,瞭解快取失效之間的關係及其與我們上面討論的快取更新策略的關係非常重要。
快取更新策略與如何從快取載入和檢索資料有關。另一方面,快取失效更多地與記錄系統和快取之間的資料一致性和新鮮度有關。
因此,這兩個概念之間存在一些重疊,並且如果使用某些快取策略,失效將比其他策略更簡單。例如,透過Write-Through方法,快取會在每次寫入時更新,因此您無需另外實現快取失效。
Event-Driven 事件驅動
使用事件驅動方法,每次資料庫資料發生更改時,應用程式都會通知快取。無論是同步還是非同步。
Time Based 基於時間
使用基於時間的方法,所有快取記錄都將具有與其關聯的 TTL(生存時間)。記錄的 TTL 過期後,該快取記錄將被刪除。這通常由快取產品控制。
可以與Cache-Aside配合使用的另外兩種方案
看到這裡,我或許明白了,快取更新策略是配合使用的,不同的順序可以獲取到不同的效能,對讀要求高和對寫要求高需要使用的方案也不一樣。
選擇什麼方案通常取決於以下兩個指標
- 對資料一致性的要求,強一致性還是最終一致性?
- 對系統效能的要求,是讀多場景還是寫多場景?
如果你有不一樣的見解,請務必指教!
方案一:先淘汰快取,再寫資料庫
因為先淘汰快取,因此資料的最終一致性可以得到保證(因為如果先淘汰快取,即使寫資料庫發生異常,也就是下次讀取快取時,多讀取一次資料庫)。
但是這種方案會導致資料不一致的情況,理由見圖 1。由圖可知,最後快取中的資料還是舊的資料,出現資料不一致的情況。
那麼,如何解決快取並行寫,實現序列寫呢?引入分散式鎖
- 在寫請求時,先淘汰快取之前,先獲取該分散式鎖。
- 在讀請求時,發現快取不存在時,先獲取分散式鎖。
方案二:先寫資料庫,再更新快取
先來講講可能遇到的讀寫競爭問題
一個是查詢操作,一個是更新操作的併發,首先,先更新了資料庫中的資料,此時,快取依然有效,所以,併發的查詢操作拿的是沒有更新的資料,但是,更新操作馬上讓快取的失效了,後續的查詢操作再把資料從資料庫中拉出來。
那麼,是不是Cache Aside這個就不會有併發問題了?不是的,比如,一個是讀操作,但是沒有命中快取,然後就到資料庫中取資料,此時來了一個寫操作,寫完資料庫後,讓快取失效,然後,之前的那個讀操作再把老的資料放進去,所以,會造成髒資料。
但,這個case理論上會出現,不過,實際上出現的機率可能非常低,因為這個條件需要發生在讀快取時快取失效,而且併發著有一個寫操作。而實際上資料庫的寫操作會比讀操作慢得多,而且還要鎖表,而讀操作必需在寫操作前進入資料庫操作,而又要晚於寫操作更新快取,所有的這些條件都具備的機率基本並不大。
那麼,如何解決上述問題呢?可以使用雙重刪除策略
雙重刪除策略:更新資料庫後,刪除快取;為了防止併發問題,可以在短暫的延遲後再執行一次快取刪除。這種方法可以減少快取和資料庫不一致的視窗期 。如下圖
再來講講中途失敗問題
如果在更新資料庫成功後但在清除快取之前發生故障(如程序崩潰或網路中斷),快取中的舊資料將不會被更新,這會導致讀請求拿到錯誤的資料。
可以透過兩種方式來解決該問題,先嚐試更新快取,如果失敗,再進行下面任一的操作。
- 基於定時任務實現
- 如果失敗,插入一條記錄到任務表,該記錄會儲存需要更新的快取<Key,Value>
- 【非同步】定時任務定時掃描任務表,更新到快取,之後刪除該記錄。
- 基於訊息佇列來實現
- 如果失敗,傳送帶有快取<Key,Value>的事務訊息。此時,需要有支援事務訊息特性的訊息佇列,或者我們自己封裝訊息佇列,支援事務訊息。
- 【非同步】最後,消費者消費該訊息,更新到快取中。
上述解決方案仍可能存在問題,例如發生下列情況,此時,資料被覆蓋了,可以透過分散式鎖或者CAS解決該問題。
擴充:基於資料庫的binlog日誌
這也是近些年多出的一種快取更新策略
- 應用直接寫資料到資料庫中(這不就是Write-Through嘛)。
- 資料庫更新binlog日誌。
- 利用Canal中介軟體讀取binlog日誌。
- Canal藉助於限流元件按頻率將資料發到MQ中。
- 應用監控MQ通道,將MQ的資料更新到Redis快取中。
可以看到這種方案對研發人員來說比較輕量,不用關心快取層面,而且這個方案雖然比較重,但是卻容易形成統一的解決方案。
引用
https://svip.iocoder.cn/Cache/Interview/
https://redis.io/blog/three-ways-to-maintain-cache-consistency/
https://dev.to/lazypro/consistency-between-cache-and-database-part-1-2e97
https://coolshell.cn/articles/17416.html
https://codeahoy.com/2017/08/11/caching-strategies-and-how-to-choose-the-right-one/
https://medium.com/@yt-cloudwaydigital/mastering-caching-in-distributed-applications-e7449f4db399
https://blog.csdn.net/v123411739/article/details/114803998
一切都是效能與一致性的權衡,沒有最好的方案,只有最適合的方案!!!
ps:這篇文章寫完已經是深夜了,寫完我自個都感覺或許有些不對的地方,如果讀者發現什麼問題,或者有什麼不同的體會,請務必告訴我!畢竟,我不能閉門造車2333