爲了提高系統吞吐量,我們經常在業務架構中引入緩存層。
緩存通常使用 Redis / Memcached 等高性能內存緩存來實現, 本文以 Redis 爲例討論緩存應用中面臨的一些問題。
緩存穿透
爲了避免無效數據佔用緩存,我們通常不會在緩存中存儲空對象,但這種策略會造成緩存穿透問題。
若要查詢的數據不存在,那麼當然不可能從緩存中查到這個數據,按照緩存失效即訪問數據庫的邏輯,所有對不存在數據的查詢都會到達數據庫,這種現象稱作緩存穿透。
爲了減少無意義的數據庫訪問,我們可以緩存表示數據不存在的佔位符。
與訪問一個從未存在過的數據相比訪問已刪除數據的概率較高, 因此刪除數據時應在緩存中放置表示已被刪除的佔位符。
集合式緩存
Redis 提供了 List、Hash、Set 和 SortedSet 等數據結構,我們將其稱爲集合式緩存。
集合式緩存通常更新的邏輯較爲複雜(或者難以保證一致性)而重建邏輯較爲簡單,但重建緩存時也可能帶來很大的數據庫壓力。
計數器式緩存同樣具有更新邏輯複雜、重建簡單但重建緩存時數據庫壓力大的特點,因此作者也將其歸入集合式緩存。計數器的複雜度在對象狀態機複雜時尤爲明顯,如計數某個用戶公開文章數和全部文章數。
以文章的評論列表爲例,當 Redis 緩存中評論列表爲空時,可能有兩種原因:
- 緩存失效
- 確實沒有評論
若當發佈評論後試圖更新緩存時發現緩存中沒有評論列表,我們需要考慮是緩存失效還是原來確實沒有評論。不要直接使用 LPUSH 或 ZADD 指令插入評論。
集合式緩存中元素應爲不可變的對象或對象ID。仍以評論列表爲例,若在 List 或 SortedSet 中直接存儲序列化後的評論對象,則只有知道對象的全部字段才能定位該評論。在修改評論後,我們難以獲得原評論的內容定位或修改的難度較高。若某條評論存在於多個集合式緩存中,則需要多處修改。
此外,完整的評論對象字節數遠大於ID, 在需要多處存儲時使用ID可以節省大量內存。
熱點數據緩存
在實際業務中我們常常需要處理熱點數據緩存失效問題。熱點數據的併發讀取量很大,一旦發生緩存失效可能會有大量線程訪問數據庫,可能造成響應變慢甚至數據庫宕機等嚴重後果。
一些場景下可能出現頻繁寫入的熱點數據,使用更新緩存的策略通常不會產生問題。若我們選擇了刪除過期緩存的策略進行更新,因爲熱點數據更新非常迅速導致頻繁地刪除緩存,進一步產生大量緩存失效錯誤。若採用了先刪除緩存後更新數據庫的策略,大量讀請求非常可能將過時數據寫入緩存中造成併發錯誤。
若熱點數據爲 Set 或 SortedSet 等集合式緩存,我們可能無法使用一條原子性指令完成整個重建操作,因此需要考慮保證重建過程的線程安全性。
根據對熱點數據一致性要求的不同,我們有兩套策略。
使用鎖保證高一致性
對於高一致性要求的場景我們可以使用分佈式鎖服務。讀請求應獲得讀鎖後才能訪問數據,寫請求應獲得寫鎖後才能更新數據。
當發生緩存失效的情況時,分佈式鎖服務會保證有且只有一個讀線程獲得寫鎖並完成緩存重建工作,其它讀線程因無法獲得鎖而被堵塞,直到緩存重建完成。這種方法避免了大量線程重複執行緩存重建工作造成數據庫壓力,但是無法避免響應變慢。
在單例模式中多線程同時調用 getInstance() 方法可能會導致對象重複創建,使用鎖進行緩存重建存在着類似的問題。線程A發現緩存失效於是獲取寫鎖進行重建工作,線程B在重建完成前訪問緩存仍然出現緩存失效,於是線程B嘗試獲取寫鎖。由於寫鎖被線程A持有,線程B會被阻塞直到重建完成才能得到寫鎖。因爲緩存已被重建,若線程B繼續重建緩存則會導致無意義的開銷。
使用單例模式中我們熟悉的 Check-Lock-Check 策略即可解決這個問題:
try {
讀取緩存
加讀鎖
} finally {
釋放讀鎖
}
if (緩存失效) {
try {
加寫鎖
讀取緩存
if (緩存失效) {
重建緩存
}
} finally {
釋放寫鎖
}
}
因爲有寫鎖保護我們無需擔心重建緩存時的線程安全問題。
樂觀策略
當熱點數據的緩存失效時,我們可以先使用 placeholder 佔位然後進行緩存重建工作。其它線程讀取到緩存中的 placeholder 會返回空結果而不會訪問數據庫,同時也避免了大量線程阻塞可能造成的不良後果。
placeholder 不能保證只有一個線程訪問數據庫。當線程A寫入 placeholder 時,線程B可能已經發生了緩存失效進入了重建流程。
若我們無法保證重建過程的原子性,則可以在臨時鍵上完成重建操作,然後使用 Rename 命令原子性替換掉正式鍵開放給所有線程。
Rename
雖然 Redis 命令都是原子性的但我們常常會遇到單個命令無法完成的操作,除了使用分佈式鎖來保證複雜過程的線程安全外,一些場景下我們可以使用 rename 命令來降低開銷。
典型的一個場景是上文提到的,無法保證緩存重建或更新操作的原子性時可以在當前線程私有的臨時鍵上完成操作,然後使用 Rename 命令原子性替換掉正式鍵開放給所有線程。
另一個常見的場景是將髒數據放入 Set 或 Hash 中,使用 SSCAN 或 HSCAN 命令進行異步更新。SSCAN 命令只保證在遍歷開始到結束整個過程中一直存在於數據集中的鍵至少會被返回一次,若遍歷的同時添加新數據則可能造成重複或遺漏的情況。
我們可以將髒數據集 rename 到異步線程私有的臨時鍵上,異步線程在遍歷私有髒數據集的同時,其它線程仍然可以向線上髒數據集添加數據。
臨時鍵的生成
在集羣環境中,可能僅支持相同 Slot 下的 RENAME 和 RENAMENX 命令。因此, 我們可以使用 HashKey 機制保證臨時鍵和原鍵在同一個Slot中。
若原鍵爲 "original" 我們則可以生成臨時鍵爲 "{original}-1", 花括號表示僅由花括號內部的子串進行哈希來決定 Slot, "{original}-1" 一定會與 "original" 處於相同 Slot 中。
使用臨時鍵的目的是爲了單線程的進行操作避免併發問題,因此務必檢查臨時鍵是否已被其它線程佔用。
臨時鍵有兩種生成策略:
- 原鍵加隨機值: 如 "{original}-kGi3X1", 這種方法的優點是隨機鍵衝突的概率較小,但是難以掃描庫中有哪些臨時鍵
- 原鍵加計數器: 如 "{original}-1"、"{original}-2", 這種方法的優點是容易掃描庫中的臨時鍵,但是衝突的概率較高。
在檢測臨時鍵不存在後就使用是不安全的,在線程A檢測到臨時鍵可用到實際使用臨時鍵之間,其它線程檢測同一個臨時鍵時也會認爲它可用。
爲了避免臨時鍵衝突,我們可以在使用前先嚐試設置一個佔位符。如,在使用 "{original}-1" 前先執行 "SETNX {original}-1-lock" 若設置成功則可以安全地使用 "{original}-1"。這種做法實際上是加了一個簡單的分佈式鎖。
在更新或重建緩存時應使用加隨機值的方法以儘量減少衝突。在遍歷髒數據時應使用加計數器的方法,我們可以根據計數器來搜索未被釋放的臨時鍵,從而繼續被中斷的遍歷過程。
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 緩存使用經驗,因此一些基本常識放在最後以儘量避免浪費讀者的時間。
- IO操作的耗時通常遠高於CPU計算,儘量使用 MGET 等批量命令或 Pipeline 機制來減少 IO 時間,切勿循環進行 Redis 讀寫等IO操作
- Redis 使用IO複用模型內核單線程模式,保證命令執行原子性和串行性。(至寫作時 Redis 4.0 版本仍是如此,此後很可能引入多線程內核)
- Redis 的RDB和AOF都採用異步持久化的模式,無法保證Redis崩潰後完全不丟失數據。 因此請勿將Redis用於一致性要求較高的業務場景。