Redis 緩存應用實戰

爲了提高系統吞吐量,我們經常在業務架構中引入緩存層。

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

緩存更新一致性

當執行寫操作後,需要保證從緩存讀取到的數據與數據庫中持久化的數據是一致的,因此需要對緩存進行更新。

因爲涉及到數據庫和緩存兩步操作,難以保證更新的原子性。

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

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

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

而併發異常造成的數據不一致則非常難以檢測,且多在流量高峯時發生可能造成較多數據不一致,需要更加重視。

併發異常通常由於後開始的線程卻先完成操作導致,我們可以把這種現象稱爲“搶跑”。

更新緩存有兩種方式:

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

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

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

兩兩組合共有四種更新策略,現在我們逐一進行分析。

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

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

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

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

若線程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", 這種方法的有點是容易掃描庫中有哪些臨時鍵可以用於離線數據處理,但是衝突的概率較高

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用於一致性要求較高的業務場景。
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章