網上看了很多解決緩存擊穿的方案,
我覺得不夠好,自己總結了一番
本文儘量使用大白話,儘量不寫代碼,請認真讀
希望能讓你們滿意
徹底解決redis緩存擊穿問題
1. redis的緩存擊穿是什麼?
如果我有一個業務,需要查詢數據庫,這個查詢很耗時,
且業務上來看這個要非常頻繁的取查詢它
那麼通常我可以把查詢的結果保存在redis,設置一個符合業務的過期時間
然後以後的查詢都直接查redis
redis的高QPS特性,可以很好的解決查數據庫很慢的問題
隱患:
如果我們系統的併發很高,
在某個時間節點,突然緩存失效,這時候有大量的請求打過來
那麼由於redis沒有緩存數據,這時候我們的請求會全部去查一遍數據庫
這時候我們的數據庫服務會面臨非常大的風險,要麼連接被佔滿
要麼其他業務不可用
這種情況就是redis的緩存擊穿
2. 如何解決緩存擊穿
解決緩存擊穿換句話說,就是要保證最終落在硬盤上的查詢操作要可控
注意這裏使用的是可控,而不是少
2.1 普通的redis緩存使用方式
先來看看普通的redis緩存設計
2.1.1 使用redis緩存
- 比較簡單的設計,可以在數據需要的時候再加載
查詢邏輯
由此圖可以看出,查詢是隻有兩種情況的:
- 緩存命中,直接返回緩存結果
- 緩存未命中,查數據庫,再返回結果
優點
- 設計簡單,開發效率高
- 不會侵入業務代碼,spring的aop就能很好的實現
缺點
- 一旦redis的數據失效,那麼假如這時候有10w個請求打過來,由於redis沒有緩存,那麼按照緩存設計的邏輯,就會全部去查數據庫
適用情況
- 如果一個系統是內部系統,天生沒有太多的用戶量,而某個接口需要非常耗時的查詢,而數據變化又不會太頻繁,就比較適用這種設計, 沒有太多用戶,就不會造成緩存失效的時候大量請求壓到數據庫的問題
2.2 解決redis緩存擊穿問題
下面我們來看看有哪些設計可以解決緩存擊穿的問題
下面的設計都解決了緩存擊穿的問題
只是他們的設計各有利弊,我們需要在不同情況下來使用
2.2.1 主動刷新緩存設計
如果我們的數據是保存在redis緩存中,而redis緩存失效之後一定會查數據庫
那麼我們是否可以主動出擊,在redis緩存失效之前,主動查數據庫?
查詢邏輯
- 先將所有可能查詢到的數據存入redis
- 對redis中的數據庫定時更新,保證redis永遠都會有數據存在
- 來請求只查redis
優點
- 用戶的請求壓力永遠不會直接打到數據庫上
- 查詢效率很高
缺點
- 可能對redis內存消耗非常大,因爲要提前將數據加載到緩存
- 增加了系統的複雜度,必須要有個非常可靠的定時任務操作,不然一旦定時任務失效,那麼redis中的數據失效,對於用戶來說就是服務不可用
- 數據的實時性非常依賴定時任務的執行頻率,定時任務執行的頻率高,實時性就強
- 如果刷新緩存的間隔設置很長,那麼數據實時性就不夠好,
- 如果刷新緩存的間隔很短,那麼頻繁的全量刷數據庫到緩存對系統和數據庫都是壓力,也會讓數據庫和應用服務器的負載變得不夠平穩
- 由於是隻查詢緩存,所以會對業務代碼進行較大程度的改動,後期業務變化,可能會非常難以維護
適用情況
符合以下條件,那麼我們可以使用這種設計
- 已有一套現成的高可靠分佈式定時任務系統
- 查詢的數據變化不大
- 用戶的請求量非常大的情況下
2.2.2 使用redis的分佈式鎖
對 2.1.1 使用redis緩存 的設計進行一些改動
讓我們對數據庫的重複查詢操作變爲1次
既然我們使用了redis,那麼可以利用redis實現的分佈式鎖setnx
來實現互斥的數據庫操作
查詢邏輯
- 如果緩存命中直接返回數據集
- 如果緩存沒有,則嘗試獲取分佈式鎖(有超時設置)
- 如果沒有拿到鎖,則阻塞當前線程,n秒,之後再次嘗試獲取分佈式鎖(自旋,輪詢,浪費CPU)
- 拿到鎖之後檢查數據是否已經被其他線程放到redis緩存中,如果redis緩存已有,直接返回redis中的數據,釋放分佈式鎖
- 如果緩存沒有被刷新,則查數據庫
- 將數據庫查詢的結果保存到redis緩存中
- 返回查詢結果
優點
- 數據的實時性較高
- 不需要其他外部系統依賴,利用了redis自己的特性,實現分佈式鎖
- 保證了同樣的數據庫查詢同時只會查詢1次,對數據庫的壓力較小
- 不會侵入業務代碼,spring的aop就能很好的實現
缺點
- 由於阻塞等待分佈式鎖是個自旋阻塞操作,所以其實對應用服務器來說非常浪費cpu的分片時間
- 如果這時候大量請求打過來, 應用服務器反而會先扛不住,因爲這裏會有大量的線程在自旋佔用CPU
- 如果用戶的查詢是由多個系統的結果構成,每個系統的查詢依賴上一個系統查詢的結果,各個查詢是串行的,那麼自旋的睡眠時間可能會成爲拖慢請求的罪魁禍首,多個系統都這麼設計都在自旋睡眠,明顯效率很低
適用情況
這種方法也是網上給的最多的方法
如果要求保證數據庫的壓力特別小,同樣的請求只能查詢一次數據庫,
而且服務器較多,足以將多個請求分散到不同服務器,不至於造成太多線程自旋,
那麼可以使用這樣的設計,但不推薦,因爲這種自旋操作真的不是個好設計
2.2.3 普通加jvm的鎖查詢緩存
上面分佈式鎖自旋的方法,真的不優雅
這時候我們需要反問一下自己,每個請求,真的只能查詢一次數據庫嗎?數據庫的壓力已經大到如此地步了嗎?
如果不是
那麼下面還有更加合適的設計
不再強求相同的查詢只能查一次數據庫
查詢邏輯
- 如果緩存命中直接返回數據集
- 如果緩存沒有,則嘗試JVM鎖,其他線程阻塞
- 拿到鎖之後,檢查redis是否有數據,以免其他線程已經刷過緩存
- 如果redis已經有數據,直接返回,並釋放鎖,返回數據庫結束
- 如果redis沒有數據,則查詢數據庫,並保存到redis緩存中
- 返回數據,釋放鎖
設有s
臺服務器,用戶請求數爲n
那麼同一時間參數相同的請求最多隻會有s
次查詢打到數據庫上,這裏s
這個常量
相當於原來對於數據庫來說一個O(n)
的操作時間下降到了O(s)
這裏可以看出,查詢數據庫操作的耗時與n
的增長無關,只與s
有關
想象一下,我們有4臺服務器,本來打到數據庫上可能有10w個查詢,但是因爲我們使用了jvm的鎖,每臺服務器只會查詢一次,總的數據庫查詢次數下降到了4次,是不是很高效?而且jvm提供的鎖一定比redis分佈式鎖自旋輪詢高效太多!
優點
- 數據的實時性較高
- 相對於使用redis分佈式鎖,大幅降低服務器資源的消耗,jvm的鎖效率要高很多
- 對於數據庫的消耗較小,是一個和服務器數量
s
相關的耗時操作,與請求數量n
無關(n
可能會很大,十萬,百萬級別,而s
可能最多兩位數) - 如果mysql數據庫版本較低,說不定還能利用上mysql數據庫的緩存,如果是個不頻繁更新的表,運氣好的情況下
s-1
次的查詢可能都會命中mysql的緩存 - 實現的複雜度低
- 不會侵入業務代碼,spring的aop就能很好的實現
缺點
- 對數據庫查詢雖然減小到了一個只與服務器數量相關的函數,但依然有冗餘(其實也還好了)
適用情況
- 真的需要強求,所有服務器只查一次緩存嗎?
- 如果能容忍較少次數的數據庫重複查詢
- 這種設計就用這種就已經能很好的解決緩存穿透的問題了,而且設計簡單複雜度低
- 複雜度低意味着系統的穩定
2.2.4 多級緩存
如果寧非要強求,數據庫同一時間不能收到重複的查詢,那麼也不是沒有辦法,往下看
查詢邏輯
查詢的邏輯看圖吧,我懶得一步一步說了,一圖勝錢言
二級緩存的關鍵在於:
- jvm的緩存時間是個隨機值,比如 10秒~30秒
- 這種設計,服務器只會在jvm緩存失效,且redis緩存也失效的情況下才會查詢數據庫
- 而多個服務器的jvm緩存失效時間是隨機值,所以很大程度上避免的同時失效去查庫的情況
- 由於所有服務器jvm緩存同時失效redis緩存也失效的可能性極低,所以數據庫上重複的查詢會很少
- (不一定是jvm緩存和jvm的鎖啊,python,go同理)
設服務器的臺數爲s
- 如何讓O(s)的問題其變爲O(1)呢?其實也是有辦法的,就是多級緩存
- 就是讓每臺服務器上加一個jvm的緩存在redis之前
- 這個jvm的緩存時間需要設置一個隨機值,比如 緩存時間爲 5s-10s,這樣可以很大程度避免在redis失效的時候,每臺服務器都需要去做更新redis緩存的操作,因爲每個服務器的jvm緩存失效時間是不一樣的
優點
- 數據的實時性較高 (設置合適的jvm緩存過期時間和redis緩存過期時間)
- 幾乎沒有冗餘的數據庫查詢
- 絕大多數查詢是使用的jvm緩存,效率極高
- 對cpu的佔用很低
- 不會侵入業務代碼,spring的aop就能很好的實現
缺點
- 如果查詢的參數離散度較高,其實會很浪費業務服務器的內存空間(但是可以通過減少jvm緩存的時間來優化一點)
- 設計稍微有點複雜,需要有經驗的碼畜來實現
適用情況
幾乎所有情況,強力推薦,我也是這麼做的
2.2.5 其他注意點
以上的這些設計,只是在正常的高併發情況下
如果你的服務器遭遇到了DOS攻擊,那什麼緩存策略都沒用,因爲遲早會把你線程喫滿,然後服務器不可用
這時候你只能在網關或者nginx做一些對ip限流的措施,設置閾值,防止惡意調用接口