30-20 之資料緩存失效問題
it 鐵人賽 2019 redis
Lastmod: 2020-04-21

正文開始

上一篇文章中咱們已經學習了一些緩存基本的策略,那接下來我們要來理解一下一個重要的主題 :

如果緩存失效的情況,與可能會發生什麼事情呢 ?

基本上緩存失效後的結果,會很慘,尤其是你當初建立緩存時,就是已經為了讀取接近性能臨異值而建立的情況,當緩存一失效,你的資料庫也會瞬間爆掉,然後用戶就不愈悅,你就完了。

圖 1 : 緩存失效圖

而在實務上,緩存失效大至可以分為以下幾類,也就是咱們接下來每個章節 :

  • 緩存失效情況 - 緩存穿透
  • 緩存失效情況 - 緩存雪崩
  • 緩存失效情況 - 緩存服務炸了

緩存失效情況 - 緩存穿透

這個的主要情境如下 :

查詢一個不存在的資料,由於沒命中緩存,因此會一直往 DB 穿,如下圖 2 所示。

圖 2 : 緩存穿透

通常這種情況有可能是前端出了錯,導致一直送不存在的資料,又或是人為刻意,就是要有人想打爆你整個系統。

解法 1 : 硬處理

這種方法就是,當用戶使用 a key 去 redis 找發現沒有 cache,然後再去 db 抓,也發現沒有,然後就將『 這個 a key 是空值 』也寫入到緩存中 ( 可以給它設個到期時間 ),如下程式碼範例。

這樣後面有人在使用這個 key 去打,會在 cache 這被防下來,然後將算後來真的有寫入 a key 時,咱們緩存寫入流程也是會將它處理 ( 詳見前篇文章 )。

// 概念碼

const userId = 'A';

if(isExistInCache(userId)){
  return getCache(userId);
}

const user = getUser(userId);
let cacheValue = user;
if( user === null ){
  cacheValue = getDefaultEmptyValue();
}

setCache(userId, cacheValue);

不過這種方案還是有缺點

它無法防護隨機編號的攻擊,因為每一次都是不同的

解法 2 : 布隆過濾器 ( Bloom Filter )

它的基本解法概念為 :

建立一個很大的 hash 來『 存放值 』,當如果是『 不存在的值 』則會再檢查時被過濾掉。

問個問題 ~ 這裡用 hash 感覺就可以處理了,為啥要用啥布隆過濾器概念不是很像嗎 ?

咱們寫來說說 hash 的處理方案。

首先 hash 就是一個 key 與 value 的對應,然後 key 會通過 hash 函數來進行轉換,如下圖 3 所示。

圖 3 : hash

這裡順到說一下,上圖 3 的 jack 與 jiro 這兩個 key 通過 hash 有可能會指到相同的 value 4,那這樣如何處理呢 ?

比較常見有兩種方案 :

  • 拉鍊法 : 就是 value 裡面實際上是存 linklist,但是這種要很注意 hash function 的設計,因為一不小心就會變成一個很長的 linklist。
  • 尋址法 : 就是第一個 key 放到某個位置後,如果第二個 key 還是到這個位置,它發現有人了,那它就會再找下一個位置,有可能是往前或往後走。

在補充一下 hash 的新增與抓取操作時間複雜都是 o(1),然後 linklist 新增是 o(1),但是抓取就是 o(n),而如果變成太長的 linklist,你要抓值時,就代表要一個一個看看,也就是 o(n) 的操作。

那 hash 又與布隆過濾器有啥差別 ?

兩則概念很相似,但問題出在 :

如果 key 非常多的情況下

hash 有可能會爆,因為你可以想成它是每一個 key,就會花個空間來儲,而布隆過濾器就是可以解決這一點。

簡單來說它可以花少少的空間來判斷,一個 key 是否存在,但然後它的缺點就在於 :

有誤判率

所以這個演算法不適合用在 100% 確定的場景,而是只能用在大約有 70 ~ 80 % 左右的準確度就夠了,例如緩存穿透、垃圾郵件過濾、爬蟲 url 去除重複。

詳細的布隆過濾器原理,這裡就不說了,有興趣的友人請自行查查。

實現

好然後拉回來,如果要用布隆過濾器來處理緩存穿透時,咱們大概會用以下程式碼來實現 :

const target = 'Mark';

// 這裡會先判斷 target 有沒有存在,透布隆過濾器表來判斷
if(!isExistBoolFilter(target)){
   return;
}

if(isExistInCache(target)){
  rturn cache[target];
}

return db.get(target);

然後我覺得比較重要的點在於,你的 boolFilter 要如何維護呢 ?

目前想到比較簡單的處理是,當 target 被新增的時後,則更新一次記憶體中的 boolFilter,而如果是移除了,則不理它,反正 cache 那會防。

別忘了這裡是為了達到 :

儘可能的過濾資料,別讓緩存服務爆了。

所以你要確保,即使 boolFilter 誤判了,那業務運行還是不會出錯。

對了,那這個 boolFilter 要存那呢 ?

比較建議是存在緩存服務外的另一個 redis 上。

在單機時存記憶體上還沒啥毛病,就算機器重開了,那也項多是去 db 重拉一份資料然後再存入記憶體,但多機時你還要考慮每台機器 boolFilter 更新的問題。

緩存失效情況 - 緩存雪崩

這個的主要情境如下 :

當緩存在同一個時間內大量失效時(因為到期或啥的)。

圖 4 : 緩存雪崩

這種情況基本上分為以下兩個情境 :

雪崩情況 1 : 緩存的到期時間到期,而自動清除緩存

首先說說第一個,上一章節有提到,通常會建議,在寫入緩存時,會給它設置一個到期時間,免得上面殘留太多的那些忘記寫清除程式,或因系統故障導致沒有清除的不需要的緩存。下面為 redis 設定 key 的到期時間小範例。

redis> SET mykey "Hello"
"OK"
redis> EXPIRE mykey 10
(integer) 1

而這時如果有大量的 key,它們到期時間設置相同,那失效的那個瞬間就會產生所謂的緩存雪崩。

解法

這種方案的解法通常為,給它設置一個隨機變數 :

EXPIRE mykey 10 + random

雪崩情況 2 : 瞬間大量的寫入

而至於第二種情況,瞬間的大量寫入,上一章節咱們要有說在緩存最少是使用『 淘汰 』,而不是用『 更新 』,但是如果這時有瞬間的大量寫入,那就是產生大量的『 淘汰 』,那這時也會產生所謂的緩存雪崩。

解法

這一題目前想了一下,事實上發生機率很低,因為會建立緩存的資料前提假設之一就是更新頻率低,而在這個情況下還是會發生這種情況的另一種可能就在於『 有人在批次大量修改資料 』。

如果是在這種批次的情況下,那就建議直接用『 批次更新 』緩存,而不要在正規使用『 淘汰 』,先說一下系統內的運行一樣是『 淘汰 』,只是當發現有需要大量修改資料時,請手動的『 更新 』緩存。

如果都不是上面說的這種情況,而還是發生了,那就可能需要使用『 隊列服務 』來讓這些寫操作排隊,不要一口氣灌到緩存服務,但當然,這會對架構與性能可能都會有不少影響,所不定也會出現一堆坑。

緩存失效情況 - 緩存服務炸了

就是緩存服務炸掉了,導致直接灌到資料庫層去,如下圖 5 所示。

圖 5 : 緩存服務炸了

~ 小備註 ~

這裡應該會有人覺得很奇怪,假設咱們程式碼如下所示,那基本緩存服務掛了,資料庫那層應該是不會處理啊,因為錯誤囉。

const target = 'Mark';

if(isExistInCache(target)){
  rturn cache[target];
}

return db.get(target);

對是沒錯。

但這時你要想想,這樣是對的嗎 ?

緩存是為了什麼 ? 是為了加快查詢的速度與保護資料庫層。那你想想,你覺得緩存服務掛了,要讓整個『 系統業務 』都死嗎,如果你有個記錄 log 的服務掛了,也要整個系統都死嗎 ?

不不 ~ 這樣不太對 ~ 別忘了我們追求的是『 好的系統 』,是要讓客戶愉悅的,如果一個業務系統要綁十個服務,然後每個服務都要完成,業務才算完成,那你想想你的業務完成機率是多少呢 ? 順到說一下,如果一個服務裡需要綁那麼多服務,請考慮開拆。

緩存掛了對用戶來說,有時也感覺不到,log 服務掛了對用戶來說更感覺不到,所以通常咱們會在這 isExistInCache 這方法裡面加個 try cache 然後繼續去 db 查詢。

這樣才能保證緩存服務就算故障了,也不影響『 系統業務 』。

解法 - Master-Slave 模式

這個架構基本算是最基本的 redis 高可用架構,它主要的目的在於 :

當 master 掛了,一台 slave 馬上來補 ( 就是升為 master )

所以當緩存服務有建立這架構後,就可以解決緩存服務炸了導致大量請求直接灌入到 db 的問題,因為有後補者。

圖 6 : master-slave 架構

這裡簡單說明一下這個架構,這個架構基本上是有三個服務 :

  • master : 主要接客用的 redis。
  • slave : 後補球員。
  • sentinel : master 監控者

圖 6 中,這裡有兩個小重點,首先第一個 master slave 會一直運行資料同步 ( a 的部份 ),這樣才能保證 slave 轉換到 master 時,有相同的資料,而第二點為 sentinel 它會不斷的監控這 master ( b 的部份 ),當它發現 master 掛了,就會將某個 slave 上架上來。

小備註 傳統它這個架構,它會有三個 sentinel, 主要的原因有兩個 :

  • 防止 sentinel 單點掛掉。
  • sentinel 仲裁會。

其中 sentinel 仲裁會比較特別一點,這個東西就是用來決定 master 要不要下掉的機制。一台 master 要下掉需要由多個 sentinel 來投票,如果 n 個 sentinel 同意 master 掛掉,則進行 failover ( 就是 slave 補上 )。

結論與心得

本篇文章中,咱們介紹了幾種緩存失效的類型 :

  • 緩存穿透
  • 緩存雪崩
  • 緩存服務炸了

基本上緩存穿透我目前還比較少碰到,雖然當初再開發時有想到這問題,但後來想想發生機率不高,就有點懶的解…… ( 這不算好事情,請別學 )。然後我也是在查資料時才知道有布隆過濾器這個解法。

而至於緩存雪崩這問題,這就還真的碰到不少次,最後也是給每個 key 的到期時間,給它加個大秒 10 秒以內的隨機亂數,才比較安全一點。但如果真的沒辦法給你就要考慮 key 的退場方案新機制,例如用 queue 來處理之類的。

最後緩存服務炸了這件事情麻,還真沒啥發生過,因為咱家 redis 基本型就是 master-slave 炸了可以馬上的換過來。

但是最近咱們在談討一個問題 :

如果 redis 有被應用層 connection pool 連線住,那 failover 機制可以正常運行嗎 ?

目前我們發現好像不會正常運行,會卡住,但根本原因還要再研究一下,之後有空在發篇文來談談。

參考資料

comments powered by Disqus