redis系列(九)—緩存

redis系列(九)—緩存

前言

大家好,牧碼心今天給大家推薦一篇redis系列(九)—緩存的文章,在實際工作中有很多應用場景,希望對你有所幫助。內容如下:

  • 緩存概述
  • 緩存優劣
  • 緩存更新策略
  • 緩存常見問題

緩存概述

隨着互聯網的普及,內容信息越來越複雜,用戶數和訪問量越來越大,我們的應用需要支撐更多的併發量,同時我們的應用服務器和數據庫服務器所做的計算也越來越多。但是往往我們的應用服務器資源是有限的,數據庫每秒能接受的請求次數也是有限的,如何能夠有效利用有限的資源來提供儘可能大的吞吐量?一個有效的辦法就是引入緩存,那什麼是緩存?

緩存是一個高速數據存儲層,其中存儲了數據子集,且通常是短暫性存儲,這樣日後再次請求該數據時,速度要比訪問數據的主存儲位置快。

爲了更直觀說明,我們看下使用緩存的流程圖:
使用緩存的流程圖

緩存的優劣勢

  • 緩存的優勢

    • 提升應用程序性能:因爲內存比磁盤或 SSD 快幾個數量級,所以從內存中緩存讀取數據非常快(亞毫秒級)。這大大加快了數據訪問速度,從而提升了應用程序的整體性能。
    • 減少後端負載:通過將讀取負載的重要部分從後端數據庫重定向到內存層,緩存可以減少數據庫上的負載,防止其在負載情況下性能降低,甚至可以防止其在高峯期崩潰;
    • 提高讀取吞吐量 (IOPS):相對於同等的基於磁盤的數據庫,除了更低的延遲之外,內存中系統還可以實現更高的請求速度 (IOPS)。用作分佈式端緩存的單個實例每秒可以處理數十萬個請求。
  • 緩存的劣勢

    • 數據不一致性:緩存層和存儲層的數據存在着一定時間窗口的不一致性,時間窗口與更新策略有關;

    • 維護成本:加入緩存後,需要同時處理緩存層和存儲層的邏輯,加大了開發者維護的成本;

  • 緩存的使用場景

    • 開銷大的複雜計算:

    • 加速請求響應:使用redis做緩存,每秒可以完成數萬次讀寫,並且提供的批量操作可以優化整個IO鏈的響應時間。

緩存的更新策略

緩存中的數據通常都是有生命週期的,需要在指定時間後被刪除或更新,這樣可以保證緩存空間在一個可控的範圍。但是緩存中的數據會和數據源中的真實數據有一段時間窗口的不一致,需要利用某些策略進行更新。
在介紹更新策略前,我們先介紹下緩存的幾個概念
緩存命中率
命中率=返回正確結果數/請求緩存次數,命中率越高,表明緩存的使用率越高。
最大元素(或最大空間)
緩存中可以存放的最大元素的數量,一旦緩存中元素數量超過這個值(或者緩存數據所佔空間超過其最大支持空間),那麼將會觸發緩存啓動清空策略根據不同的場景合理的設置最大元素值往往可以一定程度上提高緩存的命中率,從而更有效的時候緩存。
清空策略
設計合適的清空策略可有效提升緩存命中率,常見一般策略有:

  • 先進先出策略(FIFO)
    先進先出策略是最先進入緩存的數據在緩存空間不夠的情況下(超出最大元素限制)會被優先被清除掉,以騰出新的空間接受新的數據。
    策略算法主要比較緩存元素的創建時間。在數據實效性要求場景下可選擇該類策略,優先保障最新數據可用。
  • 最近最少使用策略(LRU)
    最近最少使用策略,無論數據是否過期,根據最後一次被使用的時間戳,清除最遠使用時間戳的元素釋放空間。
    策略算法主要比較元素最近一次被使用時間。在熱點數據場景下較適用,優先保證熱點數據的有效性。
  • 最少使用策略(LFU)
    該策略是無論數據是否過期,根據數據的被使用次數判斷,清除使用次數較少的元素釋放空間。策略算法主要比較數據的命中次數。在保證高頻數據有效性場景下,可選擇這類策略。
    除此之外,還有一些簡單的策略,如:
    • 根據過期時間判斷,清理過期時間最長的元素;
    • 根據過期時間判斷,清理最近要過期的元素;
    • 從數據集中任意選擇數據淘汰;

介紹完緩存更新策略的幾個關鍵概念後,下面分別從使用場景,一致性和維護成本等幾個方面介紹緩存更新策略

LRU/LFU/FIFO算法剔除

  • 使用場景:剔除算法通常用於緩存是用量超過預設的最大值時候,如何對現有的數據進行剔除。例如Redis使用maxmemory-policy這個配置作爲內存最大值後對於數據的剔除策略
  • 一致性:剔除數據由緩存自身算法決定,一致性方面差;
  • 剔除算法不需要再次實現,只需要配置配置最大maxmemory和對應的策略即可,維護簡單;

超時剔除

  • 使用場景:該策略是通過給緩存數據設置過期時間,讓其在過期時間後自動刪除,例如Redis提供的expire命令。如果業務可以容忍一段時間內,緩存層數據和存儲層數據不一致, 那麼可以爲其設置過期時間。 在數據過期後,再從真實數據源獲取數據,重新放到緩存並設置過期時間。例如一個視頻的描述信息,可以容忍幾分鐘內數據不一致,但是涉及交易方面的業務,後果可想而知。
  • 一致性:存在時間窗口內的不一致性問題;
  • 維護成本:維護成本不是很高, 只需設置expire過期時間即可;

主動更新

  • 使用場景:業務需要做到強一致性,需要更新數據庫數據後立即更新緩存,可以利用消息觸發或者回調等方式更新緩存;
  • 一致性:一致性高,但主動更新程序出問題,會存在很長時間無法更新問題,可以結合超時剔除策略使用;
  • 維護成本:維護成本高,需要開發程序,並保證數據的準確性;
    總的來說,上面幾種策略都是圍繞一致性展開的業務場景,在實際使用中要結合具體的業務分析一致性要求的強弱程度來選擇對應的策略。

常見問題

緩存雪崩
緩存雪崩是緩存層崩掉後,所有的併發請求都會達到數據庫,數據庫的短期IO壓力暴增,直至造成數據庫也出現宕機,如圖所示:
緩存雪崩

  • 解決方案:
    (1)、保證緩存層服務的高可用:保證緩存高可用可以用多個節點配置成集羣,做到個別節點宕機後,可以負載到其他可用節點,如採用redis cluster配置
    (2)、後端採用限流組件降級請求:使用類似hystrix的組件做限流&降級,資源的隔離等

緩存穿透
緩存穿透是指大量請求的 key 根本不存在於緩存中,導致請求直接到了數據庫上,根本沒有經過緩存這一層。此現象可能會使後端存儲負載加大,由於很多後端存儲不具備高併發性,甚至可能造成後端存儲宕掉。通常可以在程序中分別統計總調用數、緩存層命中數、存儲層命中數,如果發現大量存儲層空命中,可能就是出現了緩存穿透問題。

產生緩存穿透的原因:

  • 自身業務代碼或者數據出現問題;
  • 一些惡意攻擊、 爬蟲等造成大量空命中;

解決方案:

  • 基本的參數校驗,可以攔截不合規則的請求;
  • 緩存空對象:將緩存和數據庫都查不到某個 key 的數據寫入到緩存中,之後再訪問這個key,可以從緩存中獲取,此方案也存在幾個問題:
    1.空值做了緩存, 意味着緩存層中存了更多的鍵,需要更多的內存空間(如果是攻擊,問題更嚴重);
    2.存在一定時間窗口的數據不一致性問題;

下面代碼實現方式如下:

Object getObjectInclNullById(Integer id) {
    // 從緩存中獲取數據
    Object cacheValue = cache.get(id);
    // 緩存爲空
    if (cacheValue == null) {
        // 從數據庫中獲取
        Object storageValue = storage.get(key);
        // 緩存空對象
        cache.set(key, storageValue);
        // 如果存儲數據爲空,需要設置一個過期時間(300秒)
        if (storageValue == null) {
            // 必須設置過期時間,否則有被攻擊的風險
            cache.expire(key, 60 * 5);
        }
        return storageValue;
    }
    return cacheValue;
}
  • 布隆過濾器
    在訪問緩存層和存儲層之前,將存在的key用布隆過濾器提前保存起來,做第一層攔截。例如:一個推薦系統有4億個用戶id,每個小時算法工程師會根據每個用戶之前歷史行爲計算出推薦數據放到存儲層中,但是最新的用戶由於沒有歷史行爲,就會發生緩存穿透的行爲,爲此可以將所有推薦數據的用戶做成布隆過濾器。 如果布隆過濾器認爲該用戶id不存在,那麼就不會訪問存儲層,在一定程度保護了存儲層。如圖所示:
    布隆過濾器的緩存穿透優化
    此方案適用於數據命中不高、數據相對固定、實時性低(通常是數據集較大)的應用場景,代碼維護較爲複雜,但是緩存空間佔用少。

熱點key併發競爭
開發人員使用“緩存+過期時間”的策略既可以加速數據讀寫,又保證數據的定期更新,這種模式基本能夠滿足絕大部分需求。但是有兩個問題如果同時出現,可能就會對應用造成致命的危害:
1.當前請求的key,併發量很大;
2.重寫此key對應緩存數據無法在短時間內完成,此時在緩存失效期間會造成造成大量線程重新寫緩存,造成後端負載過大。

  • 解決方案:

1.利用分佈式鎖:此方法只允許一個線程重建緩存,其他線程等待重建緩存的線程執行完,重新從緩存獲取數據即可。下面是代碼實現方式:

String get(String key) {
// 從Redis中獲取數據
String value = redis.get(key);
// 如果value爲空, 則開始重構緩存
if (value == null) {
// 只允許一個線程重構緩存, 使用nx, 並設置過期時間ex
String mutexKey = "mutext:key:" + key;
if (redis.set(mutexKey, "1", "ex 180", "nx")) {
// 從數據源獲取數據
value = db.get(key);
// 回寫Redis, 並設置過期時間
redis.setex(key, timeout, value);
// 刪除key_mutex
redis.delete(mutexKey);
}/
/ 其他線程休息50毫秒後重試
else {
Thread.sleep(50);
get(key);
}
}r
eturn value;
}

2.永不過期方式:
所謂永不過期,從緩存層面理解:沒有設置過期時間, 所以不會出現熱點key過期後產生的問題。從邏輯層面理解:爲每個value設置一個邏輯過期時間, 當發現超過邏輯過期時間後, 會使用單獨的線程去構建緩存。此方法有效杜絕了熱點key產生的問題, 但唯一不足的就是重構緩存期間, 會出現數據不一致的情況。

下面將按照這三個維度對上述兩種解決方案進行分析。
1.互斥鎖(mutex key) : 這種方案思路比較簡單, 但是存在一定的隱患,如果構建緩存過程出現問題或者時間較長,可能會存在死鎖和線程池阻塞的風險,但是這種方法能夠較好地降低後端存儲負載,並在一致性上做得比較好。
2.永遠不過期: 這種方案由於沒有設置真正的過期時間,實際上已經不存在熱點key產生的一系列危害,但是會存在數據不一致的情況。

緩存和數據庫一致性
(1) 先寫數據庫,後更新緩存
此方式適合併發低的情況,也是常規方案,但此方式存在問題是更新數據庫後,若遇到緩存宕機,則會出現更新緩存失敗,造成數據不一致。
(2) 先刪除緩存,後更新數據庫
此方式可以避免上述1中寫入redis失敗問題,將緩存刪除可以讓請求去查詢數據庫,但此方式不適合高併發場景。
比如多線程情況下,另一個讀線程優先讀取數據庫數據後更新緩存,會造成緩存和數據庫數據不一致。
(3)直接操作緩存,後定時或消息觸發更新數據庫
此方式是將所有請求全部讀寫緩存,以mysql數據庫作爲備份,然後定期寫入mysql。適合高併發,但這種高併發往往會因爲業務對讀、寫的順序等等可能有不同要求,可能還要藉助消息隊列以及鎖完成針對業務上對數據和順序可能會因爲高併發、多線程帶來的不確定性和不穩定性。

總之,在一個併發量較大的應用,做好緩存設計時應考慮的幾個目標:
第一,加快用戶訪問速度 提高用戶體驗。
第二, 降低後端負載,減少潛在的風險,保證系統平穩。
第三,保證數據“儘可能”及時更新。

參考

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