深刻了解資料庫和緩存一緻性
1. 簡介
在軟體的發展過程中,我們的資料存儲在不同的媒體中,比如資料庫,Redis緩存中;每個存儲媒體有其特性,利用好這些特性可以最大程度的發揮程式的性能;而在這些存儲媒體中,如何保證各個媒體中資料的一緻性是軟體發展中一個非常重要的點;
2. 發展
在早期的系統裡面,很多的接口都是直接查詢資料庫;當請求數量比較小,查詢的資料量比較少時,資料庫還是可以抗的住這樣的查詢;
随着業務量的增多和查詢次數的增多,資料庫的響應時間會是制約軟體響應速度的瓶頸,此時緩存的使用就開始了;我的了解是緩存是一種思想,加快了請求的響應;不僅僅在開發過程中使用到,像許多的硬體,比如CPU 的一二三級緩存,電腦機械硬碟的緩存,都是為了加快響應;
緩存中存儲着常用的資料,要通路資料時,先去緩存中查詢有沒有資料,沒有的話再去資料庫中查詢資料;當遇到修改資料或者新增資料的時候,我們會去操作資料庫;然後再把資料同步到緩存中去;
這裡就引出了本文要說的問題,最後的資料庫資料同步到緩存中去,這個中間是有時間的間隔的;那在這一段時間内的請求如果是請求到緩存的資料,而這個資料已經在資料庫修改還沒有來得及更新到緩存,這就涉及到這個請求讀取到了髒資料;
3. 問題分析
軟體一直處于發展階段,不會因為上面說到髒資料的問題而去不使用緩存;業界針對上面的問題和使用場景,使用不同的緩存思路;
其實我們在這裡可以考慮,在下面寫緩存和寫DB 本來就是兩個操作,而且是并行,肯定在過程中會出現髒資料的情況,我們做的方案就是在減低這種機率,或者保持資料的最終一緻性;來降低資料一緻性帶來的影響;上面說到了并行;還有一種方案就是不并行,對資料的資料加鎖,分布式鎖,對一個資源,或者一個方法加上一個分布式鎖,把非原子性的這種操作變成一個串行的動作,這個就是對資料一緻性有較大的保證;但是帶來的結果就是效率相對較低;
除此之外,除了并行帶來的問題,就是資料存儲失敗的問題,因為緩存和DB 是兩個中間件,有可能發生一個成功,一個失敗的問題,那麼針對失敗,我們也需要做一些措施來盡可能避免失敗帶來的資料不一緻的行為;
綜上總結存在的問題:
1.請求并發帶來的資料不一緻
2.存儲DB和緩存其中一個失敗帶來的資料不一緻;
4.請求并發方案
本節針對并發請求不同方案處理的優劣勢分析;
4.1 緩存更新
- 寫請求去資料庫
- 讀請求先緩存 沒有的話 查詢資料庫,并寫入緩存,設定失效時間
場景 : 線程A,B 并發執行
都是讀,沒什麼好說,不會有資料不一緻問題;
A寫B讀 ,最差的情況是一個請求讀取到緩存髒資料或者DB 髒資料
A,B 都是寫,問題就較大了,下面就是該設計當請求都是寫請求的問題
1.先DB 後緩存
修改請求的同時,在去更新緩存;
比如是先更新DB:
- Thread A 把 DB 中 X -> 1
- Thread B 把 DB 中 X -> 2
- Thread B 把 緩存 中 X -> 2
- Thread A 把 緩存 中 X -> 1
正常情況 緩存最後是 X = 2 但是确實 X= 1 ,緩存永久(如果沒有過期時間)髒資料
2.先緩存 後DB
在如果先修改緩存:
- Thread A 把 緩存 中 X -> 1
- Thread B 把 緩存 中 X -> 2
- Thread B 把 DB 中 X -> 2
- Thread A 把 DB 中 X -> 1
正常情況 DB 中X=2 但是卻 DB 中 X=1 ,DB永久髒資料
4.2 删除緩存
寫資料的請求會删除緩存;讀取的時候和上面一樣判斷緩存中資料是否存在;
同樣這樣的方案也是有兩種;
- 先删除緩存,後寫DB
- 先寫DB , 後删除緩存
場景:線程A,B 并發執行
都是讀,沒什麼好說,不會有資料不一緻問題;
A寫B讀 ,最差的情況是一個請求讀取到緩存髒資料或者DB 髒資料
A,B 都是寫,都是修改DB,且删除緩存,不會有資料問題
綜合:分析到這裡可以看到對于 緩存更新的優勢了,都是寫請求不會出問題,且一個寫一個讀的情況出現問題的機率也相對較低,看下面分析
1.先删除緩存,後寫DB
原本資料 X = 1
- Thread A 開始執行 X=2
- Thread A 删除緩存
- Thread B 擷取X ,此時資料已經被删除,然後擷取到DB 尚未更新更新的資料 X=1
- Thread B X=1 放到緩存中去
- Thread A 執行DB X=2 ,流程結束
正常情況 緩存X=2 但是卻最後
Thread B X=1 放到緩存中去
緩存永久髒資料
2.先寫DB , 後删除緩存
- 1.Thread A 擷取X ,緩存中不存在,去到DB 中 擷取X =1
- 2.Thread B 寫DB X=2 ,
- 3.Thread B 删除緩存 ,
- 4.Thread A 在第一步擷取的 X =1 更新到緩存
正常情況 緩存X=2 但是卻最後
Thread A 在第一步擷取的 X =1 更新到緩存
緩存永久髒資料
**不同點就是:**第二種方式 2,3 ;如果 Thread A ,Thread B 在步驟1 ,2 是幾乎同時過來的話,1 + 4 的時間是要比 2 + 3 的時間短的,因為正常情況下,對資料的寫會比讀更加的消耗時間; 是以 2 + 3 正常的時間 是 在 4 後面執行,也就是最後資料正常情況下是一緻的;總結就是出現這種情況的同時滿足條件
- 緩存 X 不存在
- 讀寫并發
- 2 + 3 時間 >1+4 時間 ; 是以 3 一般後執行
那麼這種方式就是相對前面三種中較好(矮子裡面找高個了屬于)的一種方式來操作DB 和緩存;
5.單DB/緩存更新失敗
5.1 擴充緩存删除
在對删除緩存的思路分析中,可以分析到,緩存删除,緩存更新 其實是緩存删除比較好的;
緩存删除删除中先寫DB , 後删除緩存 是比較好的;
======>>>> 那麼下面就針對這個方案 在詳細看看
- 1.Thread A 擷取X ,緩存中不存在,去到DB 中 擷取X =1 ; 執行成功
- 2.Thread B 寫DB X=2 ,;執行成功
- 3.Thread B 删除緩存 ,
- 4.Thread A 在第一步擷取的 X =1 更新到緩存
A,B 線程第一步就是失敗的話,後續就可以不用執行了,就變成一個線程執行,沒有一緻性的問題,排除第一步就失敗;
======>>>> 那麼現在讨論第二步失敗:
- 3.Thread B 删除緩存 ,如果緩存删除失敗,資料庫成功 ---->>>資料不一緻,不能以為寫緩存失敗而復原,業務上不合理;
- 4.Thread A 在第一步擷取的 X =1 更新到緩存;---->>>失敗沒有問題 ,最多緩存沒有資料,資料一緻性還在
且我們讨論,一般情況下 3. 是在 4 後面執行的 (上一節說過);且如果想要資料更趨向于一緻,3 要保證在4 後面才資料一緻更能有保障(不是一定資料一緻);
======>>>> 那麼就保證 Thread B 第二步删除成功執行
正常删除什麼問題沒有,萬一删除就失敗呢
======>>>> 那麼久删除失敗
如何防止删除失敗,或者說是删除失敗後如何補償,實作方案就是重試
重試的問題是
- 一般情況下是在執行一次還是會異常(大機率)
- 或者說重試多少次才合理??
- 重試是阻塞的,影響程式運作效率
如何解決這些問題; 異步重試删除,那麼如何異步,我們在開發中異步操作大機率會想到的就是 消息隊列;不錯 我們把重試的消息直接放到消息隊列;
消息隊列優點:
- 消息隊列有持久化機制,資料不會丢失
- 消費成功才删除 =======>>>> 消息隊列保證異步
- 消費失敗繼續添加到隊列 =======>>>> 這一點 來保證重試
5.2 異步重試方案(一)
上面是推導除了異步重試方案在資料一緻性裡面比較好的方案了,整體架構就是
5.3 異步重試方案(二)
上面說的方案,在寫請求的時候,在去把對應的修改寫到消息隊列裡面去;
現在有更加簡單的方案,就是訂閱資料庫的
binlog
(資料庫修改的邏輯日志),這個訂閱的服務現在 有成熟的方案,比如阿裡的 Canal;
次方案架構為:
6.主從DB&緩存 資料不一緻
上一章節說的是單機環境的DB 和緩存,那如果DB 是主從結構,一般我們的程式還是讀寫分離的,這個時候該如何設計;
場景: 原來的循序調換一下
- 2.Thread B 寫DB X=2 ,
- 3.Thread B 删除緩存 ,
- 1.Thread A 擷取X ,緩存中不存在,去到DB從庫 中 擷取X 此時資料主從還未同步 擷取X =1 ;
- 4.Thread A X =1 更新到緩存
問題解析:
本來在并發情況下,讀操作是比較快的,更新的髒資料緩存被寫操作覆寫删除掉;但是由于從庫資料同步相對較慢,就又擷取到髒資料并加載到緩存;
解決方案:緩存延遲雙删
Thread B 寫資料庫後删除緩存,完成之後,在稍等一會,再次删除緩存;(當然中間可能會短暫的讀取到髒資料)
=======>>>> 那麼延遲多久?
- 延遲時間大于主從複制時間
- 時間要在 其他線程 讀取從庫+ 寫入緩存的時間
大概這個時間是在 1~ 3秒左右;
7.總結
從上面分析的情況可以看到不同場景下的方案的優劣性;采用這樣的方案要是基于自己業務系統對髒資料的容忍性,來換取程式執行的效率;
方案的好壞,需要實際的使用場景去驗證;,5,6中的方案可能會在短暫的時間内讀取到髒資料;但是會在短暫的時間後會達到資料的最終一緻性;
我們引入緩存,就必然引入資料一緻性的問題,我們所有的方案就是降低一緻性帶來的系統問題;