引言
數據庫跟緩存,或者用Mysql和Redis來代替,想必每個CRUD boy都不會陌生。本文要聊的也是一個經典問題,就是以怎樣的方式去操作數據庫和緩存比較合理。
在本文正式開始之前,我覺得我們需要先取得以下兩點的共識:
緩存必須要有過期時間
保證數據庫跟緩存的最終一致性即可,不必追求強一致性
為什么必須要有過期時間?首先對于緩存來說,當它的命中率越高的時候,我們的系統性能也就越好。如果某個緩存項沒有過期時間,而它命中的概率又很低,這就是在浪費緩存的空間。而如果有了過期時間,且在某個緩存項經常被命中的情況下,我們可以在每次命中的時候都刷新一下它的過期時間,這樣也就保證了熱點數據會一直在緩存中存在,從而保證了緩存的命中率,提高了系統的性能。
設置過期時間還有一個好處,就是當數據庫跟緩存出現數據不一致的情況時,這個可以作為一個最后的兜底手段。也就是說,當數據確實出現不一致的情況時,過期時間可以保證只有在出現不一致的時間點到緩存過期這段時間之內,數據庫跟緩存的數據是不一致的,因此也保證了數據的最終一致性。
那么為什么不應該追求數據強一致性呢?這個主要是個權衡的問題。數據庫跟緩存,以Mysql跟Redis舉例,畢竟是兩套系統,如果要保證強一致性,勢必要引入2PC或Paxos等分布式一致性協議,或者是分布式鎖等等,這個在實現上是有難度的,而且一定會對性能有影響。而且如果真的對數據的一致性要求這么高,那引入緩存是否真的有必要呢?直接讀寫數據庫不是更簡單嗎?那究竟如何做到數據庫跟緩存的數據強一致性呢?這是個比較復雜的問題,本文會在最后稍作展開。
本文主要在保證最終一致性的前提下進行方案討論。
數據庫和緩存的讀寫順序
說到數據庫和緩存的讀寫順序,最經典的方案就是這個所謂的Cache Aside Pattern了。其實這個方案一點也不高大上,基本上我們平時都在用,只是未必知道名字而已,下面簡單介紹一下這個方案的思路:
失效:程序先從緩存中讀取數據,如果沒有命中,則從數據庫中讀取,成功之后將數據放到緩存中
命中:程序先從緩存中讀取數據,如果命中,則直接返回
更新:程序先更新數據庫,在刪除緩存
前兩步跟數據讀取順序有關,我覺得大家對這樣的設計應該都沒有異議。讀數據的時候當然要優先從緩存中讀取,讀不到當然要從數據庫中讀取,然后還要放到緩存中,否則下次請求過來還得從數據庫中讀取。關鍵問題在于第三點,也就是數據更新流程,為什么要先更新數據庫?為什么之后要刪除緩存而不是更新?這就是本文主要要討論的問題。
總共大概有四種可能的選項(你不可能把數據庫刪了吧...):
先更新緩存,再更新數據庫
先更新數據庫,再更新緩存
先刪除緩存,再更新數據庫
先更新數據庫,再刪除緩存
接下來我們分情況逐個討論一下:
先更新緩存,再更新數據庫
我們都知道不管是操作數據庫還是操作緩存,都有失敗的可能。如果我們先更新緩存,再更新數據庫,假設更新數據庫失敗了,那數據庫中就存的是老數據。當然你可以選擇重試更新數據庫,那么再極端點,負責更新數據庫的機器也宕機了,那么數據庫中的數據將一直得不到更新,并且當緩存失效之后,其他機器再從數據庫中讀到的數據是老數據,然后再放到緩存中,這就導致先前的更新操作被丟失了,因此這么做的隱患是很大的。
從數據持久化的角度來說,數據庫當然要比緩存做的好,我們也應當以數據庫中的數據為主,所以需要更新數據的時候我們應當首先更新數據庫,而不是緩存。
先更新數據庫,再更新緩存
這里主要有兩個問題,首先是并發的問題:假設線程A(或者機器A,道理是一樣的)和線程B需要更新同一個數據,A先于B但時間間隔很短,那么就有可能會出現:
線程A更新了數據庫
線程B更新了數據庫
線程B更新了緩存
線程A更新了緩存
按理說線程B應該最后更新緩存,但是可能因為網絡等原因,導致線程B先于線程A對緩存進行了更新,這就導致緩存中的數據不是最新的。
第二個問題是,我們不確定要更新的這個緩存項是否會被經常讀取,假設每次更新數據庫都會導致緩存的更新,有可能數據還沒有被讀取過就已經再次更新了,這就造成了緩存空間的浪費。另外,緩存中的值可能是經過一系列計算的,而并不是直接跟數據庫中的數據對應的,頻繁更新緩存會導致大量無效的計算,造成機器性能的浪費。
綜上所述,更新緩存這一方案是不可取的,我們應當考慮刪除緩存。
先刪除緩存,再更新數據庫
這個方案的問題也是很明顯的,假設現在有兩個請求,一個是寫請求A,一個是讀請求B,那么可能出現如下的執行序列:
請求A刪除緩存
請求B讀取緩存,發現不存在,從數據庫中讀取到舊值
請求A將新值寫入數據庫
請求B將舊值寫入緩存
這樣就會導致緩存中存的還是舊值,在緩存過期之前都無法讀到新值。這個問題在數據庫讀寫分離的情況下會更明顯,因為主從同步需要時間,請求B獲取到的數據很可能還是舊值,那么寫入緩存中的也會是舊值。
先更新數據庫,再刪除緩存
終于來到我們最常用的方案了,但是最常用并不是說就一定不會有任何問題,我們依然假設有兩個請求,請求A是查詢請求,請求B是更新請求,那么可能會出現下述情形:
先前緩存剛好失效
請求A查數據庫,得到舊值
請求B更新數據庫
請求B刪除緩存
請求A將舊值寫入緩存
上述情況確實有可能出現,但是出現的概率可能不高,因為上述情形成立的條件是在讀取數據時,緩存剛好失效,并且此時正好又有一個并發的寫請求??紤]到數據庫上的寫操作一般都會比讀操作要慢,(這里指的是在寫數據庫時,數據庫一般都會上鎖,而普通的查詢語句是不會上鎖的。當然,復雜的查詢語句除外,但是這種語句的占比不會太高)并且聯系常見的數據庫讀寫分離的架構,可以合理認為在現實生活中,讀請求的比例要遠高于寫請求,因此我們可以得出結論。這種情況下緩存中存在臟數據的可能性是不高的。
那如果是讀寫分離的場景下呢?如果按照如下所述的執行序列,一樣會出問題:
請求A更新主庫
請求A刪除緩存
請求B查詢緩存,沒有命中,查詢從庫得到舊值
從庫同步完畢
請求B將舊值寫入緩存
如果數據庫主從同步比較慢的話,同樣會出現數據不一致的問題。事實上就是如此,畢竟我們操作的是兩個系統,在高并發的場景下,我們很難去保證多個請求之間的執行順序,或者就算做到了,也可能會在性能上付出極大的代價。那為什么我們還是應當采用先更新數據庫,再刪除緩存這個策略呢?首先,為什么要刪除而不是更新緩存,這個在前面有分析,這里不再贅述。那為什么我們應當先更新數據庫呢?因為緩存在數據持久化這方面往往沒有數據庫做得好,而且數據庫中的數據是不存在過期這個概念的,我們應當以數據庫中的數據為主,緩存因為有著過期時間這一概念,最終一定會跟數據庫保持一致。
那如果我就是想解決上述說的這兩個問題,在不要求強一致性的情況下可以怎么做呢?
其他Pattern
Read/Write Through Pattern
我們可以看到,在上面的Cache Aside套路中,我們的應用代碼需要維護兩個數據存儲,一個是緩存(Cache),一個是數據庫(Repository)。所以,應用程序比較啰嗦。而Read/Write Through套路是把更新數據庫(Repository)的操作由緩存自己代理了,所以,對于應用層來說,就簡單很多了??梢岳斫鉃椋瑧谜J為后端就是一個單一的存儲,而存儲自己維護自己的Cache。
Read Through
Read Through 套路就是在查詢操作中更新緩存,也就是說,當緩存失效的時候(過期或LRU換出),Cache Aside是由調用方負責把數據加載入緩存,而Read Through則用緩存服務自己來加載,從而對應用方是透明的。
Write Through
Write Through 套路和Read Through相仿,不過是在更新數據時發生。當有數據更新的時候,如果沒有命中緩存,直接更新數據庫,然后返回。如果命中了緩存,則更新緩存,然后再由Cache自己更新數據庫(這是一個同步操作)
Write Behind Caching Pattern
Write Behind 又叫 Write Back。一些了解Linux操作系統內核的同學對write back應該非常熟悉,這不就是Linux文件系統的Page Cache的算法嗎?是的,你看基礎這玩意全都是相通的。所以,基礎很重要,我已經不是一次說過基礎很重要這事了。
Write Back套路,一句說就是,在更新數據的時候,只更新緩存,不更新數據庫,而我們的緩存會異步地批量更新數據庫。這個設計的好處就是讓數據的I/O操作飛快無比(因為直接操作內存嘛 ),因為異步,write backg還可以合并對同一個數據的多次操作,所以性能的提高是相當可觀的。
但是,其帶來的問題是,數據不是強一致性的,而且可能會丟失(我們知道Unix/Linux非正常關機會導致數據丟失,就是因為這個事)。在軟件設計上,我們基本上不可能做出一個沒有缺陷的設計,就像算法設計中的時間換空間,空間換時間一個道理,有時候,強一致性和高性能,高可用和高性性是有沖突的。軟件設計從來都是取舍Trade-Off。
另外,Write Back實現邏輯比較復雜,因為他需要track有哪數據是被更新了的,需要刷到持久層上。操作系統的write back會在僅當這個cache需要失效的時候,才會被真正持久起來,比如,內存不夠了,或是進程退出了等情況,這又叫lazy write。
有沒有更好的思路?
其實在討論最后一個方案時,我們沒有考慮操作數據庫或者操作緩存可能失敗的情況,而這種情況也是客觀存在的。那么在這里我們簡單討論下,首先是如果更新數據庫失敗了,其實沒有太大關系,因為此時數據庫和緩存中都還是老數據,不存在不一致的問題。假設刪除緩存失敗了呢?此時確實會存在數據不一致的情況。除了設置緩存過期時間這種兜底方案之外,如果我們希望盡可能保證緩存可以被及時刪除,那么我們必須要考慮對刪除操作進行重試。
你當然可以直接在代碼中對刪除操作進行重試,但是要知道如果是網絡原因導致的失敗,立刻進行重試操作很可能也是失敗的,因此在每次重試之間你可能需要等待一段時間,比如幾百毫秒甚至是秒級等待。為了不影響主流程的正常運行,你可能會將這個事情交給一個異步線程或者線程池來執行,但是如果機器此時也宕機了,這個刪除操作也就丟失了。
那要怎么解決這個問題呢?首先可以考慮引入消息隊列,OK我知道寫入消息隊列一樣可能會失敗,但是這是建立在緩存跟消息隊列都不可用的情況下,應該說這樣的概率是不高的。引入消息隊列之后,就由消費端負責刪除緩存以及重試,可能會慢一些但是可以保證操作不會丟失。
回到上述的兩個問題中去,上述的兩個問題的核心其實都在于將舊值寫入了緩存,那么解決這個問題的辦法其實就是要將緩存刪除,考慮到網絡問題導致的執行失敗或執行順序的問題,這里要進行的刪除操作應當是異步延時操作。具體來說應該怎么做呢?就是參考前面說的,引入消息隊列,在刪除緩存失敗的情況下,將刪除緩存作為一條消息寫入消息隊列,然后由消費端進行慢慢的消費和重試。
那如果是讀寫分離場景呢?我們知道數據庫(以Mysql為例)主從之間的數據同步是通過binlog同步來實現的,因此這里可以考慮訂閱binlog(可以使用canal之類的中間件實現),提取出要刪除的緩存項,然后作為消息寫入消息隊列,然后再由消費端進行慢慢的消費和重試。在這種情況下,程序可以不去主動刪除緩存,但如果你希望緩存中盡快讀取到最新的值,也可以考慮將緩存刪除,那么就有可能出現又將舊值寫入緩存,且緩存被重復刪除的情況。但是一般來說這不會是個問題,首先舊值重新寫入緩存,情況無非就是又退化到了程序沒有主動刪除緩存的這一情況,另外,重復刪除緩存保證了數據庫和緩存之間不會存在長時間的數據不一致。(為什么刪除了緩存之后,還是有可能將舊值寫入緩存?參見上面先更新數據庫,再刪除緩存的方案下,讀寫分離場景下的執行序列)當然我個人的建議是,如果你可以忍受一段時間之內的數據不一致,那就沒必要自己再主動去刪除緩存了。
要解決上述問題的核心就在于要實現異步延時刪除這一策略,因此在這里我們需要引入消息隊列。如果數據庫采用讀寫分離架構,則需要考慮訂閱binlog,否則一樣可能會出現先刪除,后同步完畢的情況。
緩存擊穿
可能會有同學注意到,如果采用刪除緩存的方案,在高并發場景下可能會導致緩存擊穿(這個跟緩存穿透還有點區別),也就是大量的請求同時去查詢同一個緩存,但是這個緩存又剛好過期或者被刪除了,那么所有的請求全部都會打到數據庫上,導致嚴重的性能問題。對于這個問題包括如何解決緩存穿透,后面我可能會考慮單獨寫文章來闡釋一下,這里先簡單說下解決思路,其實也就是上鎖。
當一個線程需要去訪問這個緩存的時候,如果發現緩存為空,則需要先去競爭一個鎖,如果成功則進行正常的數據庫讀取和寫入緩存這一操作,然后再釋放鎖,否則就等待一段時間之后,重新嘗試讀取緩存,如果還沒有數據就繼續去競爭鎖。這個是單機場景,如果有多臺機器同時去訪問同一個緩存項該怎么辦呢?如果機器數不是很多的話,這種情況一般來說也不會成為一個問題,不過這里有個優化點,就是從數據庫讀取到數據之后,再對緩存做一次判斷,如果緩存中已經存在數據,就不需要再寫一遍緩存了。但是如果機器數也很多的話,那么就得考慮上分布式鎖了。此方案的問題是顯而易見的,加鎖尤其是加分布式鎖會對系統性能有重大影響,而且分布式鎖的實現非??简為_發者的經驗和實力,在高并發場景下這一點顯得尤為重要,因此我建議各位,不到萬不得已的情況下,不要盲目上分布式鎖。
怎么做到強一致性?
可能有同學就是要來抬杠,現有的這些方案還是不夠完美,如果我就是想要做到強一致性可以怎么做?
上一致性協議當然是可以的,雖然成本也是非常客觀的。2PC甚至是3PC本身是存在一定程度的缺陷的,所以如果要采用這個方案,那么在架構設計中要引入很多的容錯,回退和兜底措施。那如果是上Paxos和Raft呢?那么你首先至少要看過這兩者的相關論文,并且調研清楚目前市面上有哪些開源方案,并做好充分的驗證,并且能夠做到出了問題自己有能力修復...對了,我還沒提到性能問題呢。
那除了一致性協議以外,有沒有其他的思路?
我們先回到"先更新數據庫,再刪除緩存"這個方案本身上來,從字面上來看,這里有兩步操作,因此在數據庫更新之前,到緩存被刪除這段時間之內,讀請求讀取到的都是臟數據。如果要實現這兩者的強一致性,只能是在更新完數據庫之前,所有的讀請求都必須要被阻塞直到緩存最終被刪除為止。如果是讀寫分離的場景,則要在更新完主庫之前就開始阻塞讀請求,直到主從同步完畢,且緩存被刪除之后才能釋放。
這個思路其實就是一種串行化的思路,寫請求一定要在讀請求之前完成,才能保證最新的數據對所有讀請求來說是可見的。說到這里是不是讓你想起了什么?比如volatile,內存屏障,ReadWriteLock,或者是數據庫的共享鎖,排他鎖...當前場景可能不同,但是要面對的問題都是相似的。
現在回到問題本身,我們要怎么實現這種阻塞呢?可能有同學已經發現了,我們需要的其實是一種 分布式讀寫鎖。對于寫請求來說,在更新數據庫之前,必須要先申請寫鎖,而其他線程或機器在讀取數據之前,必須要先申請讀鎖。讀鎖是共享的,寫鎖是排他的,即如果讀鎖存在,可以繼續申請讀鎖但無法申請寫鎖,如果寫鎖存在,則無論是讀鎖還是寫鎖都無法申請。只有實現了這種分布式讀寫鎖,才能保證寫請求在完成數據庫和緩存的操作之前,讀請求不會讀取到臟數據。
注意,這里用到的分布式讀寫鎖并沒有解決緩存擊穿的問題,因為從讀請求的視角來看,如果發生了更新數據庫的情況,讀請求要么被阻塞,要么就是緩存為空,需要從數據庫讀取數據再寫入緩存。為了防止因緩存失效或被刪除導致大量請求直接打到數據庫上導致數據庫崩潰,你只能考慮加鎖甚至是加分布式鎖。
那么說到分布式讀寫鎖,其實現一樣有一定的難度。如果確定要使用,我建議使用Curator提供的InterProcessReadWriteLock,或者是Redisson提供的RReadWriteLock。對分布式讀寫鎖的討論超出了本文的范圍,這里就不做過多展開了。
這里我只提出了我個人的想法,其他同學可能還會有自己的方案,但我相信不管是哪一種,為了要實現強一致性,系統的性能是一定要付出代價的,甚至可能會超出你引入緩存所得到的性能提升。
總結
在我看來所謂的架構設計,往往是要在眾多的trade-off中選擇最適合當前場景的。其實一旦在方案中使用了緩存,那往往也就意味著我們放棄了數據的強一致性,但這也意味著我們的系統在性能上能夠得到一些提升。在如何使用緩存這個問題上有很多的講究,比如過期時間的合理設置,怎么解決或規避緩存穿透,擊穿甚至是雪崩的問題。后續有機會的話,我會逐步地闡釋清楚這些問題的來龍去脈,以及如何去解決比較合適。