分散式系統先寫DB還是先快取?
由於篇幅原因,這次先聊三個問題。首先就是我們應該“先寫DB還是快取?”。我想,只要你開始運用快取,這會是你第一個要好好思考的問題,否則在前方等待你的就是災難……
一、先寫DB還是快取?
一個程式可以沒有快取,但是一定要有資料庫。這是大家的普遍觀點,所以資料庫的重要性在你的潛意識裡總是被放在了第一位。
1、先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秒內就會強制走「主庫」取資料。
可以看到,不同的方案各有利弊,需要根據具體的場景仔細權衡。
2、先快取再DB
你工作中的大部分場景對資料準確性肯定是低容忍的,所以一般不建議選擇「先快取再DB」的方案,因為記憶體是易失性的。一旦遇到操作快取成功,操作DB失敗的情況,問題就來了。
在這個時候最新的資料只有快取裡有,怎麼辦?單獨起個執行緒不斷的重試往資料庫寫?這個方案在一定程度上可行,但不適合用於對資料準確性有高要求的場景,因為快取一旦掛了,資料就丟了!
題外話: 哪怕選擇了這個方案,重試執行緒應確保只有1個,否則會存在“ABBA”的「併發寫」問題。
可能你會說用delete cache不就沒問題了?
可以是可以,但要有個前提條件,訪問快取的程式不會產生併發。因為只要你的程式是多執行緒執行的,一旦出現併發就有可能出現「讀」的執行緒由於cache miss從資料庫取的時候,「寫」的執行緒還沒將資料寫到資料庫的情況。
所以,哪怕用delete cache的方式,要麼帶lock(多客戶端情況下還得上分散式鎖),要麼必然出現數據不一致。
值得注意的是,如果資料庫同樣做了高可用,哪怕帶了lock,也還需要考慮和上面提到的「先DB再快取」中一樣的由於主從同步的時間差可能會產生的問題。
當然了,「先快取再DB」也不是一文不值。當對寫入速度有極致要求,而對資料準確性沒那麼高要求的場景下就非常好使,也就是「延遲寫」機制。
總結來看,相比快取來說,資料庫的「高可用」一般會在系統發展的後期才會引入,所以在沒有引入資料庫「高可用」的情況下,建議你使用「先DB再快取」的方式,並且快取操作用delete而不是set,這樣基本就可以高枕無憂了。
但是如果資料庫做了「高可用」,那麼團隊必然也形成一定規模了,這個時候就老老實實的做資料庫變更記錄(binlog)的訂閱吧。
到這裡可能有的小夥伴要問了,“如果上了分散式快取,還需要本地快取嗎?”
二、本地快取還要不要?
在解答這個問題之前我們先來思考一個問題,一個分散式系統最重要的價值是什麼?
是「無限擴充套件」,只要堆硬體就能應對業務增長。要達到這點的背後需要滿足一個特性,就是程式要「無狀態」。那麼既想引入快取來加速,又要達到「無狀態」,靠的就是分散式快取。
所以,能用分散式快取解決的問題就儘量不要引入本地快取。否則引入分散式快取的作用就小了很多。
但是在少數場景下,本地快取還是可以發揮其價值的,但是我們需要仔細識別出來。主要是三個場景:
-
不經常變更的資料。(比如一天甚至好幾天更新一次的那種)
-
需要支撐非常高的併發。(比如秒殺)
-
對資料準確性能容忍的場景。(比如瀏覽量,評論數等)
不過,我還是建議,除了第二種場景,其餘儘量還是不要引入本地快取。根本原因在於 在引入了本地快取後,本地快取(程序內快取)、分散式快取(程序外快取)、資料庫這三者之間的資料一致性該怎麼進行呢?
三、本地快取、分散式快取、DB之間的資料一致性
如果是個單點應用程式的話,很簡單,將本地快取的操作放在最後就好了。
可能你會說本地快取修改失敗怎麼辦?比如重複key啊什麼的異常。那你可以反思一下為這種資料為什麼可以成功的寫進資料庫……
但是,本地快取帶來的一個巨大問題就是:雖然一個節點沒問題,但是多個本地快取節點之間的資料如何同步?
解決這個問題的方式中有兩種。要麼是由接收修改的節點通知其它節點變更(通過rpc或者mq皆可),要麼藉助一致性hash讓同一個來源的請求固定落到一個節點上。後者可以讓不同節點上的本地快取資料都不重複,從源頭上避免了這個問題。
但是這兩個方案走的都是極端,前者變更成本太高,比如需要通知上千個節點的話,這個成本難以接受;而後者的話對資源的消耗太高,而且還容易出現壓力分攤不均勻的問題。所以,一般系統規模小的時候可以考慮前者,而規模越大越會選擇後者。
還有一種相對中庸一些的,以降低資料的準確性來換成本的方案。就是設定快取定時過期或者定時往下游的分散式快取拉取最新資料。這和前面「先DB再快取」中提到的定時機制是一樣的邏輯,勝在簡單,缺點就是會存在更長時間的資料不一致。
小結一下,本地快取的資料一致性解決方案,能徹底解決的是藉助一致性hash的方案,但是成本比較高。所以,如非必要還是慎重決定要不要做本地快取。
四、總結
好了,我們一起總結一下。
這次呢,Z哥先花了大量的篇幅和你討論「先寫DB還是快取」的問題,並且帶你層層深入,通過一點一點的演進來闡述不同的解決方案。
然後與你討論了「本地快取」的意義以及如何在「分散式快取」和「資料庫」的基礎上做好資料一致性,這其中主要是多個本地快取節點之間的資料同步問題。
希望對你有所啟發。
這次的快取實踐是一個非常好的例子,從中我們可以看到一件事情的精細化所帶來的複雜度需要更加的精細化去解決,但是又會帶來新的複雜度。所以作為技術人的你,需要無時無刻考慮該怎麼權衡,而不是人云亦云。