數據庫跟緩存,或者用Mysql和Redis來代替,想必每一個CRUD boy都不會陌生。本文要聊的也是一個經典問題,就是以怎樣的方式去操做數據庫和緩存比較合理。html
在本文正式開始以前,我以爲咱們須要先取得如下兩點的共識:java
爲何必需要有過時時間?首先對於緩存來講,當它的命中率越高的時候,咱們的系統性能也就越好。若是某個緩存項沒有過時時間,而它命中的機率又很低,這就是在浪費緩存的空間。而若是有了過時時間,且在某個緩存項常常被命中的狀況下,咱們能夠在每次命中的時候都刷新一下它的過時時間,這樣也就保證了熱點數據會一直在緩存中存在,從而保證了緩存的命中率,提升了系統的性能。redis
設置過時時間還有一個好處,就是當數據庫跟緩存出現數據不一致的狀況時,這個能夠做爲一個最後的兜底手段。也就是說,當數據確實出現不一致的狀況時,過時時間能夠保證只有在出現不一致的時間點到緩存過時這段時間以內,數據庫跟緩存的數據是不一致的,所以也保證了數據的最終一致性。sql
那麼爲何不該該追求數據強一致性呢?這個主要是個權衡的問題。數據庫跟緩存,以Mysql跟Redis舉例,畢竟是兩套系統,若是要保證強一致性,勢必要引入2PC或Paxos等分佈式一致性協議,或者是分佈式鎖等等,這個在實現上是有難度的,並且必定會對性能有影響。並且若是真的對數據的一致性要求這麼高,那引入緩存是否真的有必要呢?直接讀寫數據庫不是更簡單嗎?那究竟如何作到數據庫跟緩存的數據強一致性呢?這是個比較複雜的問題,本文會在最後稍做展開。數據庫
本文主要在保證最終一致性的前提下進行方案討論。apache
說到數據庫和緩存的讀寫順序,最經典的方案就是這個所謂的Cache Aside Pattern了。其實這個方案一點也不高大上,基本上咱們平時都在用,只是未必知道名字而已,下面簡單介紹一下這個方案的思路:api
前兩步跟數據讀取順序有關,我以爲你們對這樣的設計應該都沒有異議。讀數據的時候固然要優先從緩存中讀取,讀不到固然要從數據庫中讀取,而後還要放到緩存中,不然下次請求過來還得從數據庫中讀取。關鍵問題在於第三點,也就是數據更新流程,爲何要先更新數據庫?爲何以後要刪除緩存而不是更新?這就是本文主要要討論的問題。緩存
總共大概有四種可能的選項(你不可能把數據庫刪了吧...):網絡
接下來咱們分狀況逐個討論一下:架構
咱們都知道無論是操做數據庫仍是操做緩存,都有失敗的可能。若是咱們先更新緩存,再更新數據庫,假設更新數據庫失敗了,那數據庫中就存的是老數據。固然你能夠選擇重試更新數據庫,那麼再極端點,負責更新數據庫的機器也宕機了,那麼數據庫中的數據將一直得不到更新,而且當緩存失效以後,其餘機器再從數據庫中讀到的數據是老數據,而後再放到緩存中,這就致使先前的更新操做被丟失了,所以這麼作的隱患是很大的。
從數據持久化的角度來講,數據庫固然要比緩存作的好,咱們也應當以數據庫中的數據爲主,因此須要更新數據的時候咱們應當首先更新數據庫,而不是緩存。
這裏主要有兩個問題,首先是併發的問題:假設線程A(或者機器A,道理是同樣的)和線程B須要更新同一個數據,A先於B但時間間隔很短,那麼就有可能會出現:
按理說線程B應該最後更新緩存,可是可能由於網絡等緣由,致使線程B先於線程A對緩存進行了更新,這就致使緩存中的數據不是最新的。
第二個問題是,咱們不肯定要更新的這個緩存項是否會被常常讀取,假設每次更新數據庫都會致使緩存的更新,有可能數據尚未被讀取過就已經再次更新了,這就形成了緩存空間的浪費。另外,緩存中的值多是通過一系列計算的,而並非直接跟數據庫中的數據對應的,頻繁更新緩存會致使大量無效的計算,形成機器性能的浪費。
綜上所述,更新緩存這一方案是不可取的,咱們應當考慮刪除緩存。
這個方案的問題也是很明顯的,假設如今有兩個請求,一個是寫請求A,一個是讀請求B,那麼可能出現以下的執行序列:
這樣就會致使緩存中存的仍是舊值,在緩存過時以前都沒法讀到新值。這個問題在數據庫讀寫分離的狀況下會更明顯,由於主從同步須要時間,請求B獲取到的數據極可能仍是舊值,那麼寫入緩存中的也會是舊值。
終於來到咱們最經常使用的方案了,可是最經常使用並非說就必定不會有任何問題,咱們依然假設有兩個請求,請求A是查詢請求,請求B是更新請求,那麼可能會出現下述情形:
上述狀況確實有可能出現,可是出現的機率可能不高,由於上述情造成立的條件是在讀取數據時,緩存恰好失效,而且此時正好又有一個併發的寫請求。考慮到數據庫上的寫操做通常都會比讀操做要慢,(這裏指的是在寫數據庫時,數據庫通常都會上鎖,而普通的查詢語句是不會上鎖的。固然,複雜的查詢語句除外,可是這種語句的佔比不會過高)而且聯繫常見的數據庫讀寫分離的架構,能夠合理認爲在現實生活中,讀請求的比例要遠高於寫請求,所以咱們能夠得出結論。這種狀況下緩存中存在髒數據的可能性是不高的。
那若是是讀寫分離的場景下呢?若是按照以下所述的執行序列,同樣會出問題:
若是數據庫主從同步比較慢的話,一樣會出現數據不一致的問題。事實上就是如此,畢竟咱們操做的是兩個系統,在高併發的場景下,咱們很難去保證多個請求之間的執行順序,或者就算作到了,也可能會在性能上付出極大的代價。那爲何咱們仍是應當採用先更新數據庫,再刪除緩存這個策略呢?首先,爲何要刪除而不是更新緩存,這個在前面有分析,這裏再也不贅述。那爲何咱們應當先更新數據庫呢?由於緩存在數據持久化這方面每每沒有數據庫作得好,並且數據庫中的數據是不存在過時這個概念的,咱們應當以數據庫中的數據爲主,緩存由於有着過時時間這一律念,最終必定會跟數據庫保持一致。
那若是我就是想解決上述說的這兩個問題,在不要求強一致性的狀況下能夠怎麼作呢?
其實在討論最後一個方案時,咱們沒有考慮操做數據庫或者操做緩存可能失敗的狀況,而這種狀況也是客觀存在的。那麼在這裏咱們簡單討論下,首先是若是更新數據庫失敗了,其實沒有太大關係,由於此時數據庫和緩存中都仍是老數據,不存在不一致的問題。假設刪除緩存失敗了呢?此時確實會存在數據不一致的狀況。除了設置緩存過時時間這種兜底方案以外,若是咱們但願儘量保證緩存能夠被及時刪除,那麼咱們必需要考慮對刪除操做進行重試。
你固然能夠直接在代碼中對刪除操做進行重試,可是要知道若是是網絡緣由致使的失敗,馬上進行重試操做極可能也是失敗的,所以在每次重試之間你可能須要等待一段時間,好比幾百毫秒甚至是秒級等待。爲了避免影響主流程的正常運行,你可能會將這個事情交給一個異步線程或者線程池來執行,可是若是機器此時也宕機了,這個刪除操做也就丟失了。
那要怎麼解決這個問題呢?首先能夠考慮引入消息隊列,OK我知道寫入消息隊列同樣可能會失敗,可是這是創建在緩存跟消息隊列都不可用的狀況下,應該說這樣的機率是不高的。引入消息隊列以後,就由消費端負責刪除緩存以及重試,可能會慢一些可是能夠保證操做不會丟失。
回到上述的兩個問題中去,上述的兩個問題的核心其實都在於將舊值寫入了緩存,那麼解決這個問題的辦法其實就是要將緩存刪除,考慮到網絡問題致使的執行失敗或執行順序的問題,這裏要進行的刪除操做應當是異步延時操做。具體來講應該怎麼作呢?就是參考前面說的,引入消息隊列,在刪除緩存失敗的狀況下,將刪除緩存做爲一條消息寫入消息隊列,而後由消費端進行慢慢的消費和重試。
那若是是讀寫分離場景呢?咱們知道數據庫(以Mysql爲例)主從之間的數據同步是經過binlog同步來實現的,所以這裏能夠考慮訂閱binlog(可使用canal之類的中間件實現),提取出要刪除的緩存項,而後做爲消息寫入消息隊列,而後再由消費端進行慢慢的消費和重試。在這種狀況下,程序能夠不去主動刪除緩存,但若是你但願緩存中儘快讀取到最新的值,也能夠考慮將緩存刪除,那麼就有可能出現又將舊值寫入緩存,且緩存被重複刪除的狀況。可是通常來講這不會是個問題,首先舊值從新寫入緩存,狀況無非就是又退化到了程序沒有主動刪除緩存的這一狀況,另外,重複刪除緩存保證了數據庫和緩存之間不會存在長時間的數據不一致。(爲何刪除了緩存以後,仍是有可能將舊值寫入緩存?參見上面先更新數據庫,再刪除緩存的方案下,讀寫分離場景下的執行序列)固然我我的的建議是,若是你能夠忍受一段時間以內的數據不一致,那就不必本身再主動去刪除緩存了。
要解決上述問題的核心就在於要實現異步延時刪除這一策略,所以在這裏咱們須要引入消息隊列。若是數據庫採用讀寫分離架構,則須要考慮訂閱binlog,不然同樣可能會出現先刪除,後同步完畢的狀況。
可能會有同窗注意到,若是採用刪除緩存的方案,在高併發場景下可能會致使緩存擊穿(這個跟緩存穿透還有點區別),也就是大量的請求同時去查詢同一個緩存,可是這個緩存又恰好過時或者被刪除了,那麼全部的請求所有都會打到數據庫上,致使嚴重的性能問題。對於這個問題包括如何解決緩存穿透,後面我可能會考慮單獨寫文章來闡釋一下,這裏先簡單說下解決思路,其實也就是上鎖。
當一個線程須要去訪問這個緩存的時候,若是發現緩存爲空,則須要先去競爭一個鎖,若是成功則進行正常的數據庫讀取和寫入緩存這一操做,而後再釋放鎖,不然就等待一段時間以後,從新嘗試讀取緩存,若是尚未數據就繼續去競爭鎖。這個是單機場景,若是有多臺機器同時去訪問同一個緩存項該怎麼辦呢?若是機器數不是不少的話,這種狀況通常來講也不會成爲一個問題,不過這裏有個優化點,就是從數據庫讀取到數據以後,再對緩存作一次判斷,若是緩存中已經存在數據,就不須要再寫一遍緩存了。可是若是機器數也不少的話,那麼就得考慮上分佈式鎖了。此方案的問題是顯而易見的,加鎖尤爲是加分佈式鎖會對系統性能有重大影響,並且分佈式鎖的實現很是考驗開發者的經驗和實力,在高併發場景下這一點顯得尤其重要,所以我建議各位,不到萬不得已的狀況下,不要盲目上分佈式鎖。
可能有同窗就是要來擡槓,現有的這些方案仍是不夠完美,若是我就是想要作到強一致性能夠怎麼作?
上一致性協議固然是能夠的,雖然成本也是很是客觀的。2PC甚至是3PC自己是存在必定程度的缺陷的,因此若是要採用這個方案,那麼在架構設計中要引入不少的容錯,回退和兜底措施。那若是是上Paxos和Raft呢?那麼你首先至少要看過這二者的相關論文,而且調研清楚目前市面上有哪些開源方案,並作好充分的驗證,而且可以作到出了問題本身有能力修復...對了,我還沒提到性能問題呢。
那除了一致性協議之外,有沒有其餘的思路?
咱們先回到"先更新數據庫,再刪除緩存"這個方案自己上來,從字面上來看,這裏有兩步操做,所以在數據庫更新以前,到緩存被刪除這段時間以內,讀請求讀取到的都是髒數據。若是要實現這二者的強一致性,只能是在更新完數據庫以前,全部的讀請求都必需要被阻塞直到緩存最終被刪除爲止。若是是讀寫分離的場景,則要在更新完主庫以前就開始阻塞讀請求,直到主從同步完畢,且緩存被刪除以後才能釋放。
這個思路其實就是一種串行化的思路,寫請求必定要在讀請求以前完成,才能保證最新的數據對全部讀請求來講是可見的。說到這裏是否是讓你想起了什麼?好比volatile,內存屏障,ReadWriteLock,或者是數據庫的共享鎖,排他鎖...當前場景可能不一樣,可是要面對的問題都是類似的。
如今回到問題自己,咱們要怎麼實現這種阻塞呢?可能有同窗已經發現了,咱們須要的實際上是一種 分佈式讀寫鎖。對於寫請求來講,在更新數據庫以前,必需要先申請寫鎖,而其餘線程或機器在讀取數據以前,必需要先申請讀鎖。讀鎖是共享的,寫鎖是排他的,即若是讀鎖存在,能夠繼續申請讀鎖但沒法申請寫鎖,若是寫鎖存在,則不管是讀鎖仍是寫鎖都沒法申請。只有實現了這種分佈式讀寫鎖,才能保證寫請求在完成數據庫和緩存的操做以前,讀請求不會讀取到髒數據。
注意,這裏用到的分佈式讀寫鎖並無解決緩存擊穿的問題,由於從讀請求的視角來看,若是發生了更新數據庫的狀況,讀請求要麼被阻塞,要麼就是緩存爲空,須要從數據庫讀取數據再寫入緩存。爲了防止因緩存失效或被刪除致使大量請求直接打到數據庫上致使數據庫崩潰,你只能考慮加鎖甚至是加分佈式鎖,具體參見緩存擊穿這一章節。
那麼說到分佈式讀寫鎖,其實現同樣有必定的難度。若是肯定要使用,我建議使用Curator提供的InterProcessReadWriteLock,或者是Redisson提供的RReadWriteLock。對分佈式讀寫鎖的討論超出了本文的範圍,這裏就不作過多展開了。
這裏我只提出了我我的的想法,其餘同窗可能還會有本身的方案,但我相信無論是哪種,爲了要實現強一致性,系統的性能是必定要付出代價的,甚至可能會超出你引入緩存所獲得的性能提高。
在我看來所謂的架構設計,每每是要在衆多的trade-off中選擇最適合當前場景的。其實一旦在方案中使用了緩存,那每每也就意味着咱們放棄了數據的強一致性,但這也意味着咱們的系統在性能上可以獲得一些提高。在如何使用緩存這個問題上有不少的講究,好比過時時間的合理設置,怎麼解決或規避緩存穿透,擊穿甚至是雪崩的問題。後續有機會的話,我會逐步地闡釋清楚這些問題的前因後果,以及如何去解決比較合適。
呂亞東,某風控領域互聯網公司技術專家,主要關注高性能,高併發以及中間件底層原理和調優等領域。