【Redis —進階】緩存設計
作者:與昊
來源:SegmentFault 思否社區
緩存的收益和成本
收益
-
加速讀寫:因爲緩存通常都是全內存的(例如 Redis、Memcache),而存儲層通常讀寫性能不夠強悍(例如 MySQL),通過緩存的使用可以有效地加速讀寫,優化用戶體驗。
-
降低後端負載:幫助後端減少訪問量和複雜計算(例如很複雜的 SQL 語句),在很大程度降低了後端的負載。
成本
-
數據不一致性:緩存層和存儲層的數據存在着一定時間窗口的不一致性,時間窗口跟更新策略有關。
-
代碼維護成本:加入緩存後,需要同時處理緩存層和存儲層的邏輯,增大了開發者維護代碼的成本。
-
運維成本:以 Redis Cluster 爲例,加入後無形中增加了運維成本。
緩存的使用場景基本包含如下兩種:
-
開銷大的複雜計算:以 MySQL 爲例,一些複雜的操作或者計算(例如大量聯表操作、一些分組計算),如果不加緩存,不但無法滿足高併發量,同時也會給 MySQL 帶來巨大的負擔。
-
加速請求響應:即使查詢單條後端數據足夠快,那麼依然可以使用緩存,以 Redis 爲例,每秒可以完成數萬次讀寫,並且提供的批量操作可以優化整個 IO 鏈的響應時間。
緩存更新策略
LRU/LFU/FIFO 算法剔除
使用場景:通常用於緩存使用量超過了預設的最大值時候,如何對現有的數據進行剔除。例如 Redis 使用 maxmemory-policy 這個配置作爲內存最大值後對於數據的剔除策略。
一致性:要清理哪些數據是由具體算法決定,開發人員只能決定使用哪種算法,所以數據的一致性是最差的。
維護成本:算法不需要開發人員自己來實現,通常只需要配置最大 maxmemory 和對應的策略即可。開發人員只需要知道每種算法的含義,選擇適合自己的算法即可。
超時剔除
使用場景:超時剔除通過給緩存數據設置過期時間,讓其在過期時間後自動刪除,例如 Redis 提供的 expire 命令。如果業務可以容忍一段時間內,緩存層數據和存儲層數據不一致,那麼可以爲其設置過期時間。在數據過期後,再從真實數據源獲取數據,重新放到緩存並設置過期時間。
一致性:一段時間窗口內存在一致性問題,即緩存數據非和真實數據源的數據不一致。
維護成本:維護成本不是很高,只需設置 expire 過期時間即可,當然前提是應用方允許這段時間可能發生的數據不一致。
主動更新
使用場景:應用方對於數據的一致性要求高,需要在真實數據更新後,立即更新緩存數據。例如可以利用消息系統或者其他方式通知緩存更新。
一致性:一致性最高,但如果主動更新發生了問題,那麼這條數據很可能很長時間不會更新,所以建議結合超時剔除一起使用效果會更好。
維護成本:維護成本會比較高,開發者需要自己來完成更新,並保證更新操作的正確性。
最佳實踐
低一致性業務建議配置最大內存和淘汰策略的方式使用。高一致性業務可以結合使用超時剔除和主動更新,這樣即使主動更新出了問題,也能保證數據過期時間後刪除髒數據。
緩存粒度控制
例如現在需要將 MySQL 的用戶信息使用 Redis 緩存,假設用戶表有 100 個列,需要緩存到什麼維度呢?這個問題就是緩存粒度問題,究竟是緩存全部屬性還是隻緩存部分重要屬性?下面將從通用性、空間佔用、代碼維護三個角度進行說明。
通用性:緩存全部數據比部分數據更加通用,但從實際經驗看,很長時間內應用只需要幾個重要的屬性。
空間佔用:緩存全部數據要比部分數據佔用更多的空間,可能存在以下問題:
-
全部數據會造成內存的浪費。
-
全部數據可能每次傳輸產生的網絡流量會比較大,耗時相對較大,在極端情況下會阻塞網絡。
-
全部數據的序列化和反序列化的 CPU 開銷更大。
代碼維護:全部數據的優勢更加明顯,而部分數據一旦要加新字段需要修改業務代碼,而且修改後通常還需要刷新緩存數據。
緩存粒度問題是一個容易被忽視的問題,如果使用不當,可能會造成很多無用空間的浪費,網絡帶寬的浪費,代碼通用性較差等情況,需要綜合數據通用性、空間佔用比、代碼維護性三點進行取捨。
緩存穿透
緩存穿透是指查詢一個根本不存在的數據,緩存層和存儲層都不會命中,通常出於容錯的考慮,如果從存儲層查不到數據則不寫入緩存層。緩存穿透將導致不存在的數據每次請求都要到存儲層去查詢,失去了緩存保護後端存儲的意義。
緩存穿透問題可能會使後端存儲負載加大,由於很多後端存儲不具備高併發性,甚至可能造成後端存儲宕掉。通常可以在程序中分別統計總調用數、緩存層命中數、存儲層命中數,如果發現大量存儲層空命中,可能就是出現了緩存穿透問題。
造成緩存穿透的基本原因有兩個。第一,自身業務代碼或者數據出現問題,第二,一些惡意攻擊、爬蟲等造成大量空命中。下面我們來看一下如何解決緩存穿透問題。
緩存空對象
當存儲層不命中後,仍然將空對象保留到緩存層中,之後再訪問這個數據將會從緩存中獲取,這樣就保護了後端數據源。
緩存空對象會有兩個問題:
-
空值做了緩存,意味着緩存層中存了更多的鍵,需要更多的內存空間(如果是攻擊,問題更嚴重),比較有效的方法是針對這類數據設置一個較短的過期時間,讓其自動剔除。
-
緩存層和存儲層的數據會有一段時間窗口的不一致,可能會對業務有一定影響。例如過期時間設置爲 5 分鐘,如果此時存儲層添加了這個數據,那此段時間就會出現緩存層和存儲層數據的不一致,此時可以利用消息系統或者其他方式清除掉緩存層中的空對象。
布隆過濾器攔截
在訪問緩存層和存儲層之前,將存在的 key 用布隆過濾器提前保存起來,做第一層攔截。例如:一個推薦系統有 4 億個用戶 id,每個小時算法工程師會根據每個用戶之前歷史行爲計算出推薦數據放到存儲層中,但是最新的用戶由於沒有歷史行爲,就會發生緩存穿透的行爲,爲此可以將所有推薦數據的用戶做成布隆過濾器。如果布隆過濾器認爲該用戶 id 不存在,那麼就不會訪問存儲層,在一定程度保護了存儲層。
這種方法適用於數據命中不高、數據相對固定、實時性低(通常是數據集較大)的應用場景,代碼維護較爲複雜,但是緩存空間佔用少。
緩存空對象和布隆過濾器方案對比
緩存無底洞
2010 年,Facebook 的 Memcache 節點已經達到了 3000 個,承載着 TB 級別的緩存數據。但開發和運維人員發現了一個問題,爲了滿足業務要求添加了大量新 Memcache 節點,但是發現性能不但沒有好轉反而下降了,當時將這種現象稱爲緩存的 “無底洞” 現象。
那麼爲什麼會產生這種現象呢,通常來說添加節點使得 Memcache 集羣性能應該更強了,但事實並非如此。鍵值數據庫由於通常採用哈希函數將 key 映射到各個節點上,造成 key 的分佈與業務無關,但是由於數據量和訪問量的持續增長,造成需要添加大量節點做水平擴容,導致鍵值分佈到更多的節點上,所以無論是 Memcache 還是 Redis 的分佈式,批量操作(例如 mget)通常需要從不同節點上獲取,相比於單機批量操作只涉及一次網絡操作,分佈式批量操作會涉及多次網絡時間。
無底洞問題分析:
-
客戶端一次批量操作會涉及多次網絡操作,也就意味着批量操作會隨着節點的增多,耗時會不斷增大。
-
網絡連接數變多,對節點的性能也有一定影響。
用一句通俗的話總結就是,更多的節點不代表更高的性能,所謂 “無底洞” 就是說投入越多不一定產出越多。但是分佈式又是不可以避免的,因爲訪問量和數據量越來越大,一個節點根本抗不住,所以如何高效地在分佈式緩存中批量操作是一個難點。
下面介紹如何在分佈式條件下優化批量操作。在介紹具體的方法之前,我們來看一下常見的單機 IO 優化思路:
-
命令本身的優化,例如優化 SQL 語句等。
-
減少網絡通信次數。
-
降低接入成本,例如客戶端使用長連 / 連接池、NIO 等。
這裏我們假設命令、客戶端連接已經爲最優,重點討論減少網絡操作次數。以 Redis 批量獲取 n 個字符串爲例,我們將結合 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),這種方案會增加編程的複雜度。它的操作時間爲:max_slow(node 次網絡時間)+n 次命令時間。
hash_tag 實現
使用 Redis Cluster 的 hash_tag 功能,它可以將多個 key 強制分配到一個節點上,它的操作時間 = 1 次網絡時間 + n 次命令時間。
方案對比
緩存雪崩
由於緩存層承載着大量請求,有效地保護了存儲層,但是如果緩存層由於某些原因不能提供服務,於是所有的請求都會達到存儲層,存儲層的調用量會暴增,造成存儲層也會級聯宕機的情況。
預防和解決緩存雪崩問題,可以從以下三個方面進行着手。
保證緩存層服務高可用性
如果緩存層設計成高可用的,即使個別節點、個別機器、甚至是機房宕掉,依然可以提供服務,例如 Redis Sentinel 和 Redis Cluster 都實現了高可用。
依賴隔離組件爲後端限流並降級
無論是緩存層還是存儲層都會有出錯的概率,可以將它們視同爲資源。作爲併發量較大的系統,假如有一個資源不可用,可能會造成線程全部阻塞在這個資源上,造成整個系統不可用。
降級機制在高併發系統中是非常普遍的:比如推薦服務中,如果個性化推薦服務不可用,可以降級補充熱點數據。在實際項目中,我們需要對重要的資源(例如 Redis、MySQL、HBase、外部接口)都進行隔離,讓每種資源都單獨運行在自己的線程池中,即使個別資源出現了問題,對其他服務沒有影響。但是線程池如何管理,比如如何關閉資源池、開啓資源池、資源池閥值管理,這些做起來還是相當複雜的。
提前演練
在項目上線前,演練緩存層宕掉後,應用以及後端的負載情況以及可能出現的問題,在此基礎上做一些預案設定。
熱點 key 重建
開發人員使用 “緩存 + 過期時間” 的策略既可以加速數據讀寫,又保證數據的定期更新,這種模式基本能夠滿足絕大部分需求。但是有兩個問題如果同時出現,可能就會對應用造成致命的危害:
-
當前 key 是一個熱點 key,併發量非常大。
-
重建緩存不能在短時間完成,可能是一個複雜計算,例如複雜的 SQL、多次 IO、多個依賴等。
在緩存失效的瞬間,有大量線程來重建緩存,造成後端負載加大,甚至可能會讓應用崩潰。要解決這個問題也不是很複雜,但是不能爲了解決這個問題給系統帶來更多的麻煩,所以需要制定如下目標:
-
減少重建緩存的次數。
-
數據儘可能一致。
-
較少的潛在危險。
互斥鎖
此方法只允許一個線程重建緩存,其他線程等待重建緩存的線程執行完,重新從緩存獲取數據即可。例如可以使用 Redis 的 setnx 命令來實現一個簡單的分佈式互斥鎖來完成。
永遠不過期
“永遠不過期” 包含兩層意思:
-
從緩存層面來看,確實沒有設置過期時間,所以不會出現熱點 key 過期後產生的問題,也就是 “物理” 不過期。
-
從功能層面來看,爲每個 value 設置一個邏輯過期時間,當發現超過邏輯過期時間後,會使用單獨的線程去構建緩存。
此方法有效杜絕了熱點 key 產生的問題,但唯一不足的就是重構緩存期間,會出現數據不一致的情況,這取決於應用方是否容忍這種不一致。
總結
作爲一個併發量較大的應用,在使用緩存時有三個目標:第一,加快用戶訪問速度,提高用戶體驗。第二,降低後端負載,減少潛在的風險,保證系統平穩。第三,保證數據 “儘可能” 及時更新。下表是按照這三個維度對上述兩種解決方案所進行的對比。
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/PUbQbMJt99YD55_8ktZuPw