緩存使用及優化方案
關於緩存
在計算機技術裏,大家對於緩存一詞肯定不陌生,CPU有緩存、數據庫有緩存、靜態資源緩存CDN、Redis等等;
在這裏我們談的主要是服務器緩存技術,服務端性能優化,最常用的手段就是緩存;
一般來說,緩存作用是把 熱數據/結果數據 存放在讀取速度更快的地方(內存),使程序可以節省大量讀取時間,從而更快速地加載處理;
緩存主要分爲本地緩存和遠程緩存兩種;
本地緩存:YAC(PHP)
遠程緩存:Redis、memcache等
本地緩存:
本地緩存是應用程序在同一服務器內的緩存,優點是耗時極低,缺點是佔用本地內存、多機冗餘、數據不同步;
以PHP的YAC爲例:
Yac 是爲PHP實現的一個基於共享內存,無鎖的內容Cache;
假設PHP以PHP-FPM運行,Yac和Pcache緩存的用戶內容User Cache就像Opcache一樣,保存在PHP-FPM佔用的內存中,下一次腳本可以直接從PHP-FPM中讀取數據;
httpd_mod-php同理,而Memcached/Redis需要通過網絡(端口)才能訪問數據;
使用本地緩存需要注意解決消息的一致性問題;
遠程緩存:
Memcached、Redis都屬於遠程緩存,基於tcp傳輸數據,所以有一定的網絡開銷;
優點是可以實現分佈式集羣,有更強的一致性,可以用作大規模緩存的方案;
緩存使用
緩存使用場景:
二八定律中的 “二” ,熱點數據;
經過複雜運算後得到的可以複用的數據;
某些需要頻繁加載的配置信息;
等等…
本地緩存存放更新頻率低,但請求量很高的數據,因爲本地緩存速度更快,但儲存空間有限;
對於更新頻率很高的數據應該由遠程分佈式緩存來承擔;
把一些不易改變且訪問量巨大的數據緩存在本地,通過多級緩存的模式,從而提升系統性能;
緩存數據更新策略
當發生變更的時候,直接採取數據庫和redis緩存雙寫的方案,讓緩存時效性最高:
經典的緩存+數據庫讀寫的模式:
- 讀的時候,先讀緩存,緩存沒有的話,那麼就讀數據庫,然後取出數據後放入緩存,同時返回響應
- 更新的時候,更新數據庫,刪除緩存
當發生變更之後,採取MQ異步通知的方式,通過數據生產服務來監聽MQ消息,然後異步去拉取服務的數據更新本地緩存和遠程緩存:
MQ更新模式:
- 通過mq的訂閱模式(區別於隊列模式),來解決多節點的分發;
- 節點進行監控,通過寫入監控數據,然後統一採集分析線上緩存的使用情況,如:命中率,調用次數,緩存堆大小,存儲層命中數等
類似做法:
MySQL binlog增量訂閱消費+消息隊列+處理並把數據更新到redis
緩存冷啓動/預熱
當系統第一次啓動,大量請求涌入,此時的緩存爲空,可能會導致數據庫崩潰,進而讓系統不可用,同樣當redis所有緩存數據異常丟失,也會導致該問題;
因此,可以提前放入數據到redis避免上述冷啓動的問題,當然也不可能是全量數據,可以統計出訪問頻率較高的熱數據,這裏熱數據也比較多,需要多個服務並行的分佈式去讀寫到redis中;
緩存穿透與雪崩
緩存穿透
緩存穿透是指查詢沒有命中各級緩存,直接穿透到數據存儲層進行查詢,一般存儲層無法直接應對高併發的查詢,從而導致存儲層負載異常;
在程序中可以通過統計總調用數、緩存層命中數、存儲層命中數等數據,來監測緩存命中率及穿透情況;
解決方法:
1)緩存空對象
存儲層不命中後,仍然將空對象保留到緩存層中,之後再訪問這個數據將會從緩存中獲取,保護了後端數據源
緩存空對象會有兩個問題:
第一,空值做了緩存,意味着緩存層中存了更多無效的數據,需要更多的內存空間,比較有效的方法是針對這類數據設置一個較短的過期時間,讓其自動剔除;
第二,如果存儲層數據此時更新,緩存層和存儲層的數據會有一段時間窗口的不一致,可能會對業務有一定影響,這個時候應考慮數據一致性問題;
2)布隆過濾器攔截
對一定不存在的key進行過濾,可以把所有存在的key放到一個大bitmap中,查詢時通過該bitmap過濾;
這種方法適用於數據命中不高,數據相對固定、實時性低(通常是數據集較大)的應用場景,代碼維護較爲複雜
緩存雪崩
針對造成服務雪崩的不同原因, 可以使用不同的應對策略:
流量控制
網關限流(Nginx+Lua/OpenResty)
用戶交互限流(1. 採用加載動畫,提高用戶的忍耐等待時間 2. 提交按鈕添加強制等待時間機制)
關閉重試
改進緩存模式
緩存預加載
同步改爲異步刷新
服務自動擴容
服務調用者降級服務
資源隔離(主要是對調用服務的線程池進行隔離,避免所有資源都等待)
對依賴服務進行分類(強依賴服務不可用會導致當前業務中止,而弱依賴服務的不可用不會導致當前業務的中止)
不可用服務的調用快速失敗(超時機制,熔斷器 和熔斷後的 降級方法 )
資源降級(如:個性化推薦服務不可用,可以降級補充熱點數據)
這裏我們特指緩存穿透導致的雪崩:
由於緩存層承載着大量請求,有效的保護了存儲層,但是如果緩存層由於某些原因整體不能提供服務,於是所有的請求都會達到存儲層,存儲層的調用量會暴增,造成存儲層宕機的情況
解決方案:
1)保證緩存層服務高可用性
- 和飛機都有多個引擎一樣,如果緩存層設計成高可用的,即使個別節點、個別機器、甚至是機房宕掉,依然可以提供服務;
如:高可用架構的redis cluster集羣,主從架構、一主多從,一旦主節點宕機,從節點自動跟上,並且最好使用雙機房部署集羣;
- 遠程緩存結合本地緩存使用,在redis全部失效的情況下依然能夠靠本地緩存抗住部分壓力;
2)依賴隔離組件爲後端限流並降級
- 無論是緩存層還是存儲層都會有出錯的概率,可以將它們視同爲資源;
作爲併發量較大的系統,假如有一個資源不可用,可能會造成線程全部 hang 在這個資源上,造成整個系統不可用;
降級在高併發系統中是非常正常的:比如推薦服務中,如果個性化推薦服務不可用,可以降級補充熱點數據,不至於造成前端頁面是開天窗;
- 在實際項目中,我們需要對重要的資源 ( 例如 Redis、 MySQL、 Hbase、外部接口 ) 都進行隔離;
讓每種資源都單獨運行在自己的線程池中,即使個別資源出現了問題(單獨資源的線程異常),對其他服務沒有影響,避免所有資源都等待;
但是線程池如何管理,比如如何關閉資源池,開啓資源池,資源池閥值管理,這些做起來還是相當複雜的,可以選擇開源組件進行管理(如Hystrix)
3)提前演練
在項目上線前,演練緩存層宕掉後,應用以及後端的負載情況以及可能出現的問題,在此基礎上做一些預案設定;
緩存熱點key重建優化
開發人員使用緩存 + 過期時間的策略既可以加速數據讀寫,又保證數據的定期更新,這種模式基本能夠滿足絕大部分需求;
但是有兩個問題如果同時出現,可能就會對應用造成致命的危害:
- 當前 key 是一個熱點 key( 例如一個熱門的娛樂新聞),併發量非常大;
- 重建緩存不能在短時間完成,可能是一個複雜計算,例如複雜的 SQL、多次 IO、多個依賴等;
在緩存失效的瞬間,有大量線程來重建緩存,造成後端負載加大,甚至可能會讓應用崩潰;
要解決這個問題也不是很複雜,但是不能爲了解決這個問題給系統帶來更多的麻煩,所以需要制定如下目標:
減少重建緩存的次數
數據儘可能一致
較少的潛在危險
1)互斥鎖 (mutex key)
此方法只允許一個線程重建緩存,其他線程等待重建緩存的線程執行完,重新從緩存獲取數據即可
(1) 從 Redis 獲取數據,如果值不爲空,則直接返回值,否則執行 (2.1) 和 (2.2)。
(2.1) 如果 set(nx 和 ex) 結果爲 true,說明此時沒有其他線程重建緩存,那麼當前線程執行緩存構建邏輯。
(2.2) 如果 setnx(nx 和 ex) 結果爲 false,說明此時已經有其他線程正在執行構建緩存的工作,那麼當前線程將休息指定時間後,重新執行函數,直到獲取到數據
2)永遠不過期
“永遠不過期”包含兩層意思:
從緩存層面來看,確實沒有設置過期時間,所以不會出現熱點 key 過期後產生的問題,也就是“物理”不過期。
從功能層面來看,爲每個 value 設置一個邏輯過期時間,當發現超過邏輯過期時間後,會使用單獨的線程去構建緩存。
此方法有效杜絕了熱點 key 產生的問題,但唯一不足的就是重構緩存期間,會出現數據不一致的情況
互斥鎖 (mutex key):
這種方案思路比較簡單,但是存在一定的隱患,如果構建緩存過程出現問題或者時間較長,可能會存在死鎖和線程池阻塞的風險,但是這種方法能夠較好的降低後端存儲負載並在一致性上做的比較好;
” 永遠不過期 “:
這種方案由於沒有設置真正的過期時間,實際上已經不存在熱點 key 產生的一系列危害,但是會存在數據不一致的情況,同時代碼複雜度會增大,維護成本也會增大;
Redis的鎖實現
redis能用的的加鎖命令分別是INCR、SETNX、SET
一、INCR
這種加鎖的思路是, key 不存在,那麼 key 的值會先被初始化爲 0 ,然後再執行 INCR 操作進行加一。
然後其它用戶在執行 INCR 操作進行加一時,如果返回的數大於 1 ,說明這個鎖正在被使用當中。
1、 客戶端A請求服務器獲取key的值爲1表示獲取了鎖
2、 客戶端B也去請求服務器獲取key的值爲2表示獲取鎖失敗
3、 客戶端A執行代碼完成,刪除鎖
4、 客戶端B在等待一段時間後在去請求的時候獲取key的值爲1表示獲取鎖成功
5、 客戶端B執行代碼完成,刪除鎖
$redis->incr($key);
$redis->expire($key, $ttl); //設置生成時間爲1秒
二、SETNX
這種加鎖的思路是,如果 key 不存在,將 key 設置爲 value
如果 key 已存在,則 SETNX 不做任何動作
1、 客戶端A請求服務器設置key的值,如果設置成功就表示加鎖成功
2、 客戶端B也去請求服務器設置key的值,如果返回失敗,那麼就代表加鎖失敗
3、 客戶端A執行代碼完成,刪除鎖
4、 客戶端B在等待一段時間後在去請求設置key的值,設置成功
5、 客戶端B執行代碼完成,刪除鎖
$redis->setNX($key, $value);
$redis->expire($key, $ttl);
三、SET
上面兩種方法都有一個問題,會發現,都需要設置 key 過期。那麼爲什麼要設置key過期呢?如果請求執行因爲某些原因意外退出了,導致創建了鎖但是沒有刪除鎖,那麼這個鎖將一直存在,以至於以後緩存再也得不到更新。於是乎我們需要給鎖加一個過期時間以防不測。
但是藉助 Expire 來設置就不是原子性操作了。所以還可以通過事務來確保原子性,但是還是有些問題,所以官方就引用了另外一個,使用 SET 命令本身已經從版本 2.6.12 開始包含了設置過期時間的功能。
1、 客戶端A請求服務器設置key的值,如果設置成功就表示加鎖成功
2、 客戶端B也去請求服務器設置key的值,如果返回失敗,那麼就代表加鎖失敗
3、 客戶端A執行代碼完成,刪除鎖
4、 客戶端B在等待一段時間後在去請求設置key的值,設置成功
5、 客戶端B執行代碼完成,刪除鎖
$redis->set($key, $value, array('nx', 'ex' => $ttl)); //ex表示秒
注意問題:
問題1 redis發現鎖失敗了要怎麼辦?中斷請求還是循環請求?
問題2 循環請求的話,如果有一個獲取了鎖,其它的在去獲取鎖的時候,是不是容易發生搶鎖的可能?
問題3 鎖提前過期後,客戶端A還沒執行完,然後客戶端B獲取到了鎖,這時候客戶端A執行完了,會不會在刪鎖的時候把B的鎖給刪掉?
解決辦法:
針對問題1:使用循環請求,循環請求去獲取鎖
針對問題2:針對第二個問題,在循環請求獲取鎖的時候,加入睡眠功能,等待幾毫秒在執行循環
針對問題3:在加鎖的時候存入的key是隨機的。這樣的話,每次在刪除key的時候判斷下存入的key裏的value和自己存的是否一樣
do { //針對問題1,使用循環
$timeout = 10;
$roomid = 10001;
$key = 'room_lock';
$value = 'room_'.$roomid; //分配一個隨機的值針對問題3
$isLock = Redis::set($key, $value, 'ex', $timeout, 'nx');//ex 秒
if ($isLock) {
if (Redis::get($key) == $value) { //防止提前過期,誤刪其它請求創建的鎖
//執行內部代碼
Redis::del($key);
continue;//執行成功刪除key並跳出循環
}
} else {
usleep(5000); //睡眠,降低搶鎖頻率,緩解redis壓力,針對問題2
}
} while(!$isLock);
以上的鎖完全滿足了需求,但是官方另外還提供了一套加鎖的算法,這裏以PHP爲例
$servers = [
['127.0.0.1', 6379, 0.01],
['127.0.0.1', 6389, 0.01],
['127.0.0.1', 6399, 0.01],
];
$redLock = new RedLock($servers);
//加鎖
$lock = $redLock->lock('my_resource_name', 1000);
//刪除鎖
$redLock->unlock($lock)
參考:
http://www.cocoachina.com/ios/20180309/22527.html
https://segmentfault.com/a/1190000008931971
https://mp.weixin.qq.com/s/TBCEwLVAXdsTszRVpXhVug
https://www.cnblogs.com/luyulong/p/5430803.html
https://segmentfault.com/a/1190000005988895
鎖參考:https://blog.csdn.net/Dennis_ukagaka/article/details/78072274