大家好,我是Java烘焙師。如何更新緩存和DB、做到性能和一致性的取捨,是一個很常見的話題。下面結合筆者的經驗和思考,系統性地總結一下緩存更新模式,講透講明白。
1、旁路緩存(cache-aside)
實現方案
- 查詢:先查緩存,查不到緩存時再查DB,並把DB內容寫入緩存、設置合適的過期時間
- 更新:先更新DB,再刪緩存;做到極致則需引入延遲雙刪機制
之所以不是先刪緩存、再更新DB,是因為在這兩個操作間隙,如果有其它查詢請求,則會把DB舊值寫到緩存。
之所以不是先更新DB、再更新緩存,是因為寫DB和緩存無法保證一致性,並且可能因為2個併發寫的時序問題而把舊數據寫到緩存。
之所以延遲雙刪,是因為在極端情況下,讀線程會把DB舊值寫到緩存。需要同時滿足幾個條件:緩存已過期,並且讀線程先查詢到DB舊值,然後寫線程更新DB、刪除緩存之後,讀線程才把DB舊值寫入緩存。如下圖所示。
因此第一次刪除緩存後,延遲一小段時間再刪除,就能保證緩存和DB的最終一致。下圖是引入了延遲雙刪機制的cache-aside架構圖。
cache-aside查詢場景:
cache-aside更新場景:
適用場景
-
絕大部分場景
優點
-
當數據量大時,可按需加載到緩存
缺點
- 如果存在熱點key,在失效後,會有大量查詢請求穿透緩存,直接打到DB,造成DB CPU使用率飆升
旁路緩存優化:主動預刷新緩存
為了解決熱點緩存失效問題,可考慮設置TTL為較長時間,並主動預刷新熱點key。
根據數據量大小區分:
- 如果數據量較大,則針對熱點key,配置白名單。做得更好的話,是自動發現、並更新熱點key白名單。
- 如果數據量較小,則可以考慮全部加載到緩存中,永不過期。如:一些全局的配置數據。
根據觸發刷新緩存的時機區分:
- 定時拉取:程序自行實現,根據熱點key白名單,定時查DB、並更新緩存
- 異構數據:監聽mysql變更,DB變化時觸發更新緩存
更推薦異構數據的方式,好處是:緩存更新及時,並且做成通用功能之後、無需額外開發。
2、異步寫回DB模式(write-back)
實現方案
- 查詢:只查詢緩存
- 更新:先寫入緩存,然後發消息、消息鏈路異步寫入DB,或定時任務兜底寫入DB
適用場景
查詢qps很高、極其熱點的數據,優先保證性能。
場景舉例:
- 計數統計:有的頁面會滾動刷新訪問人次、使用人次
- 爆品庫存扣減:redis扣減庫存,然後異步落庫,而不是常規地操作DB扣減庫存
優點
-
支撐高qps、熱點場景
缺點
- 短期內會出現緩存和DB數據不一致情況,需要消息觸發、或兜底定時任務寫回DB
3、read/write through模式
實現方案
不論是cache-aside、還是write-back模式,都需要應用程序自己來控制讀寫緩存、DB。而read/write through模式是把控制權交給底層存儲服務。
存儲服務維護緩存、持久化數據,應用程序無需感知,這也是優點了。不過完全依賴於存儲服務是否靠譜,實際業務場景並不常見。
4、持續優化
搭積木方式,根據實際情況做優化。
多級緩存:進一步降低緩存、DB的熱點風險
- 增加本地緩存,如caffeine
- 或增加DB以外的異構數據,當查不到緩存時再查異構數據、查不到異構數據時最終查DB。異構數據可以是HBase、ES等
通過邏輯層面來實現生效、過期的效果,而非系統層面
- 架構設計必須適配業務,比如通過邏輯過期解決不一致、緩存集中過期的問題,如緩存記錄業務開始時間、結束時間,TTL可設置稍長些、並且通過增加隨機時長來避免key集中失效。這樣就能實現到時間點就變的場景,如活動開始、結束。
強一致場景,只查DB、以DB數據為準
- 特別地,對一致性有強要求的場景:只查DB、不查緩存,以DB數據為準。如下單時查詢DB裏的價格,避免緩存數據非最新。
更進一步,考慮使用rocksdb,代替redis
- rocksdb相當於是自帶緩存的持久化數據庫,值得專門寫一篇文章介紹原理、區別,後面有空整理。
結論
- 絕大部分場景,使用旁路緩存模式(cache-aside)。更進一步,對部分熱點key做主動預刷新,可監聽DB變更、或定時刷新。
- 高qps、極熱key場景,使用異步寫回DB模式(write-back),優先保證性能,可接受短時間內DB與緩存不一致。
-
持續優化:
- 增加多級緩存、異構數據,來降低緩存、DB的熱點風險
- 通過邏輯層面來實現生效、過期的效果
- 強一致場景,只查DB、以DB數據為準
延伸閲讀:筆者之前寫的緩存相關文章,歡迎圍觀。
- 架構師必備:本地緩存原理和應用
- Spring cache源碼分析