深入理解緩存之緩存存在的問題及應對措施

1.緩存穿透

指查詢一個一定不存在的數據,由於緩存是不命中時被動寫( 被動寫,指的是從 DB 查詢到數據,則更新到緩存中 )的,並且處於容錯考慮,如果從 DB 查不到數據則不寫入緩存,這將導致這個不存在的數據每次請求都要到 DB 去查詢,失去了緩存的意義。

緩存穿透問題可能會使後端存儲負載加大,由於很多後端存儲不具備高併發性,甚至可能造成後端存儲宕掉。通常可以在程序中分別統計總調用數、緩存層命中數、存儲層命中數,如果發現大量存儲層空命中,可能就是出現了緩存穿透問題。

參照:Redis架構之防雪崩設計:網站不宕機背後的兵法

在流量大時,可能 DB 就掛掉了,要是有人利用不存在的 key 頻繁攻擊我們的應用,這就是漏洞。如下圖:

有兩種方案可以解決:

  • 方案一,緩存空對象:當從 DB 查詢數據爲空,我們仍然將這個空結果進行緩存,具體的值需要使用特殊的標識,能和真正緩存的數據區分開。另外,需要設置較短的過期時間,一般建議不要超過 5 分鐘。

    緩存空對象會有兩個問題:

    第一,空值做了緩存,意味着緩存層中存了更多的鍵,需要更多的內存空間 ( 如果是攻擊,問題更嚴重 ),比較有效的方法是針對這類數據設置一個較短的過期時間,讓其自動剔除。

    第二,緩存層和存儲層的數據會有一段時間窗口的不一致,可能會對業務有一定影響。例如過期時間設置爲 5 分鐘,如果此時存儲層添加了這個數據,那此段時間就會出現緩存層和存儲層數據的不一致,此時可以利用消息系統或者其他方式清除掉緩存層中的空對象。

    僞代碼:

  • 方案二,BloomFilter 布隆過濾器:在緩存服務的基礎上,構建 BloomFilter 數據結構,在 BloomFilter 中存儲對應的 KEY 是否存在,如果存在,說明該 KEY 對應的值爲空。那麼整個邏輯的如下:

    • 1、根據 KEY 查詢緩存。如果存在對應的值,直接返回;如果不存在,繼續向下執行。

    • 2、根據 KEY 查詢在緩存 BloomFilter 的值。如果存在值,說明該 KEY 不存在對應的值,直接返回空;如果不存在值,繼續向下執行。

    • 3、查詢 DB 對應的值,如果存在,則更新到緩存,並返回該值。如果不存在值,更新到 緩存 BloomFilter 中,並返回空。

    如下圖所示,在訪問緩存層和存儲層之前,將存在的 key 用布隆過濾器提前保存起來,做第一層攔截。例如: 一個個性化推薦系統有 4 億個用戶 ID,每個小時算法工程師會根據每個用戶之前歷史行爲做出來的個性化放到存儲層中,但是最新的用戶由於沒有歷史行爲,就會發生緩存穿透的行爲,爲此可以將所有有個性化推薦數據的用戶做成布隆過濾器。如果布隆過濾器認爲該用戶 ID 不存在,那麼就不會訪問存儲層,在一定程度保護了存儲層。

    可以利用 Redis 的 Bitmaps 實現布隆過濾器,GitHub 上已經開源了類似的方案,可以進行參考:

    https://github.com/erikdubbelboer/Redis-Lua-scaling-bloom-filter

    這種方法適用於數據命中不高,數據相對固定實時性低(通常是數據集較大)的應用場景,代碼維護較爲複雜,但是緩存空間佔用少。

本質上布隆過濾器是一種數據結構,比較巧妙的概率型數據結構(probabilistic data structure),特點是高效地插入和查詢,可以用來告訴你 “某樣東西一定不存在或者可能存在”。

布隆過濾器是一個 bit 向量或者說 bit 數組,長這樣:

對於每個key,只需要k個比特位,每個存儲一個標誌,用來判斷key是否在集合中。

 

  1. 首先需要k個hash函數,每個函數可以把key散列成爲1個整數

  2. 初始化時,需要一個長度爲n比特的數組,每個比特位初始化爲0

  3. 某個key加入集合時,用k個hash函數計算出k個散列值,並把數組中對應的比特位置爲1

  4. 判斷某個key是否在集合時,用k個hash函數計算出k個散列值,並查詢數組中對應的比特位,如果所有的比特位都是1,認爲在集合中。

參照:https://www.jianshu.com/p/2104d11ee0a2

https://www.cnblogs.com/liyulong1982/p/6013002.html

兩者比較:

  緩存空對象 BloomFilter 布隆過濾器
適用場景 1、數據命中不高 2、保證一致性 1、數據命中不高 2、數據相對固定、實時性低
維護成本 1、代碼維護簡單 2、需要過多的緩存空間 3、數據不一致 1、代碼維護複雜 2、緩存空間佔用小

2.緩存雪崩

緩存雪崩,是指緩存由於某些原因無法提供服務( 例如,緩存掛掉了 ),所有請求全部達到 DB 中,導致 DB 負荷大增,最終掛掉的情況。

從下圖可以很清晰出什麼是緩存雪崩:由於緩存層承載着大量請求,有效的保護了存儲層,但是如果緩存層由於某些原因整體不能提供服務,於是所有的請求都會達到存儲層,存儲層的調用量會暴增,造成存儲層也會掛掉的情況。 緩存雪崩的英文原意是 stampeding herd(奔逃的野牛),指的是緩存層宕掉後,流量會像奔逃的野牛一樣,打向後端存儲。

解決方案

1)緩存高可用

通過搭建緩存的高可用,避免緩存掛掉導致無法提供服務的情況,從而降低出現緩存雪崩的情況。

假設我們使用 Redis 作爲緩存,則可以使用 Redis Sentinel 或 Redis Cluster 實現高可用。

2)本地緩存

如果使用本地緩存時,即使分佈式緩存掛了,也可以將 DB 查詢到的結果緩存到本地,避免後續請求全部到達 DB 中。當然,引入本地緩存也會有相應的問題,例如說:

  • 本地緩存的實時性怎麼保證?

    • 方案一,可以引入消息隊列。在數據更新時,發佈數據更新的消息;而進程中有相應的消費者消費該消息,從而更新本地緩存。

      也可以使用 Redis Pub / Sub 取代消息隊列來實現,但此時 Redis 可能已經掛了,所以也不一定合適。

    • 方案二,設置較短的過期時間,請求時從 DB 重新拉取。

    • 方案三,手動設置Redis過期時間。

  • 每個進程可能會本地緩存相同的數據,導致數據浪費?

    • 方案一,需要配置本地緩存的過期策略和緩存數量上限。

如果我們使用 JVM ,則可以使用 Ehcache、Guava Cache 實現本地緩存的功能。

3)請求 DB 限流

通過限制 DB 的每秒請求數,避免把 DB 也打掛了。這樣至少能有兩個好處:

  1. 可能有一部分用戶,還可以使用,系統還沒死透。

  2. 未來緩存服務恢復後,系統立即就已經恢復,無需在處理 DB 也掛掉的情況。

如果我們使用 Java ,則可以使用 Guava RateLimiter、Sentinel 實現限流的功能。

4)服務降級

如果請求被限流,或者請求 DB 超時,我們可以服務降級,提供一些默認的值,或者友情提示,甚至空白的值也行。

如果我們使用 Java ,則可以使用 Hystrix、Sentinel 實現限流的功能。

5)提前演練

在項目上線前,演練緩存宕掉後,應用以及後端的負載情況以及可能出現的問題,在此基礎上做一些預案設定。

3.緩存擊穿

緩存擊穿,是指某個極度“熱點”數據在某個時間點過期時,恰好在這個時間點對這個 KEY 有大量的併發請求過來,這些請求發現緩存過期一般都會從 DB 加載數據並回設到緩存,但是這個時候大併發的請求可能會瞬間 DB 壓垮。

  • 對於一些設置了過期時間的 KEY ,如果這些 KEY 可能會在某些時間點被超高併發地訪問,是一種非常“熱點”的數據。這個時候,需要考慮這個問題。

    重建緩存不能在短時間完成,可能是一個複雜計算,例如複雜的 SQL、多次 IO、多個依賴等。

    在緩存失效的瞬間,有大量線程來重建緩存 ( 如下圖),造成後端負載加大,甚至可能會讓應用崩潰。

  • 緩存被“擊穿”的問題,和緩存“雪崩“”的區別在於,前者針對某一 KEY 緩存,後者則是很多 KEY 。

  • 緩存被“擊穿”的問題,和緩存“穿透“”的區別在於,這個 KEY 是真實存在對應的值的。

解決方案

  • 方案一,使用互斥鎖:請求發現緩存不存在後,去查詢 DB 前,使用分佈式鎖,保證有且只有一個線程去查詢 DB ,並更新到緩存。流程如下:

    • 1、獲取分佈式鎖,直到成功或超時。如果超時,則拋出異常,返回。如果成功,繼續向下執行。

    • 2、再去緩存中。如果存在值,則直接返回;如果不存在,則繼續往下執行。因爲,獲得到鎖,可能已經被“那個”線程去查詢過 DB ,並更新到緩存中了。

    • 3、查詢 DB ,並更新到緩存中,返回值。

    下面代碼使用 Redis 的 setnx 命令實現上述功能。

  • 方案二,手動過期:緩存上從不設置過期時間,功能上將過期時間存在 KEY 對應的 VALUE 裏,如果發現要過期,通過一個後臺的異步線程進行緩存的構建,也就是“手動”過期。通過後臺的異步線程,保證有且只有一個線程去查詢 DB。

    整個過程如下圖所示:

    從實戰看,此方法有效杜絕了熱點 key 產生的問題,但唯一不足的就是重構緩存期間,會出現數據不一致的情況,這取決於應用方是否容忍這種不一致。下面代碼使用 Redis 進行模擬:

作爲一個併發量較大的應用,在使用緩存時有三個目標:第一,加快用戶訪問速度,提高用戶體驗。第二,降低後端負載,減少潛在的風險,保證系統平穩。第三,保證數據“儘可能”及時更新。下面將按照這三個維度對上述兩種解決方案進行分析。

  • 互斥鎖 (mutex key):這種方案思路比較簡單,但是存在一定的隱患,如果構建緩存過程出現問題或者時間較長,可能會存在死鎖和線程池阻塞的風險,但是這種方法能夠較好的降低後端存儲負載並在一致性上做的比較好。

  • " 永遠不過期 ":這種方案由於沒有設置真正的過期時間,實際上已經不存在熱點 key 產生的一系列危害,但是會存在數據不一致的情況,同時代碼複雜度會增大。

這兩個方案,各有其優缺點。

  使用互斥鎖 手動過期
優點 1、思路簡單 2、保證一致性 1、性價最佳,用戶無需等待
缺點 1、代碼複雜度增大 2、存在死鎖的風險 1、無法保證緩存一致性

 

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章