快取與資料庫一致性保證
本文主要討論這麼幾個問題:
(1)啥時候資料庫和快取中的資料會不一致
(2)不一致優化思路
(3)如何保證資料庫與快取的一致性
一、需求緣起
上一篇《快取架構設計細節二三事》(點選檢視)引起了廣泛的討論,其中有一個結論:當資料發生變化時,“先淘汰快取,再修改資料庫”這個點是大家討論的最多的。
上篇文章得出這個結論的依據是,由於操作快取與操作資料庫不是原子的,非常有可能出現執行失敗。
假設先寫資料庫,再淘汰快取:第一步寫資料庫操作成功,第二步淘汰快取失敗,則會出現DB中是新資料,Cache中是舊資料,資料不一致【如上圖:db中是新資料,cache中是舊資料】。
假設先淘汰快取,再寫資料庫:第一步淘汰快取成功,第二步寫資料庫失敗,則只會引發一次
結論:先淘汰快取,再寫資料庫。
引發大家熱烈討論的點是“先操作快取,在寫資料庫成功之前,如果有讀請求發生,可能導致舊資料入快取,引發資料不一致”,這就是本文要討論的主題。
二、為什麼資料會不一致
回顧一下上一篇文章中對快取、資料庫進行讀寫操作的流程。
寫流程:
(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先完成了,讀出了髒資料,髒資料又入了快取,快取與資料庫中的資料不一致出現了
三、不一致優化思路
能否做到先發出的請求一定先執行完成呢?常見的思路是“序列化”,今天將和大家一起探討“序列化”這個點。
先一起細看一下,在一個服務中,併發的多個讀寫SQL一般是怎麼執行的
上圖是一個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連線,能夠保證同一個資料的讀寫在資料庫層面是序列的
六、遺留問題
提問:取模訪問服務是否會影響服務的可用性?
答:不會,當有下游服務掛掉的時候,服務連線池能夠檢測到連線的可用性,取模時要把不可用的服務連線排除掉。
提問:取模訪問服務與取模訪問DB,是否會影響各連線上請求的負載均衡?
答:不會,只要資料訪問id是均衡的,從全域性來看,由id取模獲取各連線的概率也是均等的,即負載是均衡的。
提問:要是資料庫的架構做了主從同步,讀寫分離:寫請求寫主庫,讀請求讀從庫也有可能導致快取中進入髒資料呀,這種情況怎麼解決呢(讀寫請求根本不落在同一個DB上,並且讀寫DB有同步時延)?
答:下一篇文章和大家分享。
參考文章:http://mp.weixin.qq.com/s?__biz=MjM5ODYxMDA5OQ==&mid=404202261&idx=1&sn=1b8254ba5013952923bdc21e0579108e&scene=23&srcid=0329PzNmJIxdTmI89vKBqpFD#rd