1.緩存的受益和成本
1.1 受益
1.可以加速讀寫:Redis是基於內存的數據源,通過緩存加速數據讀取速度
2.降低后端負載:后端服務器通過前端緩存降低負載,業務端使用Redis降低后端數據源的負載等
1.2 成本
1.數據不一致:后端數據源中的數據緩存到Redis,如果后端數據庫中的數據被更新時,根據更新策略不同,Redis緩存層中的數據和數據源的數據有時間窗口不一致
2.代碼維護成本:多了一層緩存邏輯,以前只需要讀取后端數據庫,現在還需要維護緩存的讀寫以及Redis與數據庫的連接等
3.運維成本:例如Redis Cluster
1.3 使用場景
1.降低后端負載:對高消耗的SQL,例如做排行榜的計算涉及到很多張數據表上數據的很復雜的實時計算,這種計算實際上沒有任何意義,
如果使用Redis緩存,只需要第一次把計算結果寫入到Redis緩存中,后續的計算直接在Redis中就可以了,join結果集/分組統計結果進行緩存
2.加速請求響應:由於Redis中的數據是保存在內存中的,利用Redis可以顯著的提高IO響應時間
3.大量寫請求合並為批量寫:如計數器先使用Redis進行累加,最后把結果批量寫入到后端數據庫中,而不用每次都更新到后端數據庫,有效降低后端數據庫的負載
2.緩存的更新策略
緩存中的數據有生命周期,需要定期更新和刪除,保證內存空間的合理使用以及緩存數據的一致,緩存數據需要根據合理的數據更新策略更新緩存中的數據
- LRU/LFU/FIFO算法剔除:Redis使用
maxmemory-policy
,即Redis中的數據占用的內存超過設定的最大內存時的操作策略 - 超時剔除:對緩存的數據設置過期時間,超過過期時間自動刪除緩存數據,然后再次進行緩存,保證與數據庫中的數據一致
- 主動更新:開發者控制key的更新周期,當key在后端數據庫中發生更新時,向Redis主動發送消息,Redis接收到消息對key進行更新或刪除
Redis的配置文件中定義了下面的緩存更新策略
volatile-lru -> remove the key with an expire set using an LRU algorithm # 根據LRU算法刪除過期的key
allkeys-lru -> remove any key according to the LRU algorithm # 根據LRU算法刪除一些key
volatile-random -> remove a random key with an expire set # 隨機刪除一些設置了過期時間的key
allkeys-random -> remove a random key, any key # 從所有的key中隨機刪除一些key
volatile-ttl -> remove the key with the nearest expire time (minor TTL) # 刪除一些快過期的key
noeviction -> don't expire at all, just return an error on write operations # 不刪除任何key,在向Redis寫入key時返回一個錯誤,這將會占用更多的內存
需要注意的是:with any of the above policies, Redis will return an error on write operations, when there are no suitable keys for eviction。即在上面的六種策略中,如果沒有key可以被刪除時,向Redis中寫入數據會返回一個error異常
LRU和最小TTL算法並不是精確的算法
,而是近似的算法(為了節省內存)。因此可以根據速度或准確性對其進行優化設置,使用maxmemory-samples
選項來設置這個值
默認情況下,maxmemory-samples
的值設置為5,即Redis將檢查5個鍵並選擇使用最少的一個key,如果設置為10,非常接近真實的LRU算法,但是另外消耗一些的CPU。如果設置為3則會加快Redis,但執行結果不夠准確。
緩存更新策略對比
2.1 對於緩存的建議
- 對數據一致性要求不高,即真實數據和緩存數據差別較大對業務影響不大情況下,可以采用最大內存和淘汰策略,內存使用量超過
maxmemory-policy
時,自動刪除數據,而不會影響業務 - 對數據一致性要求較高,即真實數據和緩存數據差別較大會影響業務情況下,可以采用超時剔除和主動更新結合策略,由最大內存和淘汰策略兜底。如果主動更新的功能出現問題失效,沒有把一些不必要的數據刪除時,Redis占用的內存會越來越多,此時可以給一些有生命周期的key設置比較長的過期時間,然后設置
maxmemory
和maxmemory-policy
,來保證Redis占用的內存超過設置的最大內存時刪除一些過期的key,來保證Redis的高可用
3.緩存粒度控制
上圖中,使用Redis來做緩存,底層使用MySQL來做數據存儲源,這種架構下大部分請求由Redis處理,少部分請求到達MySQL。
從MySQL中獲取一個用戶的所有信息,然后緩存到Redis的數據結構中。
此時需要面對一個問題:緩存這個用戶的所有數據信息,還是緩存用戶需要的用戶信息字段。
可以從三個角度來考慮:
3.1 通用性
從通用性角度考慮,緩存全量屬性更好。
當用戶數據表字段發生改變時,不需要修改程序就可以直接同步修改之后的用戶信息到Redis緩存中供用戶使用,但是用占用更多的內存空間
3.2 占用空間
從占用空間的角度考慮,緩存部分屬性更好.
同樣當用戶數據表字段發生改變時而用戶需要這個字段信息時,就需要修改程序源代碼來把修改之后的用戶信息同步緩存到Redis中,這種情況下占用的內存空間比全量屬性占用的內存空間要少
3.3 代碼維護
從代碼維護角度考慮,表面上全量屬性更好。
不管數據源中的數據表結構如何改變,都會把所有的數據同步到Redis緩存中,而不需要修改程序源代碼,但是在大多數情況下,不會使用到全量數據,只需要緩存需要的數據就可以了,從內存空間消耗及性能方面考慮,使用部分屬性更好
3.4 總結
選擇緩存屬性時,需要綜合考慮緩存全量屬性還是部分屬性
4.緩存穿透優化
4.1 什么叫緩存穿透
正常情況下,客戶端從緩存中獲取數據,如果緩存中沒有用戶請求需要的數據,就會讀取數據源中的數據返回給客戶端,同時把數據回寫到緩存中。這樣當下次客戶端再請求這個數據時,就可以直接從緩存中獲取數據而不需要經過數據庫了。
如果客戶端獲取一個數據源中沒有的key時,先從緩存中獲取,獲取結果為null,然后到數據源中獲取,同樣獲取結果為null,這樣所有的請求都會到達數據源,這就是緩存穿透的基本過程
緩存的存在就是為了保護數據源,緩存穿透之后會對數據源造成巨大的負載和壓力,這就失去了緩存的意義。
4.2 緩存穿透的原因
業務程序自身的問題:如無法對緩存進行回寫等邏輯bug
惡意攻擊,爬蟲等
4.3 緩存穿透的發現
根據業務的響應時間來進行判斷,當業務的響應時間遠遠過正常情況下的響應時間時,很有可能就是緩存穿透造成的
可以通過監控一些指標:總調用數,緩存層命中數,存儲層命中數等發現緩存穿透
4.4 緩存穿透解決辦法
4.4.1 緩存空對象
緩存空對象是一種簡單粗暴的解決方法
當數據源中沒有用戶請求需要的數據時,會請求數據源,之前的做法是數據源返回一個null,而緩存中並不做回寫,緩存空對象的做法就是把null回寫到緩存中,暫時解決緩存穿透帶來的壓力
緩存空對象會造成兩個問題
1.如果是惡意攻擊和爬蟲等,如果每次請求的數據都不一致,緩存空對象時會在緩存中設置很多的key,即使這些key的值都為空值,也會占用很多的內存空間,此時可以為這個key設置過期時間來降低這樣的風險
2.緩存空對象並設置過期時間,在這個時間內即使數據源恢復正常,請求得到的結果仍然是null,造成緩存層和存儲層數據短期不一致。這種情況下,可以通過訂閱發布消息來解決,當數據源恢復正常時,會發布消息,然后把正常數據緩存到Redis中
4.4.2 布隆過濾器攔截
使用布隆過濾器可以通過占用很小的內存來對數據進行過濾
布隆過濾器攔截是把所有的key或者離散數據保存到布隆過濾器中,然后使用布隆過濾器在緩存層之前再做一層攔截。
如果請求沒有被布隆過濾器攔截,則會到達緩存層獲取需要的數據並返回,以達到實際效果
布隆過濾器對於固定的數據可以起到很好的效果,但是對於頻繁更新的數據,布隆過濾器的構建會面臨很多問題
4.4.3 緩存穿透解決辦法對比
1.緩存空對象代碼層面比較簡單,但是需要一些額外的內存空間來保存空對象,而且會有短時間內的數據不一致性
2.布隆過濾器需要特殊的使用場景,布隆過濾器需要維護一些單獨的代碼,而且布隆過濾器也會占用額外的很少的內存空間來實現數據的過濾
5.無底洞問題優化
5.1 無底洞問題描述
2010年,Facebook已經有了3000個Memcache節點,Facebook發現問題:"加"Memcache節點,客戶端批量操作的效率不僅沒有提升,反而下降,這就是一個無底洞問題
5.2 無底洞問題關鍵點
當只有一個節點時,執行一次mget只產生一次網絡IO;而當節點增加到3個時,使用順序IO方式執行一次mget就會產生三次網絡IO
同理,當節點越來越多,執行一次mget所需要的網絡時間也越來越多,會對客戶端的執行效率帶來很大的下降
實際上網絡IO由於擴容已經由原來的O(1)變成O(node)了,節點越多,並行執行一次mget命令所需要的時間就越長,如果串行執行mget命令所需要的時間就更多了。
無底洞問題關鍵點即:
- 更多的機器 != 更多的性能
- 批量接口需求(mget和mset等):在執行mget和mset等命令時會面對的問題
- 數據增長與水平擴展需求等:隨着業務量越來越大,對於緩存和數據源存儲的需求也是越來越大,就需要對緩存和數據源進行擴容,即增加緩存節點和數據源節點,但是節點數量增多並不能帶來性能的提升,這是一個矛盾的問題
5.3 優化IO的方法
- 優化命令本身:例如執行慢查詢keys,hgetall bigkey等命令時,盡量選擇在緩存節點壓力不大時執行
- 減少網絡通信次數,例如執行mget命令由原來的O(n)次網絡時間縮減為O(node)次網絡時間,
- 降低接入成本:例如客戶端長連接/連接池,NIO等
5.4 四種批量優化方法:
5.4.1 串行mget
串行mget需要n次網絡時間
5.4.2 串行IO
由於客戶端對key進行重新組裝,所以把網絡通信時間降低到節點次O(node)
5.4.3 並行IO
並行IO也會在客戶端對key進行重新組裝,然后執行並行操作,所需要的網絡時間為O(1)
5.4.4 hash_tag
hash_tag會把所有的key都分配到一個節點,但是使用這種方法會遇到各種問題
5.5 四種優化方案的優缺點分析
6.執行key重建優化
6.1 緩存重建過程描述
在正常情況下,客戶端發送請求,會先到緩存,從緩存中獲取需要的數據,如果緩存中並沒有需要數據,才會繼續向數據源請求,從數據源中獲取數據返回給客戶端並回寫到緩存中,這就是緩存的重建過程
6.2 緩存重建問題描述
如果重建的是一個熱點key,用戶訪問量非常大。很多用戶發送請求獲取數據,執行線程從緩存中獲取數據,但是此時緩存中並有這些數據,就會從數據源中獲取數據,然后重建緩存。
當緩存重建完成,后續的訪問才會直接讀取緩存數制並返回
在這個過程中,會有很多線程同時查詢並重建緩存key,一方面會對數據源造成很大壓力,另一方面也會加大響應的時間
6.3 解決緩存重建的目標
減少緩存重建次數:不要多次重建緩存
數據盡可能一致:緩存中的數據要盡可能與數據源中的數據保持一致
減少潛在風險:可能造成死鎖或者線程池大量被夯住等情況
6.4 緩存重建解決方法
6.4.1 互斥鎖(mutex key)
互斥鎖是一種比較直觀和簡單的解決思路
第一個用戶從緩存中獲取數據,此時緩存中並沒有用戶需要的數據,會從數據源中重建緩存,
用戶在從數據源查詢獲取數據和重建緩存的過程中加上一把鎖,當重建緩存完成以后再把鎖解開
,並返回
當第二個用戶也想從緩存中獲取數據時,如果第一個用戶重建緩存的過程還沒有結束,即鎖還沒有被解開時,就會等待,同樣后續訪問的用戶也經過這樣一個過程
當緩存重建完成,鎖被解開,所有的用戶請求都從緩存中獲取數據並輸出
互斥鎖解決了緩存大量重建的過程,但是在緩存重建的過程中會有一個等待時間,大量線程被夯住,有可能造成死鎖的情況
6.4.2 數據永不過期
在緩存層面,每一個key都不設置過期時間(沒有設置expire)
在功能層面中,為每個value添加邏輯過期時間,一旦發現超過邏輯過期時間后,會使用單獨的線程去構建緩存
需要注意的是
數據永不過期是一個異步的過程,即使緩存重建失敗,也不會造成線程夯住的問題
數據永不過期基本杜絕了熱點key的重建問題。
數據永不過期好處是:相比於使用互斥鎖的方案,不會使用戶產生一個等待的時間,而且可以保證只有一個線程來完成數據源的查詢和緩存的重建
數據永不過期的缺點:在緩存重建完成之前,用戶從緩存中得到的原來的數據有可能與從數據源中的新數據不一致的情況
數據永不過期中設置邏輯過期時間,會為每一個key設置過期時間,會增加維護成本,占用更多的內存空間。