<kbd id="afajh"><form id="afajh"></form></kbd>
<strong id="afajh"><dl id="afajh"></dl></strong>
    <del id="afajh"><form id="afajh"></form></del>
        1. <th id="afajh"><progress id="afajh"></progress></th>
          <b id="afajh"><abbr id="afajh"></abbr></b>
          <th id="afajh"><progress id="afajh"></progress></th>

          聊聊數(shù)據(jù)庫與緩存數(shù)據(jù)一致性問題

          共 6127字,需瀏覽 13分鐘

           ·

          2022-06-10 20:22


          引言


          數(shù)據(jù)庫跟緩存,或者用Mysql和Redis來代替,想必每個CRUD boy都不會陌生。本文要聊的也是一個經(jīng)典問題,就是以怎樣的方式去操作數(shù)據(jù)庫和緩存比較合理。

          在本文正式開始之前,我覺得我們需要先取得以下兩點的共識:

          1. 緩存必須要有過期時間

          2. 保證數(shù)據(jù)庫跟緩存的最終一致性即可,不必追求強一致性

          為什么必須要有過期時間?首先對于緩存來說,當它的命中率越高的時候,我們的系統(tǒng)性能也就越好。如果某個緩存項沒有過期時間,而它命中的概率又很低,這就是在浪費緩存的空間。而如果有了過期時間,且在某個緩存項經(jīng)常被命中的情況下,我們可以在每次命中的時候都刷新一下它的過期時間,這樣也就保證了熱點數(shù)據(jù)會一直在緩存中存在,從而保證了緩存的命中率,提高了系統(tǒng)的性能。

          設置過期時間還有一個好處,就是當數(shù)據(jù)庫跟緩存出現(xiàn)數(shù)據(jù)不一致的情況時,這個可以作為一個最后的兜底手段。也就是說,當數(shù)據(jù)確實出現(xiàn)不一致的情況時,過期時間可以保證只有在出現(xiàn)不一致的時間點到緩存過期這段時間之內(nèi),數(shù)據(jù)庫跟緩存的數(shù)據(jù)是不一致的,因此也保證了數(shù)據(jù)的最終一致性。

          那么為什么不應該追求數(shù)據(jù)強一致性呢?這個主要是個權衡的問題。數(shù)據(jù)庫跟緩存,以Mysql跟Redis舉例,畢竟是兩套系統(tǒng),如果要保證強一致性,勢必要引入2PC或Paxos等分布式一致性協(xié)議,或者是分布式鎖等等,這個在實現(xiàn)上是有難度的,而且一定會對性能有影響。而且如果真的對數(shù)據(jù)的一致性要求這么高,那引入緩存是否真的有必要呢?直接讀寫數(shù)據(jù)庫不是更簡單嗎?那究竟如何做到數(shù)據(jù)庫跟緩存的數(shù)據(jù)強一致性呢?這是個比較復雜的問題,本文會在最后稍作展開。

          本文主要在保證最終一致性的前提下進行方案討論。


          數(shù)據(jù)庫和緩存的讀寫順序


          說到數(shù)據(jù)庫和緩存的讀寫順序,最經(jīng)典的方案就是這個所謂的Cache Aside Pattern了。其實這個方案一點也不高大上,基本上我們平時都在用,只是未必知道名字而已,下面簡單介紹一下這個方案的思路:

          1. 失效:程序先從緩存中讀取數(shù)據(jù),如果沒有命中,則從數(shù)據(jù)庫中讀取,成功之后將數(shù)據(jù)放到緩存中

          2. 命中:程序先從緩存中讀取數(shù)據(jù),如果命中,則直接返回

          3. 更新:程序先更新數(shù)據(jù)庫,在刪除緩存

          前兩步跟數(shù)據(jù)讀取順序有關,我覺得大家對這樣的設計應該都沒有異議。讀數(shù)據(jù)的時候當然要優(yōu)先從緩存中讀取,讀不到當然要從數(shù)據(jù)庫中讀取,然后還要放到緩存中,否則下次請求過來還得從數(shù)據(jù)庫中讀取。關鍵問題在于第三點,也就是數(shù)據(jù)更新流程,為什么要先更新數(shù)據(jù)庫?為什么之后要刪除緩存而不是更新?這就是本文主要要討論的問題。

          總共大概有四種可能的選項(你不可能把數(shù)據(jù)庫刪了吧...):

          1. 先更新緩存,再更新數(shù)據(jù)庫

          2. 先更新數(shù)據(jù)庫,再更新緩存

          3. 先刪除緩存,再更新數(shù)據(jù)庫

          4. 先更新數(shù)據(jù)庫,再刪除緩存

          接下來我們分情況逐個討論一下:

          先更新緩存,再更新數(shù)據(jù)庫

          我們都知道不管是操作數(shù)據(jù)庫還是操作緩存,都有失敗的可能。如果我們先更新緩存,再更新數(shù)據(jù)庫,假設更新數(shù)據(jù)庫失敗了,那數(shù)據(jù)庫中就存的是老數(shù)據(jù)。當然你可以選擇重試更新數(shù)據(jù)庫,那么再極端點,負責更新數(shù)據(jù)庫的機器也宕機了,那么數(shù)據(jù)庫中的數(shù)據(jù)將一直得不到更新,并且當緩存失效之后,其他機器再從數(shù)據(jù)庫中讀到的數(shù)據(jù)是老數(shù)據(jù),然后再放到緩存中,這就導致先前的更新操作被丟失了,因此這么做的隱患是很大的。

          數(shù)據(jù)持久化角度來說,數(shù)據(jù)庫當然要比緩存做的好,我們也應當以數(shù)據(jù)庫中的數(shù)據(jù)為主,所以需要更新數(shù)據(jù)的時候我們應當首先更新數(shù)據(jù)庫,而不是緩存。

          先更新數(shù)據(jù)庫,再更新緩存

          這里主要有兩個問題,首先是并發(fā)的問題:假設線程A(或者機器A,道理是一樣的)和線程B需要更新同一個數(shù)據(jù),A先于B但時間間隔很短,那么就有可能會出現(xiàn):

          1. 線程A更新了數(shù)據(jù)庫

          2. 線程B更新了數(shù)據(jù)庫

          3. 線程B更新了緩存

          4. 線程A更新了緩存

          按理說線程B應該最后更新緩存,但是可能因為網(wǎng)絡等原因,導致線程B先于線程A對緩存進行了更新,這就導致緩存中的數(shù)據(jù)不是最新的。

          第二個問題是,我們不確定要更新的這個緩存項是否會被經(jīng)常讀取,假設每次更新數(shù)據(jù)庫都會導致緩存的更新,有可能數(shù)據(jù)還沒有被讀取過就已經(jīng)再次更新了,這就造成了緩存空間的浪費。另外,緩存中的值可能是經(jīng)過一系列計算的,而并不是直接跟數(shù)據(jù)庫中的數(shù)據(jù)對應的,頻繁更新緩存會導致大量無效的計算,造成機器性能的浪費。

          綜上所述,更新緩存這一方案是不可取的,我們應當考慮刪除緩存。

          先刪除緩存,再更新數(shù)據(jù)庫

          這個方案的問題也是很明顯的,假設現(xiàn)在有兩個請求,一個是寫請求A,一個是讀請求B,那么可能出現(xiàn)如下的執(zhí)行序列:

          1. 請求A刪除緩存

          2. 請求B讀取緩存,發(fā)現(xiàn)不存在,從數(shù)據(jù)庫中讀取到舊值

          3. 請求A將新值寫入數(shù)據(jù)庫

          4. 請求B將舊值寫入緩存

          這樣就會導致緩存中存的還是舊值,在緩存過期之前都無法讀到新值。這個問題在數(shù)據(jù)庫讀寫分離的情況下會更明顯,因為主從同步需要時間,請求B獲取到的數(shù)據(jù)很可能還是舊值,那么寫入緩存中的也會是舊值。

          先更新數(shù)據(jù)庫,再刪除緩存

          終于來到我們最常用的方案了,但是最常用并不是說就一定不會有任何問題,我們依然假設有兩個請求,請求A是查詢請求,請求B是更新請求,那么可能會出現(xiàn)下述情形:

          1. 先前緩存剛好失效

          2. 請求A查數(shù)據(jù)庫,得到舊值

          3. 請求B更新數(shù)據(jù)庫

          4. 請求B刪除緩存

          5. 請求A將舊值寫入緩存

          上述情況確實有可能出現(xiàn),但是出現(xiàn)的概率可能不高,因為上述情形成立的條件是在讀取數(shù)據(jù)時,緩存剛好失效,并且此時正好又有一個并發(fā)的寫請求。考慮到數(shù)據(jù)庫上的寫操作一般都會比讀操作要慢,(這里指的是在寫數(shù)據(jù)庫時,數(shù)據(jù)庫一般都會上鎖,而普通的查詢語句是不會上鎖的。當然,復雜的查詢語句除外,但是這種語句的占比不會太高)并且聯(lián)系常見的數(shù)據(jù)庫讀寫分離的架構,可以合理認為在現(xiàn)實生活中,讀請求的比例要遠高于寫請求,因此我們可以得出結論。這種情況下緩存中存在臟數(shù)據(jù)的可能性是不高的。

          那如果是讀寫分離的場景下呢?如果按照如下所述的執(zhí)行序列,一樣會出問題:

          1. 請求A更新主庫

          2. 請求A刪除緩存

          3. 請求B查詢緩存,沒有命中,查詢從庫得到舊值

          4. 從庫同步完畢

          5. 請求B將舊值寫入緩存


          如果數(shù)據(jù)庫主從同步比較慢的話,同樣會出現(xiàn)數(shù)據(jù)不一致的問題。事實上就是如此,畢竟我們操作的是兩個系統(tǒng),在高并發(fā)的場景下,我們很難去保證多個請求之間的執(zhí)行順序,或者就算做到了,也可能會在性能上付出極大的代價。那為什么我們還是應當采用先更新數(shù)據(jù)庫,再刪除緩存這個策略呢?首先,為什么要刪除而不是更新緩存,這個在前面有分析,這里不再贅述。那為什么我們應當先更新數(shù)據(jù)庫呢?因為緩存在數(shù)據(jù)持久化這方面往往沒有數(shù)據(jù)庫做得好,而且數(shù)據(jù)庫中的數(shù)據(jù)是不存在過期這個概念的,我們應當以數(shù)據(jù)庫中的數(shù)據(jù)為主,緩存因為有著過期時間這一概念,最終一定會跟數(shù)據(jù)庫保持一致。

          那如果我就是想解決上述說的這兩個問題,在不要求強一致性的情況下可以怎么做呢?


          有沒有更好的思路?


          其實在討論最后一個方案時,我們沒有考慮操作數(shù)據(jù)庫或者操作緩存可能失敗的情況,而這種情況也是客觀存在的。那么在這里我們簡單討論下,首先是如果更新數(shù)據(jù)庫失敗了,其實沒有太大關系,因為此時數(shù)據(jù)庫和緩存中都還是老數(shù)據(jù),不存在不一致的問題。假設刪除緩存失敗了呢?此時確實會存在數(shù)據(jù)不一致的情況。除了設置緩存過期時間這種兜底方案之外,如果我們希望盡可能保證緩存可以被及時刪除,那么我們必須要考慮對刪除操作進行重試。

          你當然可以直接在代碼中對刪除操作進行重試,但是要知道如果是網(wǎng)絡原因?qū)е碌氖。⒖踢M行重試操作很可能也是失敗的,因此在每次重試之間你可能需要等待一段時間,比如幾百毫秒甚至是秒級等待。為了不影響主流程的正常運行,你可能會將這個事情交給一個異步線程或者線程池來執(zhí)行,但是如果機器此時也宕機了,這個刪除操作也就丟失了。

          那要怎么解決這個問題呢?首先可以考慮引入消息隊列,OK我知道寫入消息隊列一樣可能會失敗,但是這是建立在緩存跟消息隊列都不可用的情況下,應該說這樣的概率是不高的。引入消息隊列之后,就由消費端負責刪除緩存以及重試,可能會慢一些但是可以保證操作不會丟失。

          回到上述的兩個問題中去,上述的兩個問題的核心其實都在于將舊值寫入了緩存,那么解決這個問題的辦法其實就是要將緩存刪除,考慮到網(wǎng)絡問題導致的執(zhí)行失敗或執(zhí)行順序的問題,這里要進行的刪除操作應當是異步延時操作。具體來說應該怎么做呢?就是參考前面說的,引入消息隊列,在刪除緩存失敗的情況下,將刪除緩存作為一條消息寫入消息隊列,然后由消費端進行慢慢的消費和重試。

          那如果是讀寫分離場景呢?我們知道數(shù)據(jù)庫(以Mysql為例)主從之間的數(shù)據(jù)同步是通過binlog同步來實現(xiàn)的,因此這里可以考慮訂閱binlog(可以使用canal之類的中間件實現(xiàn)),提取出要刪除的緩存項,然后作為消息寫入消息隊列,然后再由消費端進行慢慢的消費和重試。在這種情況下,程序可以不去主動刪除緩存,但如果你希望緩存中盡快讀取到最新的值,也可以考慮將緩存刪除,那么就有可能出現(xiàn)又將舊值寫入緩存,且緩存被重復刪除的情況。但是一般來說這不會是個問題,首先舊值重新寫入緩存,情況無非就是又退化到了程序沒有主動刪除緩存的這一情況,另外,重復刪除緩存保證了數(shù)據(jù)庫和緩存之間不會存在長時間的數(shù)據(jù)不一致。(為什么刪除了緩存之后,還是有可能將舊值寫入緩存?參見上面先更新數(shù)據(jù)庫,再刪除緩存的方案下,讀寫分離場景下的執(zhí)行序列)當然我個人的建議是,如果你可以忍受一段時間之內(nèi)的數(shù)據(jù)不一致,那就沒必要自己再主動去刪除緩存了。

          要解決上述問題的核心就在于要實現(xiàn)異步延時刪除這一策略,因此在這里我們需要引入消息隊列。如果數(shù)據(jù)庫采用讀寫分離架構,則需要考慮訂閱binlog,否則一樣可能會出現(xiàn)先刪除,后同步完畢的情況。


          緩存擊穿


          可能會有同學注意到,如果采用刪除緩存的方案,在高并發(fā)場景下可能會導致緩存擊穿(這個跟緩存穿透還有點區(qū)別),也就是大量的請求同時去查詢同一個緩存,但是這個緩存又剛好過期或者被刪除了,那么所有的請求全部都會打到數(shù)據(jù)庫上,導致嚴重的性能問題。對于這個問題包括如何解決緩存穿透,后面我可能會考慮單獨寫文章來闡釋一下,這里先簡單說下解決思路,其實也就是上鎖。

          當一個線程需要去訪問這個緩存的時候,如果發(fā)現(xiàn)緩存為空,則需要先去競爭一個鎖,如果成功則進行正常的數(shù)據(jù)庫讀取和寫入緩存這一操作,然后再釋放鎖,否則就等待一段時間之后,重新嘗試讀取緩存,如果還沒有數(shù)據(jù)就繼續(xù)去競爭鎖。這個是單機場景,如果有多臺機器同時去訪問同一個緩存項該怎么辦呢?如果機器數(shù)不是很多的話,這種情況一般來說也不會成為一個問題,不過這里有個優(yōu)化點,就是從數(shù)據(jù)庫讀取到數(shù)據(jù)之后,再對緩存做一次判斷,如果緩存中已經(jīng)存在數(shù)據(jù),就不需要再寫一遍緩存了。但是如果機器數(shù)也很多的話,那么就得考慮上分布式鎖了。此方案的問題是顯而易見的,加鎖尤其是加分布式鎖會對系統(tǒng)性能有重大影響,而且分布式鎖的實現(xiàn)非常考驗開發(fā)者的經(jīng)驗和實力,在高并發(fā)場景下這一點顯得尤為重要,因此我建議各位,不到萬不得已的情況下,不要盲目上分布式鎖。


          怎么做到強一致性?


          可能有同學就是要來抬杠,現(xiàn)有的這些方案還是不夠完美,如果我就是想要做到強一致性可以怎么做?

          一致性協(xié)議當然是可以的,雖然成本也是非常客觀的。2PC甚至是3PC本身是存在一定程度的缺陷的,所以如果要采用這個方案,那么在架構設計中要引入很多的容錯,回退和兜底措施。那如果是上Paxos和Raft呢?那么你首先至少要看過這兩者的相關論文,并且調(diào)研清楚目前市面上有哪些開源方案,并做好充分的驗證,并且能夠做到出了問題自己有能力修復...對了,我還沒提到性能問題呢。

          那除了一致性協(xié)議以外,有沒有其他的思路?

          我們先回到"先更新數(shù)據(jù)庫,再刪除緩存"這個方案本身上來,從字面上來看,這里有兩步操作,因此在數(shù)據(jù)庫更新之前,到緩存被刪除這段時間之內(nèi),讀請求讀取到的都是臟數(shù)據(jù)。如果要實現(xiàn)這兩者的強一致性,只能是在更新完數(shù)據(jù)庫之前,所有的讀請求都必須要被阻塞直到緩存最終被刪除為止。如果是讀寫分離的場景,則要在更新完主庫之前就開始阻塞讀請求,直到主從同步完畢,且緩存被刪除之后才能釋放。

          這個思路其實就是一種串行化的思路,寫請求一定要在讀請求之前完成,才能保證最新的數(shù)據(jù)對所有讀請求來說是可見的。說到這里是不是讓你想起了什么?比如volatile,內(nèi)存屏障,ReadWriteLock,或者是數(shù)據(jù)庫的共享鎖,排他鎖...當前場景可能不同,但是要面對的問題都是相似的。

          現(xiàn)在回到問題本身,我們要怎么實現(xiàn)這種阻塞呢?可能有同學已經(jīng)發(fā)現(xiàn)了,我們需要的其實是一種 分布式讀寫鎖。對于寫請求來說,在更新數(shù)據(jù)庫之前,必須要先申請寫鎖,而其他線程或機器在讀取數(shù)據(jù)之前,必須要先申請讀鎖。讀鎖是共享的,寫鎖是排他的,即如果讀鎖存在,可以繼續(xù)申請讀鎖但無法申請寫鎖,如果寫鎖存在,則無論是讀鎖還是寫鎖都無法申請。只有實現(xiàn)了這種分布式讀寫鎖,才能保證寫請求在完成數(shù)據(jù)庫和緩存的操作之前,讀請求不會讀取到臟數(shù)據(jù)。

          注意,這里用到的分布式讀寫鎖并沒有解決緩存擊穿的問題,因為從讀請求的視角來看,如果發(fā)生了更新數(shù)據(jù)庫的情況,讀請求要么被阻塞,要么就是緩存為空,需要從數(shù)據(jù)庫讀取數(shù)據(jù)再寫入緩存。為了防止因緩存失效或被刪除導致大量請求直接打到數(shù)據(jù)庫上導致數(shù)據(jù)庫崩潰,你只能考慮加鎖甚至是加分布式鎖,具體參見緩存擊穿一章節(jié)。

          那么說到分布式讀寫鎖,其實現(xiàn)一樣有一定的難度。如果確定要使用,我建議使用Curator提供的InterProcessReadWriteLock,或者是Redisson提供的RReadWriteLock。對分布式讀寫鎖的討論超出了本文的范圍,這里就不做過多展開了。

          這里我只提出了我個人的想法,其他同學可能還會有自己的方案,但我相信不管是哪一種,為了要實現(xiàn)強一致性,系統(tǒng)的性能是一定要付出代價的,甚至可能會超出你引入緩存所得到的性能提升。


          總結


          在我看來所謂的架構設計,往往是要在眾多的trade-off中選擇最適合當前場景的。其實一旦在方案中使用了緩存,那往往也就意味著我們放棄了數(shù)據(jù)的強一致性,但這也意味著我們的系統(tǒng)在性能上能夠得到一些提升。在如何使用緩存這個問題上有很多的講究,比如過期時間的合理設置,怎么解決或規(guī)避緩存穿透,擊穿甚至是雪崩的問題。后續(xù)有機會的話,我會逐步地闡釋清楚這些問題的來龍去脈,以及如何去解決比較合適。

          作者簡介

          呂亞東,某風控領域互聯(lián)網(wǎng)公司技術專家,主要關注高性能,高并發(fā)以及中間件底層原理和調(diào)優(yōu)等領域。

          最近熬夜給大家準備了非常全的一套Java一線大廠面試題。全面覆蓋BATJ等一線互聯(lián)網(wǎng)公司的面試題及解答,由BAT一線互聯(lián)網(wǎng)公司大牛帶你深度剖析面試題背后的原理,不僅授你以魚,更授你以漁,為你面試掃除一切障礙。




          資源,怎么領取?


          掃二維碼,備注:Java


          一定要備注:Java,不要急哦,工作忙完后就會通過!



          作者:呂亞東

          來源:juejin.cn/post/6844903941646319623


          版權申明:內(nèi)容來源網(wǎng)絡,僅供分享學習,版權歸原創(chuàng)者所有。除非無法確認,我們都會標明作者及出處,如有侵權煩請告知,我們會立即刪除并表示歉意。謝謝!

          瀏覽 63
          點贊
          評論
          收藏
          分享

          手機掃一掃分享

          分享
          舉報
          評論
          圖片
          表情
          推薦
          點贊
          評論
          收藏
          分享

          手機掃一掃分享

          分享
          舉報
          <kbd id="afajh"><form id="afajh"></form></kbd>
          <strong id="afajh"><dl id="afajh"></dl></strong>
            <del id="afajh"><form id="afajh"></form></del>
                1. <th id="afajh"><progress id="afajh"></progress></th>
                  <b id="afajh"><abbr id="afajh"></abbr></b>
                  <th id="afajh"><progress id="afajh"></progress></th>
                  婷婷久久免费视频 | 国产在线最新地址 | 欧美综合免费 | 色婷婷亚洲婷婷 | 福利视频二区 |