Redis
是企業級系統高併發、高可用架構中很是重要的一個環節。Redis主要解決了關係型數據庫併發量低的問題,有助於緩解關係型數據庫在高併發場景下的壓力,提升系統的吞吐量(具體Redis是如何提升系統的性能、吞吐量,後面會專門講)。前端
而咱們在Redis的實際使用過程當中,不免會遇到緩存與數據庫雙寫時數據不一致的問題,這也是咱們必需要考慮的問題。若是還有同窗不瞭解這個問題,能夠搬小板凳來聽聽啦。nginx
要講數據庫+緩存雙寫不一致的問題,就須要先講一下這個問題是怎麼發生的。咱們選擇電商系統中要求數據實時性較高的庫存服務來舉例講講這個問題。redis
庫存可能會修改,每次修改數據庫的同時也都要去更新這個緩存數據;;每次庫存的數據,在緩存中一旦過時,或者是被清理掉了,前端對庫存數據的請求都會發送給庫存服務,去獲取相應的數據。數據庫
庫存這一塊,寫數據庫的時候,直接更新redis緩存嗎?實際上不是,由於沒有這麼簡單。這裏,其實就涉及到了一個問題,數據庫與緩存雙寫,數據不一致的問題。圍繞和結合實時性較高的庫存服務,把數據庫與緩存雙寫不一致問題以及其解決方案,給你們分享一下。緩存
問題服務器
若是是先修改數據庫,再刪除緩存的方案,會有問題,試想,若是刪除緩存失敗了,那麼會致使數據庫中是新數據,緩存中是舊數據,出現數據不一致的狀況。架構
解決思路併發
反過來,先刪除緩存,再修改數據庫。讀緩存讀不到,查數據庫更新緩存的時候就拿到了最新的庫存數據。若是刪除緩存成功了,而修改數據庫失敗了,那麼數據庫中依舊是舊數據,緩存中是空的,那麼數據不會不一致。由於讀的時候緩存沒有,則讀數據庫中舊數據,而後更新到緩存中。異步
當庫存數據發生了變動,咱們先刪除了緩存,而後要去修改數據庫。jvm
設想一下,若是這個時候修改數據庫的操做還沒來及完成,忽然一個請求過來,去讀緩存,發現緩存空了,去查詢數據庫,查到了修改前的舊數據,放到了緩存中。
數據變動的操做完成後數據庫的庫存被修改爲了新值,但緩存中又變成了舊數據。那麼這個時候是否是還會出現緩存和數據庫不一致的狀況?
上述問題,只有在對一個數據在併發的進行讀寫的時候,纔可能會出現。
其實若是併發量很低的話,特別是讀併發很低,天天訪問量就1萬次,那麼不多的狀況下,會出現剛纔描述的那種不一致的場景。
可是問題是,高併發了之後,問題是不少的。若是天天的是上億的流量,每秒併發讀是幾萬,每秒只要有數據更新的請求,就可能會出現上述的數據庫+緩存不一致的狀況。
怎麼解決?
這裏說一種解決方案。
不就是還沒更新數據庫的就查數據庫讀到舊數據嗎?不就是由於讀在更新前面了嗎?那我就讓你排隊執行唄。
我在系統內部維護n個內存隊列,更新數據的時候,根據數據的惟一標識,將該操做路由以後,發送到其中一個jvm內部的內存隊列中(對同一數據的請求發送到同一個隊列)。讀取數據的時候,若是發現數據不在緩存中,而且此時隊列裏有更新庫存的操做,那麼將從新讀取數據+更新緩存的操做,根據惟一標識路由以後,也將發送到同一個jvm內部的內存隊列中。而後每一個隊列對應一個工做線程,每一個工做線程串行地拿到對應的操做,而後一條一條的執行。
這樣的話,一個數據變動的操做,先執行刪除緩存,而後再去更新數據庫,可是還沒完成更新的時候,若是此時一個讀請求過來,讀到了空的緩存,那麼能夠先將緩存更新的請求發送到隊列中,此時會在隊列中積壓,排在剛纔更新庫的操做以後,而後同步等待緩存更新完成,再讀庫。
多個讀庫更新緩存的請求串在同一個隊列中是沒意義的,所以能夠作過濾,若是發現隊列中已經有了該數據的更新緩存的請求了,那麼就不用再放進去了,直接等待前面的更新操做請求完成便可,待那個隊列對應的工做線程完成了上一個操做(數據庫的修改)以後,纔會去執行下一個操做(讀庫更新緩存),此時會從數據庫中讀取最新的值,而後寫入緩存中。
若是請求還在等待時間範圍內,不斷輪詢發現能夠取到值了,那麼就直接返回; 若是請求等待的時間超過必定時長,那麼這一次直接從數據庫中讀取當前的舊值。(返回舊值不是又致使緩存和數據庫不一致了麼?那至少能夠減小這個狀況發生,由於等待超時也不是每次都是,概率很小吧。這裏我想的是,若是超時了就直接讀舊值,這時候僅僅是讀庫後返回而不放緩存)
高併發的場景下,該解決方案其實仍是有一些問題須要特別注意的。
因爲讀請求進行了很是輕度的異步化,因此必定要注意讀超時的問題,每一個讀請求必須在超時時間範圍內返回。
該解決方案,最大的風險點在於,數據更新很頻繁的狀況下致使隊列中積壓了大量更新操做在裏面,而後讀請求會發生大量的超時,最後致使大量的請求直接走數據庫取到了舊值。因此,務必經過一些模擬真實的測試,看看更新數據頻繁的場景下是怎樣的。
另一點,由於一個隊列中,可能會積壓針對多個數據項的更新操做,所以須要根據本身的業務狀況進行測試,肯定一個實例中建立多少個內存隊列,且可能須要部署多個服務,每一個服務分攤一些數據的更新操做。
若是一個內存隊列裏積壓100個商品的庫存修改操做,每一個庫存修改操做要耗費10ms去完成,那麼最後一個商品的讀請求,可能等待10 * 100 = 1000ms = 1s後,才能獲得數據。
這個時候就致使讀請求的長時阻塞。
必定要作根據實際業務系統的運行狀況,去進行一些壓力測試,和模擬線上環境,去看看最繁忙的時候,內存隊列可能會擠壓多少更新操做,可能會致使最後一個更新操做對應的讀請求,會hang多少時間。若是讀請求在200ms返回,並且你計算事後,哪怕是最繁忙的時候,積壓10個更新操做,最多等待200ms,那還能夠的。
若是一個內存隊列可能積壓的更新操做特別多,那麼你就要加機器,讓每一個機器上部署的服務實例處理更少的數據,那麼每一個內存隊列中積壓的更新操做就會越少。
Tips:
其實根據以前的項目經驗,通常來講數據的寫頻率是很低的,所以實際上正常來講,在隊列中積壓的更新操做應該是不多的。針對讀高併發,讀緩存架構的項目,通常寫請求相對讀來講,是很是很是少的,每秒的QPS能到幾百就不錯了。
假如一秒500的寫操做,能夠當作5份,每200ms就100個寫操做。對於單機器,若是又20個內存隊列,每一個內存隊列,可能就積壓5個寫操做,每一個寫操做性能測試後,通常在20ms左右就完成。
那麼針對每一個內存隊列中的數據的讀請求,也就最多hang一下子,200ms之內確定能返回了。
假如寫QPS擴大10倍,可是通過剛纔的測算,就知道,單機支撐寫QPS幾百沒問題,那麼就擴容機器,擴容10倍的機器,10臺機器,每一個機器20個隊列,200個隊列。
大部分的狀況下,應該是這樣的:大量的讀請求過來,都是直接走緩存取到數據的。少許狀況下,可能遇到讀和數據更新衝突的狀況。如上所述,那麼此時更新操做若是先入隊列,以後可能會瞬間來了對這個數據大量的讀請求,可是由於作了去重的優化,因此也就一個更新緩存的操做跟在它後面。
等數據更新完了,讀請求觸發的緩存更新操做也完成,而後臨時等待的讀請求所有能夠讀到緩存中的數據。
這裏還必須作好壓力測試,確保恰巧碰上上述狀況的時候,還有一個風險,就是忽然間大量讀請求會在幾十毫秒的延時hang在服務上,看服務能不能抗的住,須要多少機器才能抗住最大的極限狀況的峯值。
可是由於並非全部的數據都在同一時間更新,緩存也不會同一時間失效,因此每次可能也就是少數數據的緩存失效了,而後那些數據對應的讀請求過來,併發量應該也不會特別大。
Tips:
若是按1:99的比例計算寫和讀的請求,那麼每秒5萬的讀QPS,可能只有500次更新操做。
若是一秒有500的寫QPS,那麼要測算好,可能寫操做影響的數據有500條,這500條數據在緩存中失效後,可能致使多少讀緩存的請求,發送讀請求到庫存服務來,要求更新緩存。通常來講,按1:2的話,每秒鐘有1000個讀請求去讀這500個正在更新庫的數據,就會有1000個請求hang在庫存服務上,若是規定每一個請求200ms就返回,那麼每一個讀請求最多hang多少時間,這個必定要測算好。
在同一時間最多hang住的可能也就是單機200個讀請求,最壞的同時hang住,單機hang200個讀請求,仍是ok的。
可是假如1:20,每秒更新500條數據,這500秒數據對應的讀請求,會有20 * 500 = 1萬,1萬個讀請求所有hang在庫存服務上,就死定了。
可能這個庫存服務部署了多個實例,那麼必須保證說,執行數據更新操做,以及執行緩存更新操做的請求,對於同一商品的讀寫請求所有路由到同一臺機器上。能夠本身去作服務間的按照某個請求的參數作hash路由,也能夠經過nginx服務器hash路由的功能路由到相同的服務實例上。
萬一某個商品的讀寫請求特別高,所有打到相同的機器的相同的隊列裏面去了,可能形成某臺機器的壓力過大。
可是由於只有在商品數據更新的時候纔會清空緩存,而後纔會致使讀寫併發,因此更新頻率不是過高的話,這個問題的影響並非特別大。
可是的確可能某些機器的負載會高一些。
通常來講,就是若是你的系統不是嚴格要求緩存+數據庫必須一致性的話,緩存能夠稍微的跟數據庫偶爾有不一致的狀況,那最好不要上述的串行化的這個方案,由於讀請求和寫請求串行化,串到一個內存隊列裏去,這樣是能夠保證必定不會出現不一致的狀況。可是,串行化以後,就會致使系統的吞吐量會大幅度的下降,你就須要用比正常狀況下多幾倍的機器去支撐線上的一個請求。
另外,不是說,講課的、寫文章的就是超人,萬能的。就跟寫書同樣,極可能會寫錯,也可能有些方案裏的一些地方,沒考慮到。或者有些方案只是適合某些場景,在某些場景下,可能須要你進行方案的優化和調整才能適用於你本身的項目。
若是你們以爲對這些方案有什麼疑問或者看法,均可以溝通交流。若是的確以爲是講解的不對,或者有些地方考慮不周,那麼都是能夠交流的,多多包涵。