寫在前
在看redis緩存雪崩、擊穿和穿透之前,先回答一下幾個緩存的問題。

為什么要用 redis 而不用 map/guava 做緩存?
緩存分為本地緩存和分布式緩存。
以Java為例,使??帶的 map 或者 guava 實現(xiàn)的是本地緩存,最主要的特點是輕量以及快速,?命周期隨著 jvm 的銷毀?結(jié)束,并且在多實例的情況下,每個實例都需要各?保存?份緩存,緩存不具有?致性。
使? redis 或 memcached 之類的稱為分布式緩存,在多實例的情況下,各實例共??份緩存數(shù)據(jù),緩存具有?致性。缺點是需要保持 redis 或 memcached服務(wù)的?可?(需要維護),整個程序架構(gòu)上為較為復(fù)雜。
Redis 與 Memcached的區(qū)別
兩者都是非關(guān)系型(NoSql)內(nèi)存鍵值數(shù)據(jù)庫,主要有以下不同:
(1)數(shù)據(jù)類型。Memcached 僅支持字符串類型,而 Redis 支持五種不同的數(shù)據(jù)類型,可以更靈活地解決問題。
(2)數(shù)據(jù)持久化。Redis 支持兩種持久化策略:RDB 快照和 AOF 日志,而 Memcached 不支持持久化。
(3)分布式。Memcached 不支持分布式,只能通過在客戶端使用一致性哈希來實現(xiàn)分布式存儲,這種方式在存儲和查詢時都需要先在客戶端計算一次數(shù)據(jù)所在的節(jié)點。Redis Cluster 實現(xiàn)了分布式的支持。
(4)內(nèi)存管理機制。在 Redis 中,并不是所有數(shù)據(jù)都一直存儲在內(nèi)存中,可以將一些很久沒用的 value 交換到磁盤(設(shè)置過期時間),而 Memcached 的數(shù)據(jù)則會一直在內(nèi)存中。Memcached 將內(nèi)存分割成特定長度的塊來存儲數(shù)據(jù),以完全解決內(nèi)存碎片的問題。但是這種方式會使得內(nèi)存的利用率不高,例如塊的大小為 128 bytes,只存儲 100 bytes 的數(shù)據(jù),那么剩下的 28 bytes 就浪費掉了。
(5)Memcached是多線程,?阻塞IO復(fù)?的?絡(luò)模型;Redis使?單線程的多路 IO 復(fù)?模型。
使用Redis有什么缺點?存在的問題?
(1)緩存和數(shù)據(jù)庫雙寫一致性問題
(2)緩存雪崩問題
(3)緩存擊穿問題
(4)緩存穿透(并發(fā)競爭)問題
緩存雪崩
為了使查詢速度更快,我們選擇使用緩存來保存數(shù)據(jù),使原本每次請求都需要查詢數(shù)據(jù)庫的操作變成先查詢緩存,緩存有直接返回,緩存沒有則查詢數(shù)據(jù)庫然后再寫入緩存中,通常緩存都是有有效時長的,否則就會一直占用內(nèi)存空間。
問題描述:當(dāng)大量請求在訪問都會先從緩存查詢,如果此時大部分緩存同時過期失效,那么這些請求都查詢不到緩存,此時他們會全部將請求到數(shù)據(jù)庫,當(dāng)請求數(shù)量足夠大時此時將會把數(shù)據(jù)庫壓垮。簡言之,如果緩存掛掉了,就意味著大量的請求都跑到數(shù)據(jù)庫去了,壓垮數(shù)據(jù)庫,這就是緩存雪崩。
解決方案:
緩存數(shù)據(jù)的過期時間后邊加一個隨機值,防止同一時間大量數(shù)據(jù)過期現(xiàn)象發(fā)生,讓數(shù)據(jù)均勻失效。
一般并發(fā)量不是特別多的時候,使用最多的解決方案是加鎖排隊。
給每一個緩存數(shù)據(jù)增加相應(yīng)的緩存標(biāo)記,記錄緩存是否失效,如果緩存標(biāo)記失效,則更新數(shù)據(jù)緩存。
熱點數(shù)據(jù)可以考慮不失效。
Redis是如何判斷數(shù)據(jù)是否過期的呢?
Redis 通過一個叫做過期字典(可以看作是hash表)來保存數(shù)據(jù)過期的時間。過期字典的鍵是一個指針,這個指針指向鍵空間中的某個鍵對象( 也即是某個數(shù)據(jù)庫鍵)。過期字典的值是一個long long 類型的整數(shù),這個整數(shù)保存了鍵所指向的數(shù)據(jù)庫鍵的過期時間:一個毫秒精度的UNIX 時間戳。
過期字典是存儲在 redisDb 這個結(jié)構(gòu)里的:鍵空間+鍵的過期時間
typedef struct redisDb {
...
dict *dict; //數(shù)據(jù)庫鍵空間,保存著數(shù)據(jù)庫中所有鍵值對
dict *expires // 過期字典,保存著鍵的過期時間
...
} redisDb;
通過過期字典,程序可以用以下步驟檢查一個給定鍵是否過期:
(1)檢查給定鍵是否存在于過期字典: 如果存在,那么取得鍵的過期時間。
(2)檢查當(dāng)前UNIX 時間戳是否大于鍵的過期時間: 如果是的話,那么鍵已經(jīng)過期;否則的話,鍵未過期。
Redis 給緩存數(shù)據(jù)設(shè)置過期時間有啥用?
(1)有助于緩解內(nèi)存的消耗,避免長時間占用內(nèi)存。如果緩存中的所有數(shù)據(jù)都是一直保存的話,分分鐘直接 Out of memory。
(2)實際業(yè)務(wù)場景需要。很多時候,我們的業(yè)務(wù)場景就是需要某個數(shù)據(jù)只在某一時間段內(nèi)存在,比如我們的短信驗證碼可能只在1分鐘內(nèi)有效,用戶登錄的 token 可能只在 1 天內(nèi)有效。如果使用傳統(tǒng)的數(shù)據(jù)庫來處理的話,一般都是自己判斷過期,這樣更麻煩并且性能要差很多。
127.0.0.1:6379> exp key 60 # 數(shù)據(jù)在 60s 后過期
(integer) 1
127.0.0.1:6379> setex key 60 value # 數(shù)據(jù)在 60s 后過期 (setex:[set] + [ex]pire)
OK
127.0.0.1:6379> ttl key # 查看數(shù)據(jù)還有多久過期
(integer) 56
注意:Redis 中除了字符串類型有自己獨有設(shè)置過期時間的命令 setex 外,其他方法都需要依靠 expire 命令來設(shè)置過期時間 。另外, persist 命令可以移除一個鍵的過期時間, ttl查看鍵還有多久過期
redis 設(shè)置過期時間,怎么處理過期數(shù)據(jù)呢?(過期鍵刪除策略)
Redis中有個設(shè)置時間過期的功能,即對存儲在 redis 數(shù)據(jù)庫中的值可以設(shè)置?個過期時間。作為?個 緩存數(shù)據(jù)庫,這是?常實?的。如我們?般項?中的 token 或者?些登錄信息,尤其是短信驗證碼都是有時間限制的,按照傳統(tǒng)的數(shù)據(jù)庫處理?式,?般都是??判斷過期,這樣?疑會嚴(yán)重影響項?性 。
通過key設(shè)置過期時間:我們 set key 的時候,都可以給?個 expire time,就是過期時間。通過過期時間我們可以指定這個key可以存活的時間。如果假設(shè)你設(shè)置了一批 key 只能存活 1 分鐘,那么 1 分鐘后,Redis 是怎么對這批 key 進(jìn)行刪除的呢(策略)?
- 惰性刪除 :只會在取出 key 的時候才對數(shù)據(jù)進(jìn)行過期檢查。這樣對 CPU 最友好,但是可能會造成太多過期 key 沒有被刪除。
- 定期刪除 : 每隔一段時間抽取一批 key 執(zhí)行刪除過期 key 操作。對內(nèi)存友好。并且,Redis 底層會通過限制刪除操作執(zhí)行的時長和頻率來減少刪除操作對 CPU 時間的影響。
所以 Redis 采用的是 定期刪除+惰性/懶漢式刪除 。
但是,僅僅通過給 key 設(shè)置過期時間還是有問題的。因為還是可能存在定期刪除和惰性刪除漏掉了很多過期 key 的情況。這樣就導(dǎo)致大量過期 key 堆積在內(nèi)存里,然后就 Out of memory 了。
怎么解決這個問題呢?答案就是: Redis 內(nèi)存淘汰機制。
Redis 內(nèi)存淘汰機制
作為內(nèi)存數(shù)據(jù)庫,出于對性能和內(nèi)存消耗的考慮,Redis 的淘汰算法實際實現(xiàn)上并非針對所有 key,而是抽樣一小部分并且從中選出被淘汰的 key。
no-eviction:禁止驅(qū)逐數(shù)據(jù),也就是說當(dāng)內(nèi)存不足以容納新寫入數(shù)據(jù)時,新寫入操作會報錯。這個應(yīng)該沒人使用吧!
allkeys-lru(least recently used):當(dāng)內(nèi)存不足以容納新寫入數(shù)據(jù)時,在鍵空間中,移除最近最少使用的 key(這個是最常用的)
allkeys-random:從數(shù)據(jù)集(server.db[i].dict)中任意選擇數(shù)據(jù)淘汰
volatile-lru:當(dāng)內(nèi)存不足以容納新寫入數(shù)據(jù)時,在設(shè)置了過期時間的鍵空間中,移除最近最少使用(時間上最久的)的 key。
volatile-random:當(dāng)內(nèi)存不足以容納新寫入數(shù)據(jù)時,在設(shè)置了過期時間的鍵空間中,隨機移除某個 key。
volatile-ttl:從已設(shè)置過期時間的數(shù)據(jù)集(server.db[i].expires)中挑選將要過期的數(shù)據(jù)淘汰
redis 4.0 版本后增加以下兩種(針對最少使用的淘汰機制):
volatile-lfu(least frequently used):從已設(shè)置過期時間的數(shù)據(jù)集(server.db[i].expires)中挑選最不經(jīng)常(用的頻率最低的淘汰)使用的數(shù)據(jù)淘汰
allkeys-lfu(least frequently used):當(dāng)內(nèi)存不足以容納新寫入數(shù)據(jù)時,在鍵空間中,移除最不經(jīng)常使用的 key
ps:MySQL?有2000w數(shù)據(jù),Redis中只存20w的數(shù)據(jù),如何保證Redis中的數(shù)據(jù)都是熱點數(shù)據(jù)?如何提高緩存命中率?
- 使用 Redis 緩存數(shù)據(jù)時,為了提高緩存命中率,需要保證緩存數(shù)據(jù)都是熱點數(shù)據(jù)。可以將內(nèi)存最大使用量設(shè)置為熱點數(shù)據(jù)占用的內(nèi)存量,然后啟用 allkeys-lru 淘汰策略,將最近最少使用的數(shù)據(jù)淘汰。
緩存擊穿(緩存雪崩的另一個場景,熱點數(shù)據(jù)在某一時刻過期失效)
問題描述:對于一些設(shè)置了過期時間的key,當(dāng)redis緩存中有一個key是大量請求同時訪問的熱點數(shù)據(jù),如果突然這個key時間到了,那么大量的請求在緩存中獲取不到該key,穿過緩存直接來到數(shù)據(jù)庫導(dǎo)致數(shù)據(jù)庫崩潰,這樣因為單個key失效而穿過緩存到數(shù)據(jù)庫稱為緩存擊穿。
- 相比于緩存雪崩是大量key在同一時間過期引發(fā)的問題,緩存擊穿強調(diào)的是某一熱點key過期的瞬間引發(fā)的問題。兩者都是由key過期導(dǎo)致大量并發(fā)請求直接到數(shù)據(jù)庫。
熱點緩存失效解決方案
可以使用互斥鎖避免大量請求同時落到db。對緩存查詢加鎖,如果KEY不存在,就加鎖,然后查DB入緩存,然后解鎖;其他進(jìn)程如果發(fā)現(xiàn)有鎖就等待,然后等解鎖后返回數(shù)據(jù)或者進(jìn)入DB查詢。
布隆過濾器,判斷某個容器是否在集合中,例如請求的參數(shù)不合法(請求參數(shù)不存在等)。
可以將熱點數(shù)據(jù)設(shè)置為永不過期。
做好熔斷、降級,防止系統(tǒng)崩潰。
ps:上述兩種問題,針對redis服務(wù)器不可用情況:
采用 Redis 集群,避免單機出現(xiàn)問題整個緩存服務(wù)都沒辦法使用。
限流,避免同時處理大量的請求。
緩存穿透
問題描述:緩存穿透是指查詢一個一定不存在的數(shù)據(jù)。由于緩存不命中,并且出于容錯考慮,如果從數(shù)據(jù)庫查不到數(shù)據(jù),則不寫入緩存,這將導(dǎo)致這個不存在的數(shù)據(jù)每次請求都要到數(shù)據(jù)庫去查詢,失去了緩存的意義。這樣,如果請求的數(shù)據(jù)在緩存大量不命中,導(dǎo)致大量的請求走向數(shù)據(jù)庫,就很可能將數(shù)據(jù)庫搞垮,導(dǎo)致整個服務(wù)癱瘓。這種通常是惡意查詢和被攻擊幾率較大。
- 擊穿和穿透不同,穿透是key不存在,可以理解為直接繞過redis緩存去使得數(shù)據(jù)庫崩掉。而擊穿可以理解為擊穿緩存,這種通常為大量并發(fā)對熱點key(常用的)進(jìn)行大規(guī)模的讀寫操作導(dǎo)致數(shù)據(jù)庫崩潰。
解決方案:
接口層增加校驗:如用戶鑒權(quán)校驗,key值基礎(chǔ)校驗,id<=0的直接攔截;
從緩存取不到的數(shù)據(jù),在數(shù)據(jù)庫中也沒有取到,這時也可以將key-value對寫為key-null(即將查到的null設(shè)置為該key的緩存對象),緩存有效時間可以設(shè)置短點,如30秒(設(shè)置太長會導(dǎo)致正常情況也沒法使用)。這樣可以防止攻擊用戶反復(fù)用同一個id暴力攻擊;
采用布隆過濾器,將所有可能存在的數(shù)據(jù)哈希到一個足夠大的 bitmap 中,一個一定不存在的數(shù)據(jù)會被這個 bitmap 攔截掉,從而避免了對底層存儲系統(tǒng)的查詢壓力。
ps:布隆過濾器是一個非常神奇的數(shù)據(jù)結(jié)構(gòu),通過它我們可以非常方便地判斷一個給定數(shù)據(jù)是否存在于海量數(shù)據(jù)中。我們需要的就是判斷 key 是否合法,有沒有感覺布隆過濾器就是我們想要找的那個“人”。
具體是這樣做的:把所有可能存在的請求的值都存放在布隆過濾器中,當(dāng)用戶請求過來,先判斷用戶發(fā)來的請求的值是否存在于布隆過濾器中。不存在的話,直接返回請求參數(shù)錯誤信息給客戶端(無效的請求),存在的話才會走下面的流程。
但是,需要注意的是布隆過濾器可能會存在誤判的情況??偨Y(jié)來說就是: 布隆過濾器說某個元素存在,小概率會誤判。布隆過濾器說某個元素不在,那么這個元素一定不在。
為什么會出現(xiàn)誤判的情況呢? 我們還要從布隆過濾器的原理來說!
我們先來看一下,當(dāng)一個元素加入布隆過濾器中的時候,會進(jìn)行哪些操作:
- 使用布隆過濾器中的哈希函數(shù)對元素值進(jìn)行計算,得到哈希值(有幾個哈希函數(shù)得到幾個哈希值)。
- 根據(jù)得到的哈希值,在位數(shù)組中把對應(yīng)下標(biāo)的值置為 1。
我們再來看一下,當(dāng)我們需要判斷一個元素是否存在于布隆過濾器的時候,會進(jìn)行哪些操作:
- 對給定元素再次進(jìn)行相同的哈希計算;
- 得到值之后判斷位數(shù)組中的每個元素是否都為 1,如果值都為 1,那么說明這個值在布隆過濾器中,如果存在一個值不為 1,說明該元素不在布隆過濾器中。
然后,一定會出現(xiàn)這樣一種情況:不同的字符串可能哈希出來的位置相同。 (優(yōu)化方案:可以適當(dāng)增加位數(shù)組大小或者調(diào)整我們的哈希函數(shù)來降低概率)
更多關(guān)于布隆過濾器的內(nèi)容參考:《不了解布隆過濾器?一文給你整的明明白白!》
緩存與數(shù)據(jù)庫雙寫一致性
問題描述:從理論上來說,只要我們設(shè)置了鍵的過期時間,我們就能夠保證緩存和數(shù)據(jù)庫的數(shù)據(jù)最終一致性。因為只要緩存數(shù)據(jù)過期了,就會被刪除,下次讀的時候因為緩存里面沒有,就會從數(shù)據(jù)庫中查詢并更新到緩存中。但是,在緩存數(shù)據(jù)沒過期的時間內(nèi),緩存數(shù)據(jù)和數(shù)據(jù)庫數(shù)據(jù)是不同步的。
怎樣保證在寫入數(shù)據(jù)庫的同時,同步更新緩存中的數(shù)據(jù)。就是緩存與數(shù)據(jù)庫雙寫一致性問題。
對于讀操作,流程是這樣的:如果我們的數(shù)據(jù)在緩存里面有,那就直接讀取緩存的數(shù)據(jù);如果緩存里面沒有,則先去查詢數(shù)據(jù)庫,然后將數(shù)據(jù)庫查出來的數(shù)據(jù)寫入到緩存中,最后再將數(shù)據(jù)返回給請求。
如果僅僅只是查詢的話,緩存的數(shù)據(jù)和數(shù)據(jù)庫的數(shù)據(jù)都是沒問題的。但是,當(dāng)我們要更新的時候,有一些情況就很可能造成數(shù)據(jù)庫和緩存的數(shù)據(jù)不一致了。舉個例子,數(shù)據(jù)庫的庫存值是999,但是緩存的庫存值是1000,那么很可能在一段時間內(nèi),頁面拿到的是緩存1000的值,盡管實際上的庫存是999(數(shù)據(jù)庫的值)。
怎樣解決緩存與數(shù)據(jù)庫雙寫一致性問題?
方案:解決思路基本上都是刪除緩存。因為這樣的話,下一次讀就會到數(shù)據(jù)庫中讀到緩存中,保證緩存的一致性。就算數(shù)據(jù)庫更新操作失敗了,也不會有緩存數(shù)據(jù)與數(shù)據(jù)庫數(shù)據(jù)不一致的問題,即使緩存數(shù)據(jù)和數(shù)據(jù)庫數(shù)據(jù)都是舊數(shù)據(jù)。只是刪除緩存的時機不同會引發(fā)不同的問題。
先更新數(shù)據(jù)庫,再刪除緩存??赡艹霈F(xiàn)刪除緩存失敗導(dǎo)致不一致。
先刪除緩存,再更新數(shù)據(jù)庫??赡艹霈F(xiàn)讀取臟數(shù)據(jù),即在更新數(shù)據(jù)庫之前讀到數(shù)據(jù)。
寫請求先將緩存修改為指定值,再更新數(shù)據(jù)庫,再更新緩存。讀請求過來之后,先讀緩存,判斷是指定值,則進(jìn)入等待狀態(tài),等待寫請求更新緩存之后再讀緩存。如果等待超時,則直接到數(shù)據(jù)庫中讀取數(shù)據(jù),更新緩存。這種方案可以保證讀寫的一致性,但是因為讀請求需要等待寫請求的完成(串行化了),降低了吞吐量。
如果為了短時間的不一致性問題,選擇讓系統(tǒng)設(shè)計變得更加復(fù)雜的話,完全沒必要。這里聊聊,Cache Aside Pattern(旁路緩存模式)更新數(shù)據(jù)庫,刪除緩存,如果數(shù)據(jù)庫刪除成功,緩存刪除失敗,解決方案:
- 緩存失效時間變短(不推薦,治標(biāo)不治本) :我們讓緩存數(shù)據(jù)的過期時間變短,這樣的話緩存就會從數(shù)據(jù)庫中加載數(shù)據(jù)。另外,這種解決辦法對于先操作緩存后操作數(shù)據(jù)庫的場景不適用。
- 增加 cache 更新重試機制(常用): 如果 cache 服務(wù)當(dāng)前不可用導(dǎo)致緩存刪除失敗的話,我們就隔一段時間進(jìn)行重試,重試次數(shù)可以自己定。如果多次重試還是失敗的話,我們可以把當(dāng)前更新失敗的 key 存入隊列中,等緩存服務(wù)可用之后,再將 緩存中對應(yīng)的 key 刪除即可。
ps:為什么是刪除緩存,而不是更新緩存?
(1)緩存可能復(fù)雜:很多時候,在復(fù)雜點的緩存場景,緩存不單單是數(shù)據(jù)庫中直接取出來的值。
(2)更新代價高:另外更新緩存的代價有時候是很高的。
總結(jié):
一般來說,如果允許緩存可以稍微的跟數(shù)據(jù)庫偶爾有不一致的情況,最好不要做這個方案,即:讀請求和寫請求串行化,串到一個內(nèi)存隊列里去。
串行化可以保證一定不會出現(xiàn)不一致的情況,但是它也會導(dǎo)致系統(tǒng)的吞吐量大幅度降低,用比正常情況下多幾倍的機器去支撐線上的一個請求。
補充
什么是緩存預(yù)熱?
緩存預(yù)熱是一個比較常見的概念,就是指在系統(tǒng)上線后,先將相關(guān)的緩存數(shù)據(jù)直接加載到緩存系統(tǒng)。這樣,用戶請求的時候就不需要先去查詢數(shù)據(jù)庫,再將數(shù)據(jù)放入緩存了,用戶可以直接拿到實現(xiàn)被預(yù)熱的緩存數(shù)據(jù)。
什么是緩存(熔斷)降級?
熔斷機制:“我們提供過載保護。當(dāng)某個服務(wù)故障或者異常發(fā)生時,若這個異常條件需要我們處理,我們會采取一些保護措施---直接熔斷整個服務(wù),而不是一直等到此服務(wù)超時,從而防止整個系統(tǒng)的故障?!?/p>
什么是緩存降級?
當(dāng)訪問量劇增,服務(wù)出現(xiàn)問題(比如響應(yīng)慢或不響應(yīng))或非核心服務(wù)影響到核心流程的性能時。仍然需要保證服務(wù)還是可用的,及時是有損服務(wù)。系統(tǒng)可以根據(jù)一些關(guān)鍵數(shù)據(jù)進(jìn)行自動降級,也可以配置開關(guān)實現(xiàn)人工降級。
關(guān)鍵點:降級的最終目的是保證核心服務(wù)可用,即使是有損的。而且有些服務(wù)是無法降級的,比如加入購物車、結(jié)算等服務(wù)。
在進(jìn)行降級之前,要先對系統(tǒng)進(jìn)行梳理,看看系統(tǒng)是不是可以棄帥保車,進(jìn)而梳理出哪些是核心服務(wù)(不可降級),哪些是非核心服務(wù)(可降級)。
拿日志級別設(shè)置預(yù)案作為參考:
一般級別。比如某些服務(wù)偶爾因為網(wǎng)絡(luò)抖動或者服務(wù)正在上線而超時,就可以自動降級。
警告級別。有些服務(wù)在一段時間內(nèi)成功率有波動(比如在95~100%之間),就可以自動降級或人工降級,并發(fā)送警告。
錯誤級別。比如可用率低于90%,或者數(shù)據(jù)庫連接池被打爆了,或者訪問量突然猛增到系統(tǒng)能承受的最大閥值,此時可以根據(jù)情況自動降級或者人工降級。
嚴(yán)重錯誤級別。比如因為特殊原因數(shù)據(jù)錯誤了,此時就需要緊急人工降級。
巨人的肩膀:
https://www.cnblogs.com/yanggb/p/11110706.html
https://blog.csdn.net/qq_38550836/article/details/108044871