原文出處:緩存與數據庫一致性系列
作者:陶笛日記
緩存與數據庫一致性系列-01
今天,我們來分析一下,緩存與數據庫被使用次數最多的一種使用方法
寫流程:
第一步先刪除緩存,刪除之后再更新DB,之后再異步將數據刷回緩存
讀流程:
第一步先讀緩存,如果緩存沒讀到,則去讀DB,之后再異步將數據刷回緩存
方案分析
優點剖析
1. 實現起來簡單
What Should I Say ?
2. “先淘汰緩存,再寫數據庫” 合理
為什么說這也算優點呢?試想一下
如果把寫流程改一下:先更新緩存,再更新DB。 如果我們更新緩存成功,而更新數據庫失敗,就會導致緩存中的數據是錯誤的,而我們大部分的業務一般能忍受數據延遲,但是數據錯誤這是無法接受的,所以先淘汰緩存是比較合理的。 如果把寫流程改一下:不刪緩存,先更新DB,再更新緩存。 如果我們更新DB成功,而更新緩存失敗,則會導致緩存中就會一直是舊的數據(也算是一種錯誤數據),所以先淘汰緩存是比較合理的。
3. 異步刷新,補缺補漏
在很多業務場景中,緩存只是輔助,所以在很多業務中,緩存的讀寫失敗不會影響主流程,啥意思呢?就是說很多情況下,即使操作緩存失敗(比如步驟1.1中的’DEL緩存失敗’),程序還是會繼續往下走(繼續步驟1.2 更新數據庫),所以這個時候異步刷新就能在一定程度上,對1.1的失敗進行錯誤數據的修補
說完優點,我們再來看看缺點
缺點剖析
1. 容災不足
在分布式領域,“Everything will fails”,任何可能出現問題的地方都會出現問題
我們來分析一下寫流程,第一步,’DEL緩存失敗’怎么辦?流程是否還繼續走?如果繼續執行,那么從’更新完DB’到異步’刷新緩存’緩存期間,數據處於滯后狀態。而且如果緩存處於不可寫狀態,那么異步刷新那步也可能會失敗,那緩存就會長期處於舊數據,問題就比較嚴重了
2. 並發問題
寫寫並發:試想一下,同時有多個服務器的多個線程進行’步驟1.2更新DB’,更新DB完成之后,它們就要進行異步刷緩存,我們都知道多服務器的異步操作,是無法保證順序的,所以后面的刷新操作存在相互覆蓋的並發問題,也就是說,存在先更新的DB操作,反而很晚才去刷新緩存,那這個時候,數據也是錯的
讀寫並發:再試想一下,服務器A在進行’讀操作’,,在A服務器剛完成2.2時,服務器B在進行’寫操作’,假設B服務器1.3完成之后,服務器A的1.3才被執行,這個時候就相當於更新前的老數據寫入緩存,最終數據還是錯的
方案總結
今天介紹的這個方案呢,適合大部分的業務場景,很多人都在用,香還是很香的,實現起來也簡單。
適合使用的場景:並發量、一致性要求都不是很高的情況。
我覺得這個方案有一個比較大的缺陷在於刷新緩存有可能會失敗,而失敗之后緩存中數據就一直會處於錯誤狀態,所以它並不能保證數據的最終一致性,那怎么解決這個問題呢,篇幅有限,我們后續文章再繼續分享
緩存與數據庫一致性系列-02
在《緩存與數據庫一致性系列-01》文章,我們提到,“它的一個比較大的缺陷在於刷新緩存有可能會失敗,而失敗之后緩存中數據就一直會處於錯誤狀態,所以它並不能保證數據的最終一致性”
為了保證“數據最終一致性”,我們引入binlog,通過解析binlog來刷新緩存,這樣即使刷新失敗,依然可以進行日志回放,再次刷新緩存
寫流程:
第一步先刪除緩存,刪除之后再更新DB,我們監聽從庫(資源少的話主庫也ok)的binlog,通過分析binlog我們解析出需要需要刷新的數據,然后讀主庫把最新的數據寫入緩存。
這里需要提一下:最后刷新前的讀主庫或者讀從庫,甚至不讀庫直接通過binlog解析出需要的數據都是ok的,這由業務決定,比如刷新的數據只是表的一行,那直接通過binlog就完全能解析出來;然而如果需要刷新的數據來自多行,多張表,甚至多個庫的話,那就需要讀主庫或是從庫才行
讀流程:
第一步先讀緩存,如果緩存沒讀到,則去讀DB,之后再異步將數據刷回緩存
方案分析
優點剖析
1. 容災
寫步驟1.4或1.5 如果失敗,可以進行日志回放,再次重試。
無論步驟1.1是否刪除成功,后續的刷新操作是有保證的
媽耶,怎么就一個優點,講道理這個其實很常用的,那我們再來看看缺點
缺點剖析
分析缺點之前,我們先來看一下知識點
對於同一張表的同一條記錄的更新,Databus會以串行形式的通知下游服務,也就是說,只有當我們正確返回后,它才會推送該記錄的下一次更新。
對於同一張表的不同記錄的更新, Databus會以事件時間為順序的通知下游服務,但並不會等待我們返回后才推送下一條,也就是說它是非串行的。
對於不同表,根據其下游的消費速度,不同表之間沒有明確的時間順序。
1. 只適合簡單業務,復雜業務容易發生並發問題
這里先來解釋一下這里說的“簡單業務”是啥意思?
簡單業務:每次需要刷新的數據,都來自單表單行。
為什么復雜業務就不行呢?我舉個例子
我們假設 一個訂單 = A表信息 + B表信息
由於A表先變化,經過1,2,3步后,線程1獲取了A’B (A表是新數據,B表的老數據),當線程1還沒來得及刷新緩存時,並發發生了:
此時,B表發生了更新,經過4,5,6,7將最新的數據A’B’寫入緩存,此時此刻緩存數據是符合要求的。
但是,后來線程1進行了第8步,將A’B寫入數據,使得緩存最終結果 與 DB 不一致。
缺點1的改進
- 針對單庫多表單次更新的改進:利用事務
當AB表的更新發生在一個事務內時,不管線程1、線程2如何讀取,他們都能獲取兩張表的最新數據,所以刷新緩存的數據都是符合要求的。
但是這種方案具有局限性:那就是只對單次更新有效,或者說更新頻率低的情況下才適應,比如我們並發的單獨更新C表,並發問題依然會發生。
所以這種方案只針對多表單次更新的情況。
- 針對多表多次更新的改進:增量更新
每張表的更新,在同步緩存時,只獲取該表的字段覆蓋緩存。
這樣,線程1,線程2總能獲取對應表最新的字段,而且Databus對於同表同行會以串行的形式通知下游,所以能保證緩存的最終一致性。
這里有一點需要提一下:更新“某張表多行記錄“時,這個操作要在一個事務內,不然並發問題依然存在,正如前面分析的
2. 依然是並發問題
即使對於缺點1我們提出了改進方案,雖然它解決了部分問題,但在極端場景下依然存在並發問題。
這個場景,就是緩存中沒有數據的情況:
- 讀的時候,緩存中的數據已失效,此時又發生了更新
- 數據更新的時候,緩存中的數據已失效,此時又發生了更新
這個時候,我們在上面提到的“增量更新”就不起作用了,我們需要讀取所有的表來拼湊出初始數據,那這個時候又涉及到讀所有表的操作了,那我們在缺點1中提到的並發問題會再次發生
方案總結
適合使用的場景:業務簡單,讀寫QPS比較低的情況。
今天這個方案呢,優缺點都比較明顯,binlog用來刷新緩存是一個很棒的選擇,它天然的順序性用來做同步操作很具有優勢;其實它的並發問題來自於Canal 或 Databus。拿Databus來說,由於不同行、表、庫的binlog的消費並不是時間串行的,那怎么解決這個問題呢,篇幅有限,我們后續文章再繼續分享
參考文獻
1. Canal
2. Databus
3. Databus & Canal 對比
緩存與數據庫一致性系列-03
經過前兩篇的討論,我們離實現“最終一致性”只差一步了
在《緩存與數據庫一致性系列-02》文章,我們提到上一個方案的並發問題
- 讀的時候,緩存中的數據已失效,此時又發生了更新
- 數據更新的時候,緩存中的數據已失效,此時又發生了更新
那我們我們可以看到,這個問題就來自於“讀數據庫” + “寫緩存” 之間的交錯並發,那怎么來避免呢?
有一個方法就是:串行化,我們利用MQ將所有“讀數據庫” + “寫緩存”的步驟串行化
寫流程:
第一步先刪除緩存,刪除之后再更新DB,我們監聽從庫(資源少的話主庫也ok)的binlog,通過分析binlog我們解析出需要需要刷新的數據標識,然后將數據標識寫入MQ,接下來就消費MQ,解析MQ消息來讀庫獲取相應的數據刷新緩存。
關於MQ串行化,大家可以去了解一下 Kafka partition 機制 ,這里就不詳述了
讀流程:
第一步先讀緩存,如果緩存沒讀到,則去讀DB,之后再異步將數據標識寫入MQ(這里MQ與寫流程的MQ是同一個),接下來就消費MQ,解析MQ消息來讀庫獲取相應的數據刷新緩存。
方案分析
優點剖析
1. 容災完善
我們一步一步來分析:
寫流程容災分析
- 寫1.1 DEL緩存失敗:沒關系,后面會覆蓋
- 寫1.4 寫MQ失敗:沒關系,Databus或Canal都會重試
- 消費MQ的:1.5 || 1.6 失敗:沒關系,重新消費即可
讀流程容災分析
- 讀2.3 異步寫MQ失敗:沒關系,緩存為空,是OK的,下次還讀庫就好了
2. 無並發問題
這個方案讓“讀庫 + 刷緩存”的操作串行化,這就不存在老數據覆蓋新數據的並發問題了
缺點剖析
要什么自行車啦
方案總結
經過3篇由淺入深的介紹,我們終於實現了“最終一致性”。這個方案優點比較明顯,解決了我們前幾篇一直提到的“容災問題”和“並發問題”,保證了緩存在最后和DB的一致。如果你的業務只需要達到“最終一致性”要求的話,這個方案是比較合理的。
OK,到目前為止,既然已經實現了“最終一致性”,那我們再進一步,“強一致性”又該如何實現呢?我們下一期繼續分享
參考文獻
緩存與數據庫一致性系列-04
經過前三篇的討論,我們終於的實現了“最終一致性”,那今天呢,我們再進一步,在前一個方案的基礎上實現“強一致性”
強一致性,包含兩種含義:
- 緩存和DB數據一致
- 緩存中沒有數據(或者說:不會去讀緩存中的老版本數據)
首先我們來分析一下,既然已經實現了“最終一致性”,那它和“強一致性”的區別是什么呢?沒錯,就是“時間差”,所以:
“最終一致性方案” + “時間差” = “強一致性方案”
那我們的工作呢,就是加上時間差,實現方式:我們加一個緩存,將近期被修改的數據進行標記鎖定。讀的時候,標記鎖定的數據強行走DB,沒鎖定的數據,先走緩存
寫流程:
我們把修改的數據通過Cache_0標記“正在被修改”,如果標記成功,則繼續往下走,后面的步驟與上一篇是一致的《緩存與數據庫一致性系列-03》;那如果標記失敗,則要放棄這次修改。
何為標記鎖定呢?比如你可以設定一個有效期為10S的key,Key存在即為鎖定。一般來說10S對於后面的同步操作來說基本是夠了~
如果說,還想更嚴謹一點,怕DB主從延遲太久、MQ延遲太久,或Databus監聽的從庫掛機之類的情況,我們可以考慮增加一個監控定時任務。
比如我們增加一個時間間隔2S的worker的去對比以下兩個數據:
- 時間1: 最后修改數據庫的時間
VS - 時間2: 最后由更新引起的’MQ刷新緩存對應數據的實際更新數據庫’的時間
數據1: 可由步驟1.1獲得,並存儲
數據2: 需要由binlog中解析獲得,需要透傳到MQ,這樣后面就能存儲了
這里提一下:如果多庫的情況的話,存儲這兩個key需要與庫一一對應
如果 時間1 VS 時間2 相差超過5S,那我們就自動把相應的緩存分片讀降級。
讀流程:
先讀Cache_0,看看要讀的數據是否被標記,如果被標記,則直接讀主庫;如果沒有被標記,后面的步驟與上一篇是一致的(《緩存與數據庫一致性系列-03》)。
方案分析
優點剖析
1. 容災完善
我們一步一步來分析:
寫流程容災分析
- 寫1.1 標記失敗:沒關系,放棄整個更新操作
- 寫1.3 DEL緩存失敗:沒關系,后面會覆蓋
- 寫1.5 寫MQ失敗:沒關系,Databus或Canal都會重試
- 消費MQ的:1.6 || 1.7 失敗:沒關系,重新消費即可
讀流程容災分析
- 讀2.1 讀Cache_0失敗:沒關系,直接讀主庫
- 讀2.3 異步寫MQ失敗:沒關系,緩存為空,是OK的,下次還讀庫就好了
2. 無並發問題
這個方案讓“讀庫 + 刷緩存”的操作串行化,這就不存在老數據覆蓋新數據的並發問題了
缺點剖析
1. 增加Cache_0強依賴
這個其實有點沒辦法,你要強一致性,必然要犧牲一些的。
但是呢,你這個可以吧Cache_0設計成多機器多分片,這樣的話,即使部分分片掛了,也只有小部分流量透過Cache直接打到DB上,這是完全是可接受的
2. 復雜度是比較高的
涉及到Databus、MQ、定時任務等等組件,實現起來復雜度還是有的
方案總結
OK,到此呢,我們已經實現了“數據庫和緩存強一致性”,這個系列就先這樣啦,等我學到了更好的方案,再來分享~
這里還是要提一下:一致性的要求根據自己的業務決定就好,適合的才是最好的
后記
如果對你有幫助,那就再好不過了~
參考文獻
1. Canal
2. Databus
3. Kafka
4. Redis Expire 命令