26 快取異常
快取雪崩、快取擊穿和快取穿透,這三個問題一旦發生,會導致大量的請求積壓到資料庫層,導致資料庫當機或故障。
快取雪崩
快取雪崩是指大量的應用請求無法在 Redis 快取中進行處理,緊接著,應用將大量請求傳送到資料庫層,導致資料庫層的壓力激增。
如何發現:
監測 Redis 快取所在機器和資料庫所在機器的負載指標,例如每秒請求數、CPU 利用率、記憶體利用率等。如果我們發現 Redis 快取例項當機了,而資料庫所在機器的負載壓力突然增加(例如每秒請求數激增),此時,就發生快取雪崩了。
原因一:大量資料同時過期。
解決方案:
- 過期時間加隨機數
- 服務降級:暫停非核心業務訪問,直接返回預定義資訊;核心資料允許繼續查詢
原因二:Redis例項當機
解決方案:
服務熔斷或限流:
- 服務熔斷:暫停訪問,直接返回
- 限流:請求入口設定每秒請求數量,超出直接拒絕
- 配置高可用叢集
快取擊穿
快取擊穿是指,針對某個訪問非常頻繁的熱點資料的請求,無法在快取中進行處理,緊接著,訪問該資料的大量請求,一下子都傳送到了後端資料庫,導致了資料庫壓力激增,會影響資料庫處理其他請求。
快取擊穿的情況,經常在熱點資料過期失效時發生。
解決方案:熱點資料不設定過期時間。
快取穿透
快取穿透是指要訪問的資料既不在 Redis 快取中,也不在資料庫中,導致請求在訪問快取時,發生快取缺失,再去訪問資料庫時,發現資料庫中也沒有要訪問的資料。
原因:
- 業務層誤操作,資料被刪除
- 惡意攻擊,訪問資料庫中沒有的資料
解決方案:
- 快取空值或預設值
- 布隆過濾器快速判斷
- 前端過濾惡意請求
布隆過濾器
布隆過濾器由一個初值都為 0 的 bit 陣列和 N 個雜湊函式組成,可以用來快速判斷某個資料是否存在。
資料寫入時標記:
- 使用 N 個雜湊函式,分別計算這個資料的雜湊值,得到 N 個雜湊值。
- 們把這 N 個雜湊值對 bit 陣列的長度取模,得到每個雜湊值在陣列中的對應位置。
- 把對應位置的 bit 位設定為 1,這就完成了在布隆過濾器中標記資料的操作。
查詢時執行標記過程,並對比bit 陣列中這 N 個位置上的 bit 值。只要這 N 個 bit 值有一個不為 1,
這就表明布隆過濾器沒有對該資料做過標記,所以,查詢的資料一定沒有在資料庫中儲存。
27 快取汙染
在一些場景下,有些資料被訪問的次數非常少,甚至只會被訪問一次。當這些資料服務完訪問請求後,如果還繼續留存在快取中的話,就只會白白佔用快取空間。這種情況,就是快取汙染。
快取汙染會導致大量不再訪問的資料滯留在快取中,當快取空間佔滿,再寫入新資料時,把這些資料淘汰需要額外的操作時間開銷,影響應用效能。
解決方案:
- 知道資料被再次訪問的情況,根據訪問時間設定過期時間:volatile-ttl
- LFU快取策略
掃描式單次查詢:
對大量的資料進行一次全體讀取,每個資料都會被讀取,而且只會被讀取一次。
因為這些被查詢的資料剛剛被訪問過,所以 lru 欄位值都很大。在使用 LRU 策略淘汰資料時,這些資料會留存在快取中很長一段時間,造成快取汙染。
LFU快取策略
LFU 快取策略是在 LRU 策略基礎上,為每個資料增加了一個計數器,來統計這個資料的訪問次數。
- 當使用 LFU 策略篩選淘汰資料時,首先會根據資料的訪問次數進行篩選,把訪問次數最低的資料淘汰出快取。
- 如果兩個資料的訪問次數相同,LFU 策略再比較這兩個資料的訪問時效性,把距離上一次訪問時間更久的資料淘汰出快取。
掃描式單次查詢的資料因為不會被再次訪問,所以它們的訪問次數不會再增加。因此,LFU 策略會優先把這些訪問次數低的資料淘汰出快取。這樣一來,LFU 策略就可以避免這些資料對快取造成汙染了。
LRU實現原理:
- Redis 是用 RedisObject 結構來儲存資料的,RedisObject 結構中設定了一個 lru 欄位,用來記錄資料的訪問時間戳;
- Redis 並沒有為所有的資料維護一個全域性的連結串列,而是透過隨機取樣方式,選取一定數量(例如 10 個)的資料放入候選集合,後續在候選集合中根據 lru 欄位值的大小進行篩選。
LFU實現原理:把原來 24bit 大小的 lru 欄位,又進一步拆分成了兩部分。
- ldt 值:lru 欄位的前 16bit,表示資料的訪問時間戳;
- 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 單例項的記憶體容量,形成大記憶體例項,每個例項可以儲存更多的資料,這樣一來,在儲存相同的資料總量時,所需要的大記憶體例項的個數就會減少,就可以節省開銷。
潛在問題:
- 記憶體快照RDB生成和恢復效率低
- 主從同步時長增加,緩衝區易溢位,導致全量複製
解決方案:
基於 SSD 來實現大容量的 Redis 例項,如 Pika鍵值資料庫。
29 併發訪問
為了保證併發訪問的正確性,Redis 提供了兩種方法,分別是加鎖和原子操作。
併發訪問控制
指對多個客戶端訪問操作同一份資料的過程進行控制,以保證任何一個客戶端傳送的操作在 Redis 例項上執行時具有互斥性。
併發訪問控制對應的操作主要是資料修改操作。當客戶端需要修改資料時,基本流程分成兩步:
- 客戶端先把資料讀取到本地,在本地進行修改
- 修改完資料後寫回Redis
這個流程叫做“讀取 - 修改 - 寫回”操作(Read-Modify-Write,簡稱為 RMW 操作)。
當有多個客戶端對同一份資料執行 RMW 操作的話,我們就需要讓 RMW 操作涉及的程式碼以原子性方式執行。訪問同一份資料的 RMW 操作程式碼,就叫做臨界區程式碼。
當有多個客戶端併發執行臨界區程式碼時,就會存在一些潛在問題。多個客戶端操作不具有互斥行,分別基於相同的初始值進行修改,而不是基於前一個客戶端修改後的值再修改。
原子性操作
為了實現併發控制要求的臨界區程式碼互斥執行,Redis 的原子操作採用了兩種方法:
- 把多個操作在 Redis 中實現成一個操作,也就是單命令操作;
- 把多個操作寫到一個 Lua 指令碼中,以原子性方式執行單個 Lua 指令碼。
Redis 提供了 INCR/DECR 原子操作。
Lua指令碼:
Redis 會把整個 Lua 指令碼作為一個整體執行,在執行的過程中不會被其他命令打斷,從而保證了 Lua 指令碼中操作的原子性。
缺點:
操作都放在 Lua 指令碼中原子執行,會導致 Redis 執行指令碼的時間增加,同樣也會降低 Redis 的併發效能。
建議:
在編寫 Lua 指令碼時,你要避免把不需要做併發控制的操作寫入指令碼中。
30 分散式鎖
在分散式系統中,當有多個客戶端需要獲取鎖時,我們需要分散式鎖。此時,鎖是儲存在一個共享儲存系統中的,可以被多個客戶端共享訪問和獲取。
分散式鎖的要求:
- 加鎖和釋放鎖涉及多個操作,實現分散式鎖要保證操作的原子性
- 共享儲存系統儲存鎖變數,實現分散式鎖要保證共享儲存系統的可靠性
單機鎖
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 例項依次請求加鎖,如果客戶端能夠和半數以上的例項成功地完成加鎖操作,那麼我們就認為,客戶端成功地獲得分散式鎖了,否則加鎖失敗。
加鎖流程:
- 客戶端獲取當前時間
- 客戶端依序向N個Redis例項執行加鎖操作
客戶端完成所有例項加鎖後,計算加鎖總耗時,加鎖成功條件:
- 客戶端從超過半數例項(N/2+1)獲取到鎖
- 客戶端獲取鎖的總耗時沒有超過鎖的有效時間
- 重新計算所的有效時間:最初有效時間 - 獲取鎖的總耗時
釋放鎖流程:
執行釋放鎖的Lua指令碼,注意釋放鎖時,要對所有節點釋放。