Redis 緩存應用實戰

爲了提升系統吞吐量,咱們常常在業務架構中引入緩存層。css

緩存一般使用 Redis / Memcached 等高性能內存緩存來實現, 本文以 Redis 爲例討論緩存應用中面臨的一些問題。html

緩存更新一致性

當執行寫操做後,須要保證從緩存讀取到的數據與數據庫中持久化的數據是一致的,所以須要對緩存進行更新。mysql

由於涉及到數據庫和緩存兩步操做,難以保證更新的原子性。git

在設計更新策略時,咱們須要考慮多個方面的問題:github

  • 對系統吞吐量的影響:好比更新緩存會比刪除緩存減小數據庫查詢壓力
  • 併發安全性:併發讀寫時某些異常操做順序可能形成數據不一致(緩存中長期存儲舊數據)
  • 更新失敗的影響:若執行過程當中某個操做失敗,如何對業務影響降到最小
  • 檢測和修復故障的難度: 併發問題致使緩存中存儲舊數據比操做失敗致使的數據更難檢測

通常來講操做失敗出現的機率較小,且一般會在日誌中留下較爲詳細的信息比較容易修復數據。sql

而併發異常形成的數據不一致則很是難以檢測,且多在流量高峯時發生可能形成較多數據不一致,須要更加劇視。shell

併發異常一般因爲後開始的線程卻先完成操做致使,咱們能夠把這種現象稱爲「搶跑」。數據庫

更新緩存有兩種方式:緩存

  • 刪除失效緩存: 讀取時會由於未命中緩存而從數據庫中讀取新的數據並更新到緩存中
  • 更新緩存: 直接將新的數據寫入緩存覆蓋過時數據

更新緩存和更新數據庫有兩種順序:安全

  • 先數據庫後緩存
  • 先緩存後數據庫

兩兩組合共有四種更新策略,如今咱們逐一進行分析。

四種策略都存在問題,通常來講先更新數據庫再刪除緩存是四種策略中一致性最好的策略,但仍需具體場景具體分析選擇。

先更新數據庫,再刪除緩存

若數據庫更新成功,刪除緩存操做失敗,則此後讀到的都是緩存中過時的數據,形成不一致問題。

緩存操做失敗在會在日誌中留下錯誤信息,在系統恢復正常後比較容易檢測和修復數據。

若線程A試圖讀取某個數據而緩存未命中,在線程A讀取數據庫後寫入緩存前,線程B完成了更新操做。此時,緩存中還是舊數據,致使與數據庫不一致。

對於 list、hash 或計數器等緩存來講,更新緩存實現難度較大(且難以保證一致性)而重建緩存的難度較低,此時採用後刪除緩存的策略較好。

由於緩存刪除後讀操做會直接訪問數據庫,可能對數據庫形成很大壓力。這一問題在熱點數據上很是明顯。好比熱門文章的閱讀數或者某個大V的粉絲數,它們的讀寫都很是頻繁。

當緩存被清除後,線程A會讀取數據庫試圖重建緩存,在重建完成前線程B也試圖讀取該數據。此時線程B緩存未命中而去讀取數據庫,從而給數據庫帶來沒必要要的壓力。

對於熱點數據,若即時性和一致性要求較低時建議採用延遲更新的策略,若一致性要求略高則採用加(分佈式)鎖的方式。

先更新數據庫,再更新緩存

同刪除緩存策略同樣,若數據庫更新成功緩存更新失敗則會形成數據不一致問題。

緩存更新失敗的問題較爲少見且比較容易處理,但後更新緩存的模式存在難以解決的併發問題。

若線程A試圖寫入數據a, 隨後線程B試圖將該數據更新爲b。若線程B後完成了數據庫的寫入, 但卻搶在線程A以前完成了緩存更新。此時數據庫中值爲b(線程B後提交事務), 而緩存中值爲a(線程A後寫入緩存), 爲不一致狀態。

先刪除緩存,再更新數據庫

若數據庫寫入延時較大,此種方案可能出現風險。 考慮這樣的情景:

若線程A試圖更新數據, 線程B在線程A刪除緩存後、提交數據庫事務前嘗試讀取該數據。則由於數據庫未更新,線程B從數據庫中讀出舊數據寫入緩存中, 致使緩存中一直是舊數據。

先更新緩存,再更新數據庫

若緩存更新成功數據庫更新失敗, 則此後讀到的都是未持久化的數據。由於緩存中的數據是易失的,這種狀態很是危險。

由於數據庫由於鍵約束致使寫入失敗的可能性較高,因此這種策略風險較大。

異步更新

雙寫更新的邏輯複雜,一致性問題較多。如今咱們能夠採用訂閱數據庫更新的方式來更新緩存。

阿里巴巴開源了mysql數據庫binlog的增量訂閱和消費組件 - canal

咱們能夠採用API服務器只寫入數據庫,而另外一個線程訂閱數據庫 binlog 增量進行緩存更新,則能夠輕鬆地保證緩存更新順序與數據事務提交順序一致。

緩存穿透

爲了不無效數據佔用緩存,咱們一般不會在緩存中存儲空對象,但這種策略會形成緩存穿透問題。

若要查詢的數據不存在,那麼固然不可能從緩存中查到這個數據,按照緩存未命中即訪問數據庫的邏輯,全部對不存在數據的查詢都會到達數據庫,這種現象稱做緩存穿透。

爲了減小無心義的數據庫訪問,咱們能夠緩存表示數據不存在的佔位符。

一般來講訪問已被刪除的對象形成緩存穿透的機率較高, 所以刪除數據時應在緩存中放置表示已被刪除佔位符。

另外一種常見的緩存穿透場景是訪問集合式緩存,好比訪問沒有評論的文章的評論頁,或者未發表過文章的用戶主頁。這種場景可使用佔位符避免緩存穿透, 也能夠先檢查緩存中的評論計數器或文章計數器防止緩存穿透。

集合式緩存

Redis 提供了 List、Hash、Set 和 SortedSet 等數據結構,咱們能夠將其稱爲集合式緩存。

集合式緩存一般更新的邏輯較爲複雜(或者難以保證一致性)而重建邏輯較爲簡單,同時重建緩存時也可能帶來更大的數據庫壓力。

計數器式緩存一樣具備更新邏輯複雜、重建簡單但重建緩存時數據庫壓力大的特色,所以做者也將其納入集合式緩存。計數器的複雜度在計數的對象狀態機複雜時尤其明顯,如計數某個用戶公開文章和所有文章數。

以文章的評論列表爲例,當 Redis 緩存中評論列表爲空時,可能有兩種緣由:

  • 緩存未命中
  • 評論列表確實爲空

除了上一節提到的防止緩存擊穿外,更新緩存的邏輯也須要分別處理兩種狀況。若緩存未命中而直接插入新評論,則可能致使評論列表中只有這一條新評論而沒有更早評論的狀況。

做者建議集合式緩存中元素應爲不可變的對象或對象ID。仍以評論列表爲例,若在 List 或 SortedSet 中直接存儲序列化後的評論對象,則只有知道對象的所有字段才能定位該評論。

在修改評論後,咱們難以得到原評論的內容定位或修改的難度較高。若某條評論存在於多個集合式緩存中,則須要多處修改。

此外,完整的評論對象字節數遠大於ID, 在須要多處存儲時使用ID能夠節省大量內存。

重建緩存

在上文中提到過,當線程A緩存未命中時會嘗試從數據庫讀取數據以重建緩存。若在線程A重建緩存完成前,線程B嘗試讀取該數據一樣會發生緩存未命中,致使重複讀取數據庫,形成數據庫資源浪費。

若重建過程涉及較多操做 Redis 沒法保證其原子性時,咱們一樣也須要使用加鎖的方式保證重建操做的原子性避免併發異常

Check-Lock-Check

重建問題與單例模式中多線程同時調用 getInstance() 方法致使對象被重複建立的問題相似,咱們一樣能夠採用 Check-Lock-Check 模式解決。

即當線程緩存未命中後阻塞試圖加(分佈式)鎖,成功得到鎖後再次檢查緩存是否已被建立。若緩存仍未被重建則進入讀數據庫重建流程

事務

一樣的,使用 Watch 命令監視要重建的 KEY 並使用 Multi 命令開始事務重建該緩存。Redis 事務也能夠達到避免重複創建的目的,可是沒法避免重複讀取數據庫,且在集羣條件下 Redis 事務可能受到較多限制。

使用 Redis 事務進行重建的示例:

127.0.0.1:6379> WATCH a OK 127.0.0.1:6379> MULTI OK 127.0.0.1:6379> set a 1 QUEUED 127.0.0.1:6379> EXEC 1) OK

開啓兩個客戶端模擬競爭的狀況:

client-1> WATCH b OK client-1> MULTI OK client-1> set b 2 QUEUED client-2> set b 1 OK client-1> EXEC (nil)

Rename

樂觀鎖

若是說上文經過加鎖的方式避免併發問題能夠認爲是悲觀鎖的思路,對於寫入競爭不激烈的場景可使用 RENAMENX 命令來實現樂觀鎖。

當須要重建緩存時,咱們須要建立一個臨時的鍵並在其上完成重建操做, 由於臨時鍵只有一個線程訪問,無需擔憂原子性和各類併發問題。

重建完成後使用 RENAMENX 或 RENAME 命令原子性地將其重命名爲正式的鍵提供給全部線程訪問。

離線數據處理

咱們能夠將髒數據放入 SET 或 HASH 中以進行離線更新。如上文提到的熱門文章的訪問數,咱們可使用 HINCRBY 命令將文章ID及其訪問數增量放入 HASH 表中, 使用 HSCAN 命令單線程的遍歷,將增量持久化到數據庫或線上緩存。

須要注意的問題是: 在 HSCAN 命令掃描 HASH 表的過程當中, 該 HASH 表內容發生變化可能致使併發問題。特別是當 HSCAN 命令執行過程當中新增 field 可能致使重複訪問。

所以咱們須要將線上髒數據 Hash 重命名到臨時鍵中,在不會發生改變的臨時鍵中單線程的進行遍歷。

HSCAN 和 SSCAN 命令遍歷的過程較長,遍歷線程可能會被中斷。若擔憂數據丟失,則能夠按必定規則生成臨時鍵, 這樣能夠方便檢查有哪些臨時鍵還沒有被消費完畢。

臨時鍵的生成

在集羣環境中,可能僅支持相同 Slot 下的 RENAME 和 RENAMENX 命令。所以, 咱們可使用 HashKey 機制保證臨時鍵和原鍵在同一個Slot中。

若原鍵爲 "original" 咱們則能夠生成臨時鍵爲 "{original}-1", 花括號表示僅由花括號內部的子串進行哈希來決定 Slot, "{original}-1" 必定會與 "original" 處於相同 Slot 中。

使用臨時鍵的目的是爲了單線程的進行操做避免併發問題,所以務必檢查臨時鍵是否已被其它線程佔用。

臨時鍵有兩種生成策略:

  • 原鍵加隨機值: 如 "{original}-kGi3X1", 這種方法的優勢是隨機鍵衝突的機率較小可是難以掃描庫中有哪些臨時鍵
  • 原鍵加計數器: 如 "{original}-1"、"{original}-2", 這種方法的有點是容易掃描庫中有哪些臨時鍵能夠用於離線數據處理,可是衝突的機率較高

爲了不臨時鍵衝突,咱們能夠在使用前先嚐試設置一個佔位符。如,在使用 "{original}-1" 前先執行 "SETNX {original}-1-lock" 若設置成功則能夠安全地使用 "{original}-1"。這種作法其實是加了一個簡單的分佈式鎖。

在檢測臨時鍵存在後就使用是不安全的,在線程A檢測存在後實際使用前,其它線程檢測不到臨時鍵存在可能誤認爲該鍵可用。

SortedSet

SortedSet 做爲 Redis 中惟一的可排序和可範圍查找的數據結構能夠進行一些比較靈活的應用。

延時隊列

在對一致性沒有較高要求的場景可使用 SortedSet 充當延時隊列,將消息的內容做爲 member, 預約執行時間的UNIX時間戳做爲 score。

調用 ZRANGEBYSCORE 方法輪詢預約執行時間早於當前時間的消息併發送給 Msg Consumer 處理。

127.0.0.1:6379> ZADD DelayQueue 155472822 msg
(integer) 1
127.0.0.1:6379> ZRANGEBYSCORE DelayQueue 0 1554728933 WITHSCORES
1) "msg"
2) "1554728822"

必要時能夠選用富類型 Java 客戶端 Redisson 提供的 RDelayedQueue, 它實現了更完善的延時隊列。

因爲 Redis 持久化機制等緣由,任何基於 Redis 的隊列都不可能提供高一致性的服務。

請勿在高一致性要求的業務場景下使用 Redis 作消息隊列

滑動窗口

在如熱搜或限流之類的業務場景中咱們須要快速查詢過去一小時內被搜索最多的關鍵詞。

與延時隊列相似,將關鍵詞做爲 SortedSet 的 member, 發生的UNIX時間戳做爲 score。

使用 ZRANGEBYSCORE 命令查詢某個時間段內發生的事件, ZREMRANGEBYSCORE 命令移除過舊的數據。

一些常識

閱讀本文的讀者應有必定的 Redis 緩存使用經驗,所以一些基本常識放在最後以儘可能避免浪費讀者的時間。

  1. IO操做的耗時一般遠高於CPU計算,儘可能使用 MGET 等批量命令或 Pipeline 機制來減小 IO 時間,切勿循環進行 Redis 讀寫等IO操做
  2. Redis 使用IO複用模型內核單線程模式,保證命令執行原子性和串行性。(至寫做時 Redis 4.0 版本還是如此,此後極可能引入多線程內核)
  3. Redis 的RDB和AOF都採用異步持久化的模式,沒法保證Redis崩潰後徹底不丟失數據。 所以請勿將Redis用於一致性要求較高的業務場景。
Keep working, we will find a way out. This is Finley, welcome to join us.
相關文章
相關標籤/搜索