項目實際應用中redis緩存與數據庫一致性問題解決

後臺服務代碼架構:項目實際應用中redis緩存與數據庫一致性問題解決

  • 需求起因

假設先寫數據庫,再淘汰緩存:第一步寫數據庫操作成功,第二步淘汰緩存失敗,則會出現DB中是新數據,Cache中是舊數據,數據不一致【如下圖:db中是新數據,cache中是舊數據】。

假設先淘汰緩存,再寫數據庫:第一步淘汰緩存成功,第二步寫數據庫失敗【如下圖:cache中無數據,db中是舊數據】。

 

結論:先淘汰緩存,再寫數據庫。

  • 數據不一致原因

先操作緩存,在寫數據庫成功之前,如果有讀請求發生,可能導致舊數據入緩存,引發數據不一致。

寫流程:

(1)先淘汰cache

(2)再寫db

讀流程:

(1)先讀cache,如果數據命中hit則返回

(2)如果數據未命中miss則讀db

(3)將db中讀取出來的數據入緩存

什麼情況下可能出現緩存和數據庫中數據不一致呢?

 
在分佈式環境下,數據的讀寫都是併發的,上游有多個應用,通過一個服務的多個部署(爲了保證可用性,一定是部署多份的),對同一個數據進行讀寫,在數據庫層面併發的讀寫並不能保證完成順序,也就是說後發出的讀請求很可能先完成(讀出髒數據):

(a)發生了寫請求A,A的第一步淘汰了cache(如上圖中的1)

(b)A的第二步寫數據庫,發出修改請求(如上圖中的2)

(c)發生了讀請求B,B的第一步讀取cache,發現cache中是空的(如上圖中的步驟3)

(d)B的第二步讀取數據庫,發出讀取請求,此時A的第二步寫數據還沒完成,讀出了一個髒數據放入cache(如上圖中的步驟4)

即在數據庫層面,後發出的請求4比先發出的請求2先完成了,讀出了髒數據,髒數據又入了緩存,緩存與數據庫中的數據不一致出現了

 

  • 問題解決思路

能否做到先發出的請求一定先執行完成呢?常見的思路是“串行化”

上圖是一個service服務的上下游及服務內部詳細展開,細節如下:

(1)service的上游是多個業務應用,上游發起請求對同一個數據併發的進行讀寫操作,上例中併發進行了一個uid=1的餘額修改(寫)操作與uid=1的餘額查詢(讀)操作

(2)service的下游是數據庫DB,假設只讀寫一個DB

(3)中間是服務層service,它又分爲了這麼幾個部分

(3.1)最上層是任務隊列

(3.2)中間是工作線程,每個工作線程完成實際的工作任務,典型的工作任務是通過數據庫連接池讀寫數據庫

(3.3)最下層是數據庫連接池,所有的SQL語句都是通過數據庫連接池發往數據庫去執行的

 

工作線程的典型工作流是這樣的:

void work_thread_routine(){

Task t = TaskQueue.pop(); // 獲取任務

// 任務邏輯處理,生成sql語句

DBConnection c = CPool.GetDBConnection(); // 從DB連接池獲取一個DB連接

c.execSQL(sql); // 通過DB連接執行sql語句

CPool.PutDBConnection(c); // 將DB連接放回DB連接池

}

 

提問:任務隊列其實已經做了任務串行化的工作,能否保證任務不併發執行?

答:不行,因爲

(1)1個服務有多個工作線程,串行彈出的任務會被並行執行

(2)1個服務有多個數據庫連接,每個工作線程獲取不同的數據庫連接會在DB層面併發執行

 

提問:假設服務只部署一份,能否保證任務不併發執行?

答:不行,原因同上

 

提問:假設1個服務只有1條數據庫連接,能否保證任務不併發執行?

答:不行,因爲

(1)1個服務只有1條數據庫連接,只能保證在一個服務器上的請求在數據庫層面是串行執行的

(2)因爲服務是分佈式部署的,多個服務上的請求在數據庫層面仍可能是併發執行的

 

提問:假設服務只部署一份,且1個服務只有1條連接,能否保證任務不併發執行?

答:可以,全局來看請求是串行執行的,吞吐量很低,並且服務無法保證可用性

 

完了,看似無望了,

1)任務隊列不能保證串行化

2)單服務多數據庫連接不能保證串行化

3)多服務單數據庫連接不能保證串行化

4)單服務單數據庫連接可能保證串行化,但吞吐量級低,且不能保證服務的可用性,幾乎不可行,那是否還有解?

 

退一步想,其實不需要讓全局的請求串行化,而只需要“讓同一個數據的訪問能串行化”就行。

在一個服務內,如何做到“讓同一個數據的訪問串行化”,只需要“讓同一個數據的訪問通過同一條DB連接執行”就行。

如何做到“讓同一個數據的訪問通過同一條DB連接執行”,只需要“在DB連接池層面稍微修改,按數據取連接即可”

獲取DB連接的CPool.GetDBConnection()【返回任何一個可用DB連接】改爲

CPool.GetDBConnection(longid)【返回id取模相關聯的DB連接】

 

這個修改的好處是:

(1)簡單,只需要修改DB連接池實現,以及DB連接獲取處

(2)連接池的修改不需要關注業務,傳入的id是什麼含義連接池不關注,直接按照id取模返回DB連接即可

(3)可以適用多種業務場景,取用戶數據業務傳入user-id取連接,取訂單數據業務傳入order-id取連接即可

這樣的話,就能夠保證同一個數據例如uid在數據庫層面的執行一定是串行的

 

稍等稍等,服務可是部署了很多份的,上述方案只能保證同一個數據在一個服務上的訪問,在DB層面的執行是串行化的,實際上服務是分佈式部署的,在全局範圍內的訪問仍是並行的,怎麼解決呢?能不能做到同一個數據的訪問一定落到同一個服務呢?

 

能否做到同一個數據的訪問落在同一個服務上?

 

上面分析了服務層service的上下游及內部結構,再一起看一下應用層上下游及內部結構

上圖是一個業務應用的上下游及服務內部詳細展開,細節如下:

(1)業務應用的上游不確定是啥,可能是直接是http請求,可能也是一個服務的上游調用

(2)業務應用的下游是多個服務service

(3)中間是業務應用,它又分爲了這麼幾個部分

(3.1)最上層是任務隊列【或許web-server例如tomcat幫你幹了這個事情了】

(3.2)中間是工作線程【或許web-server的工作線程或者cgi工作線程幫你幹了線程分派這個事情了】,每個工作線程完成實際的業務任務,典型的工作任務是通過服務連接池進行RPC調用

(3.3)最下層是服務連接池,所有的RPC調用都是通過服務連接池往下游服務去發包執行的

 

工作線程的典型工作流是這樣的:

voidwork_thread_routine(){

Task t = TaskQueue.pop(); // 獲取任務

// 任務邏輯處理,組成一個網絡包packet,調用下游RPC接口

ServiceConnection c = CPool.GetServiceConnection(); // 從Service連接池獲取一個Service連接

c.Send(packet); // 通過Service連接發送報文執行RPC請求

CPool.PutServiceConnection(c); // 將Service連接放回Service連接池

}

後臺服務代碼架構:項目實際應用中redis緩存與數據庫一致性問題解決

  • 需求起因

假設先寫數據庫,再淘汰緩存:第一步寫數據庫操作成功,第二步淘汰緩存失敗,則會出現DB中是新數據,Cache中是舊數據,數據不一致【如下圖:db中是新數據,cache中是舊數據】。

假設先淘汰緩存,再寫數據庫:第一步淘汰緩存成功,第二步寫數據庫失敗【如下圖:cache中無數據,db中是舊數據】。

 

結論:先淘汰緩存,再寫數據庫。

  • 數據不一致原因

先操作緩存,在寫數據庫成功之前,如果有讀請求發生,可能導致舊數據入緩存,引發數據不一致。

寫流程:

(1)先淘汰cache

(2)再寫db

讀流程:

(1)先讀cache,如果數據命中hit則返回

(2)如果數據未命中miss則讀db

(3)將db中讀取出來的數據入緩存

什麼情況下可能出現緩存和數據庫中數據不一致呢?

 
在分佈式環境下,數據的讀寫都是併發的,上游有多個應用,通過一個服務的多個部署(爲了保證可用性,一定是部署多份的),對同一個數據進行讀寫,在數據庫層面併發的讀寫並不能保證完成順序,也就是說後發出的讀請求很可能先完成(讀出髒數據):

(a)發生了寫請求A,A的第一步淘汰了cache(如上圖中的1)

(b)A的第二步寫數據庫,發出修改請求(如上圖中的2)

(c)發生了讀請求B,B的第一步讀取cache,發現cache中是空的(如上圖中的步驟3)

(d)B的第二步讀取數據庫,發出讀取請求,此時A的第二步寫數據還沒完成,讀出了一個髒數據放入cache(如上圖中的步驟4)

即在數據庫層面,後發出的請求4比先發出的請求2先完成了,讀出了髒數據,髒數據又入了緩存,緩存與數據庫中的數據不一致出現了

 

  • 問題解決思路

能否做到先發出的請求一定先執行完成呢?常見的思路是“串行化”

上圖是一個service服務的上下游及服務內部詳細展開,細節如下:

(1)service的上游是多個業務應用,上游發起請求對同一個數據併發的進行讀寫操作,上例中併發進行了一個uid=1的餘額修改(寫)操作與uid=1的餘額查詢(讀)操作

(2)service的下游是數據庫DB,假設只讀寫一個DB

(3)中間是服務層service,它又分爲了這麼幾個部分

(3.1)最上層是任務隊列

(3.2)中間是工作線程,每個工作線程完成實際的工作任務,典型的工作任務是通過數據庫連接池讀寫數據庫

(3.3)最下層是數據庫連接池,所有的SQL語句都是通過數據庫連接池發往數據庫去執行的

 

工作線程的典型工作流是這樣的:

void work_thread_routine(){

Task t = TaskQueue.pop(); // 獲取任務

// 任務邏輯處理,生成sql語句

DBConnection c = CPool.GetDBConnection(); // 從DB連接池獲取一個DB連接

c.execSQL(sql); // 通過DB連接執行sql語句

CPool.PutDBConnection(c); // 將DB連接放回DB連接池

}

 

提問:任務隊列其實已經做了任務串行化的工作,能否保證任務不併發執行?

答:不行,因爲

(1)1個服務有多個工作線程,串行彈出的任務會被並行執行

(2)1個服務有多個數據庫連接,每個工作線程獲取不同的數據庫連接會在DB層面併發執行

 

提問:假設服務只部署一份,能否保證任務不併發執行?

答:不行,原因同上

 

提問:假設1個服務只有1條數據庫連接,能否保證任務不併發執行?

答:不行,因爲

(1)1個服務只有1條數據庫連接,只能保證在一個服務器上的請求在數據庫層面是串行執行的

(2)因爲服務是分佈式部署的,多個服務上的請求在數據庫層面仍可能是併發執行的

 

提問:假設服務只部署一份,且1個服務只有1條連接,能否保證任務不併發執行?

答:可以,全局來看請求是串行執行的,吞吐量很低,並且服務無法保證可用性

 

完了,看似無望了,

1)任務隊列不能保證串行化

2)單服務多數據庫連接不能保證串行化

3)多服務單數據庫連接不能保證串行化

4)單服務單數據庫連接可能保證串行化,但吞吐量級低,且不能保證服務的可用性,幾乎不可行,那是否還有解?

 

退一步想,其實不需要讓全局的請求串行化,而只需要“讓同一個數據的訪問能串行化”就行。

在一個服務內,如何做到“讓同一個數據的訪問串行化”,只需要“讓同一個數據的訪問通過同一條DB連接執行”就行。

如何做到“讓同一個數據的訪問通過同一條DB連接執行”,只需要“在DB連接池層面稍微修改,按數據取連接即可”

獲取DB連接的CPool.GetDBConnection()【返回任何一個可用DB連接】改爲

CPool.GetDBConnection(longid)【返回id取模相關聯的DB連接】

 

這個修改的好處是:

(1)簡單,只需要修改DB連接池實現,以及DB連接獲取處

(2)連接池的修改不需要關注業務,傳入的id是什麼含義連接池不關注,直接按照id取模返回DB連接即可

(3)可以適用多種業務場景,取用戶數據業務傳入user-id取連接,取訂單數據業務傳入order-id取連接即可

這樣的話,就能夠保證同一個數據例如uid在數據庫層面的執行一定是串行的

 

稍等稍等,服務可是部署了很多份的,上述方案只能保證同一個數據在一個服務上的訪問,在DB層面的執行是串行化的,實際上服務是分佈式部署的,在全局範圍內的訪問仍是並行的,怎麼解決呢?能不能做到同一個數據的訪問一定落到同一個服務呢?

 

能否做到同一個數據的訪問落在同一個服務上?

 

上面分析了服務層service的上下游及內部結構,再一起看一下應用層上下游及內部結構

上圖是一個業務應用的上下游及服務內部詳細展開,細節如下:

(1)業務應用的上游不確定是啥,可能是直接是http請求,可能也是一個服務的上游調用

(2)業務應用的下游是多個服務service

(3)中間是業務應用,它又分爲了這麼幾個部分

(3.1)最上層是任務隊列【或許web-server例如tomcat幫你幹了這個事情了】

(3.2)中間是工作線程【或許web-server的工作線程或者cgi工作線程幫你幹了線程分派這個事情了】,每個工作線程完成實際的業務任務,典型的工作任務是通過服務連接池進行RPC調用

(3.3)最下層是服務連接池,所有的RPC調用都是通過服務連接池往下游服務去發包執行的

 

工作線程的典型工作流是這樣的:

voidwork_thread_routine(){

Task t = TaskQueue.pop(); // 獲取任務

// 任務邏輯處理,組成一個網絡包packet,調用下游RPC接口

ServiceConnection c = CPool.GetServiceConnection(); // 從Service連接池獲取一個Service連接

c.Send(packet); // 通過Service連接發送報文執行RPC請求

CPool.PutServiceConnection(c); // 將Service連接放回Service連接池

}

 

似曾相識吧?沒錯,只要對服務連接池進行少量改動:

獲取Service連接的CPool.GetServiceConnection()【返回任何一個可用Service連接】改爲

CPool.GetServiceConnection(longid)【返回id取模相關聯的Service連接】

這樣的話,就能夠保證同一個數據例如uid的請求落到同一個服務Service上。

                                                                                

由於數據庫層面的讀寫併發,引發的數據庫與緩存數據不一致的問題(本質是後發生的讀請求先返回了),可能通過兩個小的改動解決:

(1)修改服務Service連接池,id取模選取服務連接,能夠保證同一個數據的讀寫都落在同一個後端服務上

(2)修改數據庫DB連接池,id取模選取DB連接,能夠保證同一個數據的讀寫在數據庫層面是串行的

 

似曾相識吧?沒錯,只要對服務連接池進行少量改動:

獲取Service連接的CPool.GetServiceConnection()【返回任何一個可用Service連接】改爲

CPool.GetServiceConnection(longid)【返回id取模相關聯的Service連接】

這樣的話,就能夠保證同一個數據例如uid的請求落到同一個服務Service上。

                                                                                

由於數據庫層面的讀寫併發,引發的數據庫與緩存數據不一致的問題(本質是後發生的讀請求先返回了),可能通過兩個小的改動解決:

(1)修改服務Service連接池,id取模選取服務連接,能夠保證同一個數據的讀寫都落在同一個後端服務上

(2)修改數據庫DB連接池,id取模選取DB連接,能夠保證同一個數據的讀寫在數據庫層面是串行的

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