對於數據庫的CRUD操做而言,當併發量較大時會出現讀或者寫的瓶頸。對於大多數場景而言,都是讀多寫少,所以讀更容易成爲數據庫的瓶頸。而緩存就是爲了解決讀的問題而出現的。緩存的數據存儲在內存中,所以性能很高。前端
緩存的更新方式從大的方向分能夠分爲同步更新緩存和異步更新緩存redis
同步更新緩存就是寫數據或者讀數據的時候同步更新緩存。spring
讀的時候更新緩存策略很簡單,如上圖所示,主要有如下幾個步驟:數據庫
寫的時候更新緩存與讀的時候更新緩存原理相似,只是在寫數據時候會先寫數據庫,而後寫緩存,而不是刪除緩存。緩存
接下來咱們對比一下這兩種方式的優缺點。bash
讀的時候更新緩存在數據寫入數據庫後只須要刪除緩存便可,操做比較簡單,所以邏輯上會簡單一些,這種方式是最多見的緩存更新方式。可是讀請求的時候要先讀數據庫而後寫入緩存,若是是一個影響很大的更新,那麼緩存失效後的第一次讀請求可能會比較慢。好比常見的好友列表,若是緩存失效,須要從數據庫先從關係鏈表查好友的關係鏈,而後去用戶表查每一個好友的頭像和暱稱,最後將數據還要寫入緩存,這個過程可能會比較耗時。架構
而寫的時候更新緩存,只須要將一樣的更新數據先寫入數據庫,而後寫一遍緩存,不用從數據庫中取出來而後寫入緩存。不過使用這種方式的時候,讀請求的時查詢緩存沒有命中,而後查數據庫的邏輯不能省,由於緩存還會由於過時而失效。併發
這兩種方式都有一個問題,寫請求時寫入數據庫成功,而後同步寫入緩存或者刪除緩存這兩個動做均可能失敗,若是失敗就會致使數據庫中的數據與緩存中的數據不一致。首先,能夠採起重試的策略來儘量減少出現的機率,並且儘可能要給緩存設置一個過時時間,這樣可使緩存中的數據與數據庫中的數據達到最終一致性。異步
同步更新緩存須要在業務邏輯裏單獨處理這一段邏輯,而其自己與業務邏輯是不相關的,咱們只能爲了提高性能而引入了緩存系統。所以能夠考慮經過異步的方式更新緩存,將緩存更新的服務與業務服務進行解耦。並且異步更新的方式,將緩存更新的操做單獨用一個服務來實現,所以讀寫請求減小了緩存更新的邏輯,性能會獲得提高。分佈式
一個簡單的異步緩存更新方案入上圖所示,寫請求寫完數據庫後會拋一個MQ消息,而後有一個獨立的緩存更新服務區接受這個消息,而後從數據庫讀數據並寫入緩存。採用異步的方案之後,數據無需同步寫入,減輕了業務服務的邏輯任務,在業務場景下可能不少個地方都須要更新緩存,採用異步更新發消息很方便。不過這裏須要依賴中間件消息隊列,須要消息隊列能保證不丟消息。緩存更新服務中也會存在緩存更新失敗的狀況,不過咱們能夠採用不斷重試的方案來避免這樣的問題。
可是上面這個設計會有一些問題,主要是在併發狀況下。
問題1:若是先有一個寫請求更新了數據庫的數據,而後拋出一條MQ消息。可是在這個MQ消息被處理前,這時候一條讀請求被髮起了,那麼這個時候讀請求會讀到緩存中的舊數據。
問題2:若是先有一個寫請求更新了數據庫的數據,並拋消息MQ1。而後接着有另外一個寫請求緊跟着也更新了數據,並拋消息MQ2。若是MQ1和MQ2串行執行,那麼就沒有問題。可是分佈式環境下,服務是多機多進程部署,所以MQ2可能比MQ1先被處理。考慮這種極端條件下,若是第二次寫請求前,MQ1的消息已經到達緩存更新服務並從數據庫中取出消息。就在這時,MQ2消息到達被另外一個進程處理,從數據庫中取出數據並先於MQ1消息更新了緩存,而後這時MQ1消息取出的數據寫入緩存就覆蓋了MQ2消息的更新的數據。這時候緩存中的數據也與數據庫中的數據不一致了。
若是對緩存中的數據與數據庫中的數據的一致性要求很是高,能夠引入髒標和版本號的機制來實現。若是徹底不能接受緩存中數據與數據庫數據不一致,就不要使用緩存。
local cache_info = redis.call('GET', KEYS[1])
local cache_version = redis.call('GET', KEYS[2])
if(type(cache_version) ~= 'string' or
type(cache_info) ~= 'string' or
tonumber(cache_version) < tonumber(ARGV[1]))
then
redis.call('SET', KEYS[2], ARGV[1], 'EX', ARGV[3])
return redis.call('SET', KEYS[1], ARGV[2], 'EX', ARGV[3])
else
return 0
end
複製代碼
ps:KEYS[1]是緩存數據的key,KEYS[2]是版本號的key,ARGV[1]是更新後的緩存數據,ARGV[2]是更新後的版本號,ARGV[3]是key的過時時間。
先寫緩存而後異步將數據刷到數據庫的方法與操做系統的文件系統的讀寫核心流程是相同的。對於操做系統的文件系統,因爲內存操做與磁盤操做存在百萬數量級的差異,所以操做系統的文件系統維護了一個高速緩存區來減少這種巨大差距帶來的影響。文件系統讀操做時,先查詢高速緩存區是否存在數據,若是沒有則從磁盤讀入高速緩存區。寫數據時,將數據寫入高速緩存區,系統調用write就返回成功了。而後經過一個名爲update的後臺進程,不斷的調用sync將高速緩存區的內容寫入磁盤。
先寫緩存,而後異步將數據刷到數據庫的方案流程圖以下:
該方案的好處是,讀寫都是走緩存,所以數據極快,能夠應對極高的併發請求。不過這種方案會致使緩存中數據與數據庫中數據存在不一致的時間段,更爲嚴重的是若是機器宕機,還沒寫入數據庫的髒數據會丟失。若是要避免數據丟失,還可使用雙緩存的方案,不過這有會是系統更加複雜,維護一致性更加困難。
通常狀況下查詢數據,數據都是存在的。大部分業務系統都須要給用戶建立一個帳戶,若是一個新用戶去查詢用戶信息,數據庫中不存在這個用戶的信息,系統會返回前端說明是一個新用戶。正常狀況下,這樣沒有問題。若是有人利用這個漏洞,用不少個這種新用戶的帳號,不斷請求用戶系統的接口,全部的請求都會打到DB上,會DB帶來很大的壓力,甚至宕機。
像這種查詢系統中壓根不存在的數據,使請求落到DB上的狀況,被稱爲緩存穿透。
對於緩存穿透經常使用解決方案有兩個:緩存和空值和布隆過濾器
緩存空值的方法,正如其名,當查詢到數據不存在時,向緩存的key中寫入null。當查詢到該key存在,且值爲null時,按數據存在處理。
第二種方案是在前一種方案以前再加一層布隆過濾器,若是布隆過濾器能命中,則查緩存,若是布隆過濾器沒有命中,則直接返回。布隆過濾器的特色是若是數據存在則布隆過濾器必定會命中,若是數據不存在則布隆過濾器絕大多數狀況下不會被命中。所以,即便有部分不存在的數據經過了布隆過濾器的過濾,仍是會被空值緩存攔截住。
第二種方案是在第一種方案的基礎上造成了,所以第二種方案複雜一些,可是若是有大量不存在的數據被緩存會浪費緩存的空間,而布隆過濾器能過濾掉絕大多數這樣的狀況。所以,若是爲null的key的數量不是不少,直接用第一種方法便可,反之,若是爲null的key的數量不少,則建議加一層布隆過濾器。
在高併發下,當緩存數據失效的一瞬間,這時全部的請求都會打到DB上,形成DB瞬時壓力陡增,這就是緩存洞穿。
防止緩存洞穿的方法是當發現緩存失效時,在查詢DB以前先加鎖,這樣第一個取到鎖的線程更新緩存,其餘線程由於取不到鎖會等待。等到一個線程更新緩存成功後,其餘線程就能夠從緩存中查詢信息了。
緩存雪崩是指同一時間緩存大規模失效,致使請求都直接打到DB上,瞬間的流量將DB打掛,致使整個系統崩潰,這種狀況就是緩存雪崩。好比緩存機器宕機或者重啓時均可能致使緩存雪崩。
對於緩存雪崩首先採用緩存集羣的方案來增長容錯性,若是使用redis作緩存,可使用主從+哨兵的部署來方案來提升可用性,避免緩存大量失效的問題發生。
對於微服務架構,雪崩已經發生的狀況,可使用開源的Hystrix實現降級和限流,避免DB宕機。可是Hystrix不具有很好的通用性,對於spring cloud能夠比較方便的使用,對於其餘語言下該怎麼作呢?微服務治理的新趨勢是使用server mesh,經過server mesh來避免服務雪崩。server mesh具備更好的通用性,並且對語言徹底兼容。
大量熱點緩存數據同時失效,致使大量請求直接打到DB上。對於熱點數據同時失效的問題,能夠在過時時間上,加上一個隨機值,避免緩存同時失效。
本篇文章,總結了本身對緩存知識的認識,介紹了四種常見的緩存方案,每種方案各有優劣,須要根據業務需求來選擇合理的方案。而後介紹了使用緩存時可能遇到的幾個問題,並總結了常見的解決方案。