Redis核心技術26-30

IT小馬發表於2023-01-17

26 快取異常

快取雪崩、快取擊穿和快取穿透,這三個問題一旦發生,會導致大量的請求積壓到資料庫層,導致資料庫當機或故障。

快取雪崩

快取雪崩是指大量的應用請求無法在 Redis 快取中進行處理,緊接著,應用將大量請求傳送到資料庫層,導致資料庫層的壓力激增。

如何發現:
監測 Redis 快取所在機器和資料庫所在機器的負載指標,例如每秒請求數、CPU 利用率、記憶體利用率等。如果我們發現 Redis 快取例項當機了,而資料庫所在機器的負載壓力突然增加(例如每秒請求數激增),此時,就發生快取雪崩了。

原因一:大量資料同時過期。
解決方案:

  1. 過期時間加隨機數
  2. 服務降級:暫停非核心業務訪問,直接返回預定義資訊;核心資料允許繼續查詢

原因二:Redis例項當機
解決方案:

  1. 服務熔斷或限流:

    • 服務熔斷:暫停訪問,直接返回
    • 限流:請求入口設定每秒請求數量,超出直接拒絕
  2. 配置高可用叢集

快取擊穿

快取擊穿是指,針對某個訪問非常頻繁的熱點資料的請求,無法在快取中進行處理,緊接著,訪問該資料的大量請求,一下子都傳送到了後端資料庫,導致了資料庫壓力激增,會影響資料庫處理其他請求。

快取擊穿的情況,經常在熱點資料過期失效時發生。

解決方案:熱點資料不設定過期時間。

快取穿透

快取穿透是指要訪問的資料既不在 Redis 快取中,也不在資料庫中,導致請求在訪問快取時,發生快取缺失,再去訪問資料庫時,發現資料庫中也沒有要訪問的資料。

原因:

  1. 業務層誤操作,資料被刪除
  2. 惡意攻擊,訪問資料庫中沒有的資料

解決方案:

  1. 快取空值或預設值
  2. 布隆過濾器快速判斷
  3. 前端過濾惡意請求

布隆過濾器

布隆過濾器由一個初值都為 0 的 bit 陣列和 N 個雜湊函式組成,可以用來快速判斷某個資料是否存在。

資料寫入時標記:

  1. 使用 N 個雜湊函式,分別計算這個資料的雜湊值,得到 N 個雜湊值。
  2. 們把這 N 個雜湊值對 bit 陣列的長度取模,得到每個雜湊值在陣列中的對應位置。
  3. 把對應位置的 bit 位設定為 1,這就完成了在布隆過濾器中標記資料的操作。

查詢時執行標記過程,並對比bit 陣列中這 N 個位置上的 bit 值。只要這 N 個 bit 值有一個不為 1,
這就表明布隆過濾器沒有對該資料做過標記,所以,查詢的資料一定沒有在資料庫中儲存。

27 快取汙染

在一些場景下,有些資料被訪問的次數非常少,甚至只會被訪問一次。當這些資料服務完訪問請求後,如果還繼續留存在快取中的話,就只會白白佔用快取空間。這種情況,就是快取汙染。

快取汙染會導致大量不再訪問的資料滯留在快取中,當快取空間佔滿,再寫入新資料時,把這些資料淘汰需要額外的操作時間開銷,影響應用效能。

解決方案:

  1. 知道資料被再次訪問的情況,根據訪問時間設定過期時間:volatile-ttl
  2. LFU快取策略

掃描式單次查詢:
對大量的資料進行一次全體讀取,每個資料都會被讀取,而且只會被讀取一次。

因為這些被查詢的資料剛剛被訪問過,所以 lru 欄位值都很大。在使用 LRU 策略淘汰資料時,這些資料會留存在快取中很長一段時間,造成快取汙染。

LFU快取策略

LFU 快取策略是在 LRU 策略基礎上,為每個資料增加了一個計數器,來統計這個資料的訪問次數。

  • 當使用 LFU 策略篩選淘汰資料時,首先會根據資料的訪問次數進行篩選,把訪問次數最低的資料淘汰出快取。
  • 如果兩個資料的訪問次數相同,LFU 策略再比較這兩個資料的訪問時效性,把距離上一次訪問時間更久的資料淘汰出快取。

掃描式單次查詢的資料因為不會被再次訪問,所以它們的訪問次數不會再增加。因此,LFU 策略會優先把這些訪問次數低的資料淘汰出快取。這樣一來,LFU 策略就可以避免這些資料對快取造成汙染了。

LRU實現原理:

  • Redis 是用 RedisObject 結構來儲存資料的,RedisObject 結構中設定了一個 lru 欄位,用來記錄資料的訪問時間戳;
  • Redis 並沒有為所有的資料維護一個全域性的連結串列,而是透過隨機取樣方式,選取一定數量(例如 10 個)的資料放入候選集合,後續在候選集合中根據 lru 欄位值的大小進行篩選。

LFU實現原理:把原來 24bit 大小的 lru 欄位,又進一步拆分成了兩部分。

  1. ldt 值:lru 欄位的前 16bit,表示資料的訪問時間戳;
  2. counter 值:lru 欄位的後 8bit,表示資料的訪問次數。

總結一下:當 LFU 策略篩選資料時,Redis 會在候選集合中,根據資料 lru 欄位的後 8bit 選擇訪問次數最少的資料進行淘汰。當訪問次數相同時,再根據 lru 欄位的前 16bit 值大小,選擇訪問時間最久遠的資料進行淘汰。

LFU使用了非線性遞增的計數器方法,透過設定 lfu_log_factor 配置項,來控制計數器值增加的速度;lfu_log_factor=100時,實際訪問次數小於 10M 的不同資料都可以透過 counter 值區分出來。

LFU 策略時還設計了一個 counter 值的衰減機制,使用衰減因子配置項 lfu_decay_time 來控制訪問次數的衰減。假設 lfu_decay_time 取值為 1,如果資料在 N 分鐘內沒有被訪問,那麼它的訪問次數就要減 N。

如果業務應用中有短時高頻訪問的資料的話,建議把 lfu_decay_time 值設定為 1,這樣一來,LFU 策略在它們不再被訪問後,會較快地衰減它們的訪問次數,儘早把它們從快取中淘汰出去,避免快取汙染。

小結

LRU 策略更加關注資料的時效性,而 LFU 策略更加關注資料的訪問頻次。

通常情況下,實際應用的負載具有較好的時間區域性性,所以 LRU 策略的應用會更加廣泛。

但是,在掃描式查詢的應用場景中,LFU 策略就可以很好地應對快取汙染問題了,建議你優先使用。

28 大容量例項

Redis 切片叢集,把資料分散儲存到多個例項上,如果要儲存的資料總量很大,但是每個例項儲存的資料量較小的話,就會導致叢集的例項規模增加,這會讓叢集的運維管理變得複雜,增加開銷。

增加 Redis 單例項的記憶體容量,形成大記憶體例項,每個例項可以儲存更多的資料,這樣一來,在儲存相同的資料總量時,所需要的大記憶體例項的個數就會減少,就可以節省開銷。

潛在問題:

  1. 記憶體快照RDB生成和恢復效率低
  2. 主從同步時長增加,緩衝區易溢位,導致全量複製

解決方案:
基於 SSD 來實現大容量的 Redis 例項,如 Pika鍵值資料庫。

29 併發訪問

為了保證併發訪問的正確性,Redis 提供了兩種方法,分別是加鎖和原子操作。

併發訪問控制

指對多個客戶端訪問操作同一份資料的過程進行控制,以保證任何一個客戶端傳送的操作在 Redis 例項上執行時具有互斥性。

併發訪問控制對應的操作主要是資料修改操作。當客戶端需要修改資料時,基本流程分成兩步:

  1. 客戶端先把資料讀取到本地,在本地進行修改
  2. 修改完資料後寫回Redis

這個流程叫做“讀取 - 修改 - 寫回”操作(Read-Modify-Write,簡稱為 RMW 操作)。

當有多個客戶端對同一份資料執行 RMW 操作的話,我們就需要讓 RMW 操作涉及的程式碼以原子性方式執行。訪問同一份資料的 RMW 操作程式碼,就叫做臨界區程式碼。

當有多個客戶端併發執行臨界區程式碼時,就會存在一些潛在問題。多個客戶端操作不具有互斥行,分別基於相同的初始值進行修改,而不是基於前一個客戶端修改後的值再修改。

原子性操作

為了實現併發控制要求的臨界區程式碼互斥執行,Redis 的原子操作採用了兩種方法:

  1. 把多個操作在 Redis 中實現成一個操作,也就是單命令操作;
  2. 把多個操作寫到一個 Lua 指令碼中,以原子性方式執行單個 Lua 指令碼。

Redis 提供了 INCR/DECR 原子操作。

Lua指令碼:
Redis 會把整個 Lua 指令碼作為一個整體執行,在執行的過程中不會被其他命令打斷,從而保證了 Lua 指令碼中操作的原子性。

缺點:
操作都放在 Lua 指令碼中原子執行,會導致 Redis 執行指令碼的時間增加,同樣也會降低 Redis 的併發效能。
建議:
在編寫 Lua 指令碼時,你要避免把不需要做併發控制的操作寫入指令碼中。

30 分散式鎖

在分散式系統中,當有多個客戶端需要獲取鎖時,我們需要分散式鎖。此時,鎖是儲存在一個共享儲存系統中的,可以被多個客戶端共享訪問和獲取。

分散式鎖的要求:

  1. 加鎖和釋放鎖涉及多個操作,實現分散式鎖要保證操作的原子性
  2. 共享儲存系統儲存鎖變數,實現分散式鎖要保證共享儲存系統的可靠性

單機鎖

Redis 可以使用一個鍵值對 lock_key:0 來儲存鎖變數,其中,鍵是 lock_key,也是鎖變數的名稱,鎖變數的初始值是 0。

加鎖時客戶端先讀取 lock_key 的值,發現 lock_key 為 0,所以,Redis 就把 lock_key 的 value 置為 1,表示已經加鎖了。釋放鎖就是直接把鎖變數值設定為 0。

// 加鎖
SET key value [EX seconds | PX milliseconds]  [NX]
// 業務邏輯
DO THINGS
// 釋放鎖
DEL lock_key

NX 選項:SET 命令只有在鍵值對不存在時,才會進行設定,否則不做賦值操作。
EX 或 PX 選項:設定鍵值對的過期時間。

風險1:加鎖後發生異常,沒有釋放鎖導致阻塞。
解決辦法:給鎖變數設定過期時間。

風險2:客戶端A加的鎖被客戶端B刪掉DEL
解決辦法:每個客戶端的鎖設一個唯一值uuid

加鎖示例:

// 加鎖, unique_value作為客戶端唯一性的標識
SET lock_key unique_value NX PX 10000

解鎖指令碼unlock.script:

//釋放鎖 比較unique_value是否相等,避免誤釋放
if redis.call("get",KEYS[1]) == ARGV[1] then
    return redis.call("del",KEYS[1])
else
    return 0
end

解鎖命令:

redis-cli  --eval  unlock.script lock_key , unique_value 

分散式鎖

為了避免 Redis 例項故障而導致的鎖無法工作的問題,Redis 的開發者 Antirez 提出了分散式鎖演演算法 Redlock。

基本思路是讓客戶端和多個獨立的 Redis 例項依次請求加鎖,如果客戶端能夠和半數以上的例項成功地完成加鎖操作,那麼我們就認為,客戶端成功地獲得分散式鎖了,否則加鎖失敗。

加鎖流程:

  1. 客戶端獲取當前時間
  2. 客戶端依序向N個Redis例項執行加鎖操作
  3. 客戶端完成所有例項加鎖後,計算加鎖總耗時,加鎖成功條件:

    1. 客戶端從超過半數例項(N/2+1)獲取到鎖
    2. 客戶端獲取鎖的總耗時沒有超過鎖的有效時間
  4. 重新計算所的有效時間:最初有效時間 - 獲取鎖的總耗時

釋放鎖流程:
執行釋放鎖的Lua指令碼,注意釋放鎖時,要對所有節點釋放。

相關文章