Redis 理解之緩存設計

緩存能夠有效地加速應用的讀寫速度,同時也可以降低後端負載,對日常應用的開發至關重要。下面會介紹緩存使用技巧和設計方案,包含如下內容:緩存的收益和成本分析、緩存更新策略的選擇和使用場景、緩存粒度控制方法、穿透問題優化、無底洞問題優化、雪崩問題優化、熱點 key 重建優化。

1)緩存的收益和成本分析

下圖左側爲客戶端直接調用存儲層的架構,右側爲比較典型的緩存層 + 存儲層架構。

下面分析一下緩存加入後帶來的收益和成本。

收益:

①加速讀寫:因爲緩存通常都是全內存的,而存儲層通常讀寫性能不夠強悍(例如 MySQL),通過緩存的使用可以有效地加速讀寫,優化用戶體驗。

②降低後端負載:幫助後端減少訪問量和複雜計算(例如很複雜的 SQL 語句),在很大程度降低了後端的負載。

成本:

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

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

③運維成本:以 Redis Cluster 爲例,加入後無形中增加了運維成本。

緩存的使用場景基本包含如下兩種:

①開銷大的複雜計算:以 MySQL 爲例子,一些複雜的操作或者計算(例如大量聯表操作、一些分組計算),如果不加緩存,不但無法滿足高併發量,同時也會給 MySQL 帶來巨大的負擔。

②加速請求響應:即使查詢單條後端數據足夠快(例如select*from table where id=),那麼依然可以使用緩存,以 Redis 爲例子,每秒可以完成數萬次讀寫,並且提供的批量操作可以優化整個 IO 鏈的響應時間。

2)緩存更新策略

緩存中的數據會和數據源中的真實數據有一段時間窗口的不一致,需要利用某些策略進行更新,下面會介紹幾種主要的緩存更新策略。

①LRU/LFU/FIFO 算法剔除:剔除算法通常用於緩存使用量超過了預設的最大值時候,如何對現有的數據進行剔除。例如 Redis 使用 maxmemory-policy 這個配置作爲內存最大值後對於數據的剔除策略。

②超時剔除:通過給緩存數據設置過期時間,讓其在過期時間後自動刪除,例如 Redis 提供的 expire 命令。如果業務可以容忍一段時間內,緩存層數據和存儲層數據不一致,那麼可以爲其設置過期時間。在數據過期後,再從真實數據源獲取數據,重新放到緩存並設置過期時間。例如一個視頻的描述信息,可以容忍幾分鐘內數據不一致,但是涉及交易方面的業務,後果可想而知。

③主動更新:應用方對於數據的一致性要求高,需要在真實數據更新後,立即更新緩存數據。例如可以利用消息系統或者其他方式通知緩存更新。

三種常見更新策略的對比:

有兩個建議:

①低一致性業務建議配置最大內存和淘汰策略的方式使用。

②高一致性業務可以結合使用超時剔除和主動更新,這樣即使主動更新出了問題,也能保證數據過期時間後刪除髒數據。

3)緩存粒度控制

緩存粒度問題是一個容易被忽視的問題,如果使用不當,可能會造成很多無用空間的浪費,網絡帶寬的浪費,代碼通用性較差等情況,需要綜合數據通用性、空間佔用比、代碼維護性三點進行取捨。

緩存比較常用的選型,緩存層選用 Redis,存儲層選用 MySQL。

4)穿透優化

緩存穿透是指查詢一個根本不存在的數據,緩存層和存儲層都不會命中,通常出於容錯的考慮,如果從存儲層查不到數據則不寫入緩存層。

通常可以在程序中分別統計總調用數、緩存層命中數、存儲層命中數,如果發現大量存儲層空命中,可能就是出現了緩存穿透問題。造成緩存穿透的基本原因有兩個。第一,自身業務代碼或者數據出現問題,第二,一些惡意攻擊、爬蟲等造成大量空命中。下面我們來看一下如何解決緩存穿透問題。

①緩存空對象:如圖下所示,當第 2 步存儲層不命中後,仍然將空對象保留到緩存層中,之後再訪問這個數據將會從緩存中獲取,這樣就保護了後端數據源。

緩存空對象會有兩個問題:第一,空值做了緩存,意味着緩存層中存了更多的鍵,需要更多的內存空間(如果是攻擊,問題更嚴重),比較有效的方法是針對這類數據設置一個較短的過期時間,讓其自動剔除。第二,緩存層和存儲層的數據會有一段時間窗口的不一致,可能會對業務有一定影響。例如過期時間設置爲 5 分鐘,如果此時存儲層添加了這個數據,那此段時間就會出現緩存層和存儲層數據的不一致,此時可以利用消息系統或者其他方式清除掉緩存層中的空對象。

②布隆過濾器攔截

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

緩存空對象和布隆過濾器方案對比

另:布隆過濾器簡單說明:

如果想判斷一個元素是不是在一個集合裏,一般想到的是將集合中所有元素保存起來,然後通過比較確定。鏈表、樹、散列表(又叫哈希表,Hash table)等等數據結構都是這種思路。但是隨着集合中元素的增加,我們需要的存儲空間越來越大。同時檢索速度也越來越慢。

Bloom Filter 是一種空間效率很高的隨機數據結構,Bloom filter 可以看做是對 bit-map 的擴展, 它的原理是:

當一個元素被加入集合時,通過 K 個 Hash 函數將這個元素映射成一個位陣列(Bit array)中的 K 個點,把它們置爲 1。檢索時,我們只要看看這些點是不是都是 1 就(大約)知道集合中有沒有它了:

如果這些點有任何一個 0,則被檢索元素一定不在;如果都是 1,則被檢索元素很可能在。

5)無底洞優化

爲了滿足業務需要可能會添加大量新的緩存節點,但是發現性能不但沒有好轉反而下降了。用一句通俗的話解釋就是,更多的節點不代表更高的性能,所謂 “無底洞” 就是說投入越多不一定產出越多。但是分佈式又是不可以避免的,因爲訪問量和數據量越來越大,一個節點根本抗不住,所以如何高效地在分佈式緩存中批量操作是一個難點。

無底洞問題分析:

①客戶端一次批量操作會涉及多次網絡操作,也就意味着批量操作會隨着節點的增多,耗時會不斷增大。

②網絡連接數變多,對節點的性能也有一定影響。

如何在分佈式條件下優化批量操作?我們來看一下常見的 IO 優化思路:

這裏我們假設命令、客戶端連接已經爲最優,重點討論減少網絡操作次數。下面我們將結合 Redis Cluster 的一些特性對四種分佈式的批量操作方式進行說明。

①串行命令:由於 n 個 key 是比較均勻地分佈在 Redis Cluster 的各個節點上,因此無法使用 mget 命令一次性獲取,所以通常來講要獲取 n 個 key 的值,最簡單的方法就是逐次執行 n 個 get 命令,這種操作時間複雜度較高,它的操作時間 = n 次網絡時間 + n 次命令時間,網絡次數是 n。很顯然這種方案不是最優的,但是實現起來比較簡單。

②串行 IO:Redis Cluster 使用 CRC16 算法計算出散列值,再取對 16383 的餘數就可以算出 slot 值,同時 Smart 客戶端會保存 slot 和節點的對應關係,有了這兩個數據就可以將屬於同一個節點的 key 進行歸檔,得到每個節點的 key 子列表,之後對每個節點執行 mget 或者 Pipeline 操作,它的操作時間 = node 次網絡時間 + n 次命令時間,網絡次數是 node 的個數,整個過程如下圖所示,很明顯這種方案比第一種要好很多,但是如果節點數太多,還是有一定的性能問題。

③並行 IO:此方案是將方案 2 中的最後一步改爲多線程執行,網絡次數雖然還是節點個數,但由於使用多線程網絡時間變爲O(1),這種方案會增加編程的複雜度。

④hash_tag 實現:Redis Cluster 的 hash_tag 功能,它可以將多個 key 強制分配到一個節點上,它的操作時間 = 1 次網絡時間 + n 次命令時間。

四種批量操作解決方案對比

6)雪崩優化

緩存雪崩:由於緩存層承載着大量請求,有效地保護了存儲層,但是如果緩存層由於某些原因不能提供服務,於是所有的請求都會達到存儲層,存儲層的調用量會暴增,造成存儲層也會級聯宕機的情況。

預防和解決緩存雪崩問題,可以從以下三個方面進行着手:

①保證緩存層服務高可用性。如果緩存層設計成高可用的,即使個別節點、個別機器、甚至是機房宕掉,依然可以提供服務,例如前面介紹過的 Redis Sentinel 和 Redis Cluster 都實現了高可用。

②依賴隔離組件爲後端限流並降級。在實際項目中,我們需要對重要的資源(例如 Redis、MySQL、HBase、外部接口)都進行隔離,讓每種資源都單獨運行在自己的線程池中,即使個別資源出現了問題,對其他服務沒有影響。但是線程池如何管理,比如如何關閉資源池、開啓資源池、資源池閥值管理,這些做起來還是相當複雜的。

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

7)熱點 key 重建優化

開發人員使用 “緩存 + 過期時間” 的策略既可以加速數據讀寫,又保證數據的定期更新,這種模式基本能夠滿足絕大部分需求。但是有兩個問題如果同時出現,可能就會對應用造成致命的危害:

要解決這個問題也不是很複雜,但是不能爲了解決這個問題給系統帶來更多的麻煩,所以需要制定如下目標:

①互斥鎖: 此方法只允許一個線程重建緩存,其他線程等待重建緩存的線程執行完,重新從緩存獲取數據即可,整個過程如圖所示。

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

1)從 Redis 獲取數據,如果值不爲空,則直接返回值;否則執行下面的 2.1)和 2.2)步驟。

2.1)如果 set(nx 和 ex)結果爲 true,說明此時沒有其他線程重建緩存,那麼當前線程執行緩存構建邏輯。

2.2)如果 set(nx 和 ex)結果爲 false,說明此時已經有其他線程正在執行構建緩存的工作,那麼當前線程將休息指定時間(例如這裏是 50 毫秒,取決於構建緩存的速度)後,重新執行函數,直到獲取到數據。

②永遠不過期

“永遠不過期” 包含兩層意思:

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

兩種熱點 key 的解決方法

作者:不行就改名

來源:blog.csdn.net/ym123456677/article/details/80063491

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/_tsP85zdNm_NF5VE9_GZbA