分佈式系統關注點(17)——先寫DB還是「緩存」?

如果第二次看到我的文章,歡迎右側掃碼訂閱我喲~  👉

本文長度爲4209字,建議閱讀12分鐘。

堅持原創,每一篇都是用心之作~

 

 

在前一篇《360°全方位解讀「緩存」》中,我們聊了運用緩存的三種思路,以及在一個完整的系統中可以設立緩存的幾個位置,並且分享了關於瀏覽器緩存、CDN緩存、網關(代理)緩存的一些使用經驗。

 

這次Z哥將深入到實際場景中,來看一下「進程內緩存」、「進程外緩存」運用時的一些最佳實踐。由於篇幅原因,這次先聊三個問題。

 

首當其衝的就是“先寫DB還是緩存?”。我想,只要你開始運用緩存,這會是你第一個要好好思考的問題,否則在前方等待你的就是災難。。。

 

 

先寫DB還是緩存?

一個程序可以沒有緩存,但是一定要有數據庫。這是大家的普遍觀點,所以數據庫的重要性在你的潛意識裏總是被放在了第一位。

 

先DB再緩存

如果不細想的話你可能會覺得,數據庫操作失敗了,自然緩存也不用操作了;數據庫操作成功了,再操作緩存,沒毛病。

 

但是數據庫操作成功,緩存操作的失敗的情況該怎麼解?(主要在用到redis,memcached這種進程外緩存的時候,由於網絡因素,失敗的可能性大增

 

辦法也是有的,在操作數據庫的時候帶一個事務,如果緩存操作失敗則事務回滾。大致的代碼意思如下:

begin trans
    var isDbSuccess = write db;
    if(isDbSuccess){
        var isCacheSuccess = write cache;
        if(isCacheSuccess){
            return success;
        }
        else{
            rollback db;
            return fail;
        }
    }
    else{
        return fail;
    }
    catch(Exception ex){
        rollback db;
    }
end trans

 

 

如此一來就萬無一失了嗎?並不是。除了由於事務的引入,增加了數據庫的壓力之外,在極端場景下可能會出現rollback db失敗的情況。是不是很頭疼?

 

解決這個問題的方式就是write cache的時候做delete操作,而不是set操作。如此一來,用多一次cache miss的代價來換rollback db失敗的問題。

 

 

就像圖上所示,哪怕rollback失敗了,通過一次cache miss重新從db中載入舊值。

 

題外話:其實這種做法有一種專業的叫法——Cache Aside Pattern。爲了便於記憶,你可以和分佈式系統的CAP定理同時記憶,叫「緩存的CAP模式」。

 

 

是不是看上去妥了?可以開始瀟灑了?

 

▲圖片來源於網絡,版權歸原作者所有

 

如果你的數據庫沒有做高可用的話,的確可以妥了。但是如果數據庫做了高可用,就會涉及到主從數據庫的數據同步,這就有新問題了

 

題外話:所以大家不要過度追求技術的酷炫,可能會得不償失,自找麻煩。

 

 

什麼問題呢?就是如果在數據還未同步到「從庫」的時候,由於cache miss去「從庫」取到了未同步前的舊值。

 

 

 

解決它的第一個方式很簡單,也很粗暴。就是定時去「從庫」讀數據,發現數據和緩存不一樣了就set到緩存裏去。

 

 

但是這個方式有點“治標不治本”。不斷的從數據庫定時讀取,對資源的消耗大不說,這個間隔頻率也不好定義一個比較合適的統一標準,太短吧,會導致重複讀取的次數加大,太長吧,又會導致緩存和數據庫不一致的時間變長。

 

所以這個方案僅適用於項目中只有2、3處需要做這種處理的場景,並且還不能是數據會頻繁修改的情況。因爲在數據修改頻次較高的場景,甚至可能還會出現這個定時機制所消耗的資源反而大於主程序的情況。

 

 

一般情況下,另一種更普適性的方案是採用接下去聊的這種更底層的方式進行,就是“哪裏有問題處理哪裏”,當「從庫」完成同步的時候再額外做一次delete cache或者set cache的操作

 

 

如此,雖說也沒有100%解決短暫的數據不一致問題,但是已經將髒數據所存在的時長降到了最低(最終由主從同步的耗時決定),並且大大減少了無謂的資源消耗。

 

 

可能你會說,“不行,這麼一點時間也不能忍”怎麼辦?辦法是有,但是會增加「主庫」的壓力。就是在產生數據庫寫入動作後的一小段時間內強制讀「主庫」來加載緩存

 

怎麼實現呢?先得依賴一個共享存儲,可以藉助數據庫或者也可以是我們現在正在聊的分佈式緩存。

 

然後,你在事務提交之後往共享存儲中臨時存一個{ key = dbname + tablename + id,value = null,expire = 3s }這樣的數據,並且再做一次delete cache的操作。

 

begin trans
    var isDbSuccess = write db;
    if(isDbSuccess){        
        var isCacheSuccess = delete cache;
        if(isCacheSuccess){
            return success;
        }
        else{
            rollback db;
            return fail;
        }
    }
    else{
        return fail;
    }
    catch(Exception ex){
        rollback db;
    }
end trans
​
//在這裏做這個臨時存儲,{key,value,expire}。
delete cache;

 

如此一來,當「讀數據」的時候發生cache miss,先判斷是否存在這個臨時數據,只要在3秒內就會強制走「主庫」取數據。

 

 

可以看到,不同的方案各有利弊,需要根據具體的場景仔細權衡。

 

 

先緩存再DB

你工作中的大部分場景對數據準確性肯定是低容忍的,所以一般不建議選擇「先緩存再DB」的方案,因爲內存是易失性的。一旦遇到操作緩存成功,操作DB失敗的情況,問題就來了。

 

 

在這個時候最新的數據只有緩存裏有,怎麼辦?單獨起個線程不斷的重試往數據庫寫?這個方案在一定程度上可行,但不適合用於對數據準確性有高要求的場景,因爲緩存一旦掛了,數據就丟了!

 

題外話:哪怕選擇了這個方案,重試線程應確保只有1個,否則會存在“ABBA”的「併發寫」問題。

 

 

可能你會說用delete cache不就沒問題了?

 

可以是可以,但是要有個前提條件,訪問緩存的程序不會產生併發。因爲只要你的程序是多線程運行的,一旦出現併發就有可能出現「讀」的線程由於cache miss從數據庫取的時候,「寫」的線程還沒將數據寫到數據庫的情況。

 

 

所以,哪怕用delete cache的方式,要麼帶lock多客戶端情況下還得上分佈式鎖),要麼必然出現數據不一致

 

 

值得注意的是,如果數據庫同樣做了高可用,哪怕帶了lock,也還需要考慮和上面提到的「先DB再緩存」中一樣的由於主從同步的時間差可能會產生的問題。

 

當然了,「先緩存再DB」也不是一文不值。當對寫入速度有極致要求,而對數據準確性沒那麼高要求的場景下就非常好使,其實就是前一篇(《360°全方位解讀「緩存」》)提到的「延遲寫」機制。

 

 

小結一下,相比緩存來說,數據庫的「高可用」一般會在系統發展的後期纔會引入,所以在沒有引入數據庫「高可用」的情況下,Z哥建議你使用「先DB再緩存」的方式,並且緩存操作用delete而不是set,這樣基本就可以高枕無憂了。

 

但是如果數據庫做了「高可用」,那麼團隊必然也形成一定規模了,這個時候就老老實實的做數據庫變更記錄(binlog)的訂閱吧。

 

 

到這裏可能有的小夥伴要問了,“如果上了分佈式緩存,還需要本地緩存嗎?”。

 

 

本地緩存還要不要?

在解答這個問題之前我們先來思考一個問題,一個分佈式系統最重要的價值是什麼?

 

是「無限擴展」,只要堆硬件就能應對業務增長。要達到這點的背後需要滿足一個特性,就是程序要「無狀態」。那麼既想引入緩存來加速,又要達到「無狀態」,靠的就是分佈式緩存。

 

所以,能用分佈式緩存解決的問題就儘量不要引入本地緩存。否則引入分佈式緩存的作用就小了很多。

 

 

但是在少數場景下,本地緩存還是可以發揮其價值的,但是我們需要仔細識別出來。主要是三個場景:

  1. 不經常變更的數據。(比如一天甚至好幾天更新一次的那種

  2. 需要支撐非常高的併發。(比如秒殺

  3. 對數據準確性能容忍的場景。(比如瀏覽量,評論數等

 

不過,我還是建議你,除了第二種場景,否則還是儘量不要引入本地緩存。原因我們下面來說說。

 

 

其實這個原因的根本問題就是在引入了本地緩存後,本地緩存(進程內緩存)、分佈式緩存(進程外緩存)、數據庫這三者之間的數據一致性該怎麼進行呢?

 

 

本地緩存、分佈式緩存、db之間的數據一致性

如果是個單點應用程序的話,很簡單,將本地緩存的操作放在最後就好了。

 

可能你會說本地緩存修改失敗怎麼辦?比如重複key啊什麼的異常。那你可以反思一下爲這種數據爲什麼可以成功的寫進數據庫。。。

 

 

但是,本地緩存帶來的一個巨大問題就是:雖然一個節點沒問題,但是多個本地緩存節點之間的數據如何同步?

 

解決這個問題的方式中有兩種和之前我們聊過的Session問題(《做了「負載均衡」就可以隨便加機器了嗎?》)是類似的。要麼是由接收修改的節點通知其它節點變更(通過rpc或者mq皆可),要麼藉助一致性hash讓同一個來源的請求固定落到一個節點上。後者可以讓不同節點上的本地緩存數據都不重複,從源頭上避免了這個問題。

 

但是這兩個方案走的都是極端,前者變更成本太高,比如需要通知上千個節點的話,這個成本難以接受。而後者的話對資源的消耗太高,而且還容易出現壓力分攤不均勻的問題。所以,一般系統規模小的時候可以考慮前者,而規模越大越會選擇後者

 

還有一種相對中庸一些的,以降低數據的準確性來換成本的方案。就是設置緩存定時過期或者定時往下游的分佈式緩存拉取最新數據。這和前面「先DB再緩存」中提到的定時機制是一樣的邏輯,勝在簡單,缺點就是會存在更長時間的數據不一致。

 

 

小結一下,本地緩存的數據一致性解決方案,能徹底解決的是藉助一致性hash的方案,但是成本比較高。所以,如非必要還是慎重決定要不要做本地緩存。

 

 

總結

好了,我們一起總結一下。

 

這次呢,Z哥先花了大量的篇幅和你討論「先寫DB還是緩存」的問題,並且帶你層層深入,通過一點一點的演進來闡述不同的解決方案。

 

然後與你討論了「本地緩存」的意義以及如何在「分佈式緩存」和「數據庫」的基礎上做好數據一致性,這其中主要是多個本地緩存節點之間的數據同步問題。

 

希望對你有所啓發。

 

 

這次的緩存實踐是一個非常好的例子,從中我們可以看到一件事情的精細化所帶來的複雜度需要更加的精細化去解決,但是又會帶來新的複雜度。所以作爲技術人的你,需要無時無刻考慮該怎麼權衡,而不是人云亦云

 

 

 


 

相關文章:

 


 

作者:Zachary

出處:https://zacharyfan.com/archives/666.html

 

如果你喜歡這篇文章,可以點一下右下角的「推薦」。

 

這樣可以給我一點反饋。: )

 

謝謝你的舉手之勞。

 

▶關於作者:張帆(Zachary,個人微信號:Zachary-ZF)。堅持用心打磨每一篇高質量原創。歡迎掃描右側的二維碼~。

定期發表原創內容:架構設計丨分佈式系統丨產品丨運營丨一些思考。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章