如何解決資料庫與快取的一致性問題

listen_發表於2021-11-17
快取是高併發系統架構中的利器,通過利用快取,系統可以輕而易舉的扛住成千上萬的併發訪問請求,但在享受快取帶來的便利的同時,如何保證資料庫與快取的資料一致性,一直是一個難題,在本篇文章中分享如何在系統架構中保障快取一致性問題。

概述

在介紹如何解決資料庫與快取的一致性問題前,先來了解一下兩個問題——什麼是資料庫和快取的一致性問題(What)和為什麼會出現資料庫和快取的資料一致性問題(Why)。

什麼是資料庫和快取的資料一致性問題

首先來了解下我們一直在說的資料一致性問題究竟是什麼。CAP理論相信大家都已經耳熟能詳了,只要是做分散式系統開發的應該基本都聽說過,C表示一致性(Consistency)、A表示可用性(Availability)、P表示分割槽容錯性(Partition tolerance),CAP理論闡述了這三個元素最多隻能同時實現兩個,不可能三者兼顧。這裡對一致性的定義是——在分散式系統中的所有資料備份,在同一時刻是否同樣的值。
因此,我們可以把資料庫和快取中的資料理解為兩份資料副本,資料庫與快取的資料一致性問題等同於如何保證資料庫與快取中的兩份資料副本的資料一致性問題。

為什麼會出現資料庫和快取的資料一致性問題

在業務開發中我們一般通過資料庫事務的四大特性(ACID)來保證資料的一致性。到了分散式環境中,由於沒有類似事務的保障,因此容易出現部分失敗的情況,比如資料庫更新成功,快取更新失敗,或者快取更新成功,資料庫更新失敗的情況等等,總結一下會導致資料庫和快取的資料不一致性的原因。

網路

在分散式系統中預設網路是不穩定的。因此在CAP理論下,一般認為網路原因導致的失敗是無法避免的,系統的設計一般會選擇CP或者AP,就是這個原因。運算元據庫和快取都涉及到網路I/O,很容易因為網路不穩定導致部分請求的失敗,從而導致資料不一致。

併發

在分散式環境下,如果不顯式同步的話,請求是會被多個伺服器結點併發處理的。看下面這個例子,假設有兩個併發請求同時更新資料庫中的欄位A,程式1先更新欄位A為1並更新快取為1,程式2更新欄位A為2並更新快取為2,由於在併發的情況下無法保證時序,就會出現下面的這種情況,最終的結果就是資料庫中欄位A的值為2,快取中的值為1,資料庫與快取的資料不一致。

程式1程式2
時間點T1更新資料庫欄位 A = 1
時間點T2 更新資料庫欄位 A = 2
時間點T3 更新快取KEY A = 2
時間點T4更新快取KEY A = 1

讀寫快取的模式

在工程實踐中,讀寫快取有幾種通用的模式。

Cache Aside

Cache Aside應該是最常用的模式了,在許多的業務程式碼中都是通過這種模式來更新資料庫和快取的。它的主要邏輯如下圖所示。

cacheaside活動圖-Cash_Aside.png

先判斷請求的型別,針對讀請求和寫請求分別做不同的處理:

寫請求:先更新資料庫,成功後再失效快取。
讀請求:先查詢快取中是否命中資料,如果命中則直接返回資料,未命中則查詢資料庫,成功後更新快取,最後返回資料。

這種模式實現起來比較簡單,在邏輯上看起來也沒什麼問題,讀請求邏輯的實現在Java中為了避免程式碼重複,一般會通過AOP的方式。
Cache Aside模式在併發環境下是會存在資料一致性問題的,比如下面表格描述的這種讀寫併發的場景。

讀請求寫請求
時間點T1查詢快取的欄位A的值未命中
時間點T2查詢資料庫得到欄位A=1
時間點T3 更新資料庫欄位A = 2
時間點T4 失效快取
時間點T5設定快取A的值為1

讀請求查詢欄位A的快取,但是未能命中,然後查詢資料庫得到欄位A的值為1,同時寫請求將欄位A的值更新為2,由於是併發的請求,寫請求中失效快取的操作先於讀請求中設定快取的操作,導致在讀請求中設定了欄位A的快取值為1且未能被正確失效,造成了快取髒資料,如果這裡還不設定快取過期時間的話,那麼資料就一直是錯的。

Read Through

Read Through模式和Cache Aside模式非常類似,區別在於在Cache Aside模式中,如果讀請求中未能命中快取,需要我們自己實現查詢資料庫再更新快取的邏輯,而在Read Through模式中,則不需要關心這些邏輯,我們只跟快取服務打交道,由快取服務來實現快取的載入,舉個Java中常用的Guava Cache的例子來說明,看下面這段程式碼。

LoadingCache<Key, Graph> graphs = CacheBuilder.newBuilder()
       .maximumSize(1000)
       .build(
           new CacheLoader<Key, Graph>() {
             public Graph load(Key key) throws AnyException {
               return createExpensiveGraph(key);
             }
           });
...
try {
  return graphs.get(key);
} catch (ExecutionException e) {
  throw new OtherException(e.getCause());
}

在這段程式碼中我們使用了Guava中的CacheLoader來替我們載入快取。在讀請求中,當呼叫get方法時,如果發生Cache Miss,那麼由CacheLoader來負責載入快取,而我們的程式碼只跟graphs這個物件打交道,不用關心底層載入快取的細節,這就是Read Through模式。
Read Through模式與Cache Aside模式在邏輯上沒有本質區別,只不過Read Through模式在實現上程式碼會更簡潔,因此同樣的,Read Through模式也會出現Cache Aside模式中的併發導資料庫和快取資料不一致的問題。

Write Through

Write Through模式的邏輯與Read Through模式有點類似,在Write Through模式下所有的寫操作都要經過快取,然後根據寫的時候是否命中快取再執行後續邏輯。

Write-through: write is done synchronously both to the cache and to the backing store.

在Wikipedia上對Write Through模式的定義強調了該模式下在寫請求中,會同步寫快取和資料庫,只有快取和資料庫都寫成功了才算成功。主要邏輯如下圖所示。

writethrough活動圖-Write_Through.png

Write Through模式在發生Cache Miss的時候,只會在讀請求中更新快取。寫請求在發生Cache Miss的時候不會更新快取,而是直接寫入資料庫,如果命中快取則先更新快取,由快取自己再將資料寫回到資料庫中。怎麼理解由快取自己將資料寫回到資料庫中呢,這裡舉個Ehcache使用的例子。在Ehcache中,CacheLoaderWriter介面實現了Write Through模式,在這介面中定義了一系列的Cache生命週期的鉤子函式,其中有兩個方法如下:

public interface CacheLoaderWriter<K, V> {

    void write(K var1, V var2) throws Exception;

    void writeAll(Iterable<? extends Entry<? extends K, ? extends V>> var1) throws BulkCacheWritingException, Exception;
}

只需要實現這兩個Write相關的方法,即可以實現在更新快取時,將資料寫入底層的資料庫,也就是說在程式碼中只需要跟CacheLoaderWriter互動即可,不需要同時實現更新快取和寫入資料庫的邏輯。
回過頭來再看下Write Through模式的邏輯,發現在讀請求的處理上跟Read Through模式基本是一樣的,所以Read Through模式和Write Through模式可以配合使用。
那麼Write Through模式有沒有Read Through模式在併發的場景下的一致性問題呢?顯然是有的,而且產生不一致問題的原因跟Read Through模式也是類似的,都是由於更新資料庫和更新快取的時序在併發場景下無法保證導致的。

Write Back

Write-back (also called write-behind): initially, writing is done only to the cache. The write to the backing store is postponed until the modified content is about to be replaced by another cache block.

還是先來看下Wikipedia上對Write Back模式的定義——該模式在寫請求中只會寫入快取,之後只有在快取中的資料要被替換出記憶體的時候,才會寫入底層的資料庫。Write Back模式與Write Through模式的主要區別有兩點:

  1. Write Through模式是同步寫入快取和資料庫,而Write Back模式則是非同步的,在寫請求中只寫入快取,後續會非同步地將資料從快取再寫入底層資料庫,而且是批量的。
  2. Write Back模式在寫請求中發生Cache Miss時,會將資料重新寫入到快取中,這點是與Write Through模式也是不同的。因此,Write Back模式的Read Cache Miss和Write Cache Miss的處理是類似的。

Write Back模式的實現邏輯比較複雜,主要原因是該模式需要track哪些是“髒”資料,在必要的時候寫入底層儲存,且如果有多次更新的話,還需要做批量的合併寫入,Write Back模式實現邏輯的圖這裡就不貼了,如果有興趣的話,可以參考Wikipedia上的圖
既然是非同步的,那Write Back模式的好處就是高效能,不足之處在於無法保證快取和資料庫的資料一致性。

思考

通過觀察以上三種模式的實現,可以看出一些在實現上的差異點——究竟是刪除快取還是更新快取,先操作快取還是先更新資料庫。下面的表格中列舉了所有可能出現的情況,其中1表示快取與資料庫中的資料一致,0則表示不一致。

快取操作失敗資料庫操作失敗
先更新快取,再更新資料庫10
先更新資料庫,再更新快取01
先刪除快取,再更新資料庫11
先更新資料庫,再刪除快取01

以上情況都是在不考慮將快取的操作放到資料庫事務中(一般不建議將非資料庫操作放到事務中,比如RPC呼叫、Redis操作等等,原因是這些外部操作往往會依賴網路等不可靠因素,一旦出現問題,容易導致資料庫事務無法提交或者造成“長事務”問題)。
可以看到,只有“先刪除快取,再更新資料庫”這種模式在部分失敗的情況下能保證資料的一致性,因此我們可以得出結論1——“先刪除快取,再更新資料庫”是最優的方案。但是,先刪除後更新的模式,容易造成快取擊穿的問題,關於這個問題會在後面細說。

除此之外,我們還可以觀察到,Cache Aside/Read Through/Write Through三種模式在併發場景下都存在快取與資料庫資料不一致的問題,且原因都是在併發場景下,無法保證更新資料庫與更新快取的時序,導致更新資料庫先於寫入快取發生,而寫入的快取是舊資料,從而發生資料不一致問題。基於此,我們可以得出結論2——只要某種模式能解決這個問題,那麼這種模式就可以在併發環境下保證快取與資料庫的資料一致性。

觀察以上三種模式之後,發現的最後一點是一旦發生快取和資料庫的資料不一致問題之後,如果資料不再更新,那麼快取中的資料一直是錯的,缺乏一種補救的機制,因此可以得出結論3——需要有某種快取自動重新整理的機制。最簡單的方式是給快取設定上過期時間,這是一種兜底手段,防止萬一發生資料不一致的時候資料一直是錯誤的。
基於以上三個結論,再來介紹下面兩種模式。

延時雙刪

延遲雙刪模式可以認為是Cache Aside模式的優化版本,主要實現邏輯如下圖所示。

延遲雙刪.drawio.png

在寫請求中,首先按照上面我們得出的最佳實踐結論,先刪除快取,再更新資料庫,然後傳送MQ訊息,這裡的MQ訊息可以由服務自己來傳送,也可以通過一些中介軟體來監聽DB的binlog變化的訊息來實現,監聽訊息後需要延遲一段時間,延遲的實現方式可以使用訊息佇列的延遲訊息功能,或者在消費端接收到訊息後自行sleep一段時間,然後再次刪除快取。虛擬碼如下。

// 刪除快取
redis.delKey(key);
// 更新資料庫
db.update(x);
// 傳送延遲訊息,延遲1s
mq.sendDelayMessage(msg, 1s);

...
// 消費延遲訊息
mq.consumeMessage(msg);
// 再次刪除快取
redis.delKey(key);

讀請求的實現邏輯同Cache Aside模式,當發現未命中快取時,將在讀請求中重新載入快取,同時要設定給快取設定上合理的過期時間。
相比Cache Aside模式,這種模式一定程度上降低了出現快取和資料庫資料不一致問題的可能性,但也僅僅是降低,問題依然還是存在的,只不過出現的條件更嚴苛了,看下面這種情況。

讀請求寫請求
時間點T1查詢快取的欄位A的值未命中
時間點T2查詢資料庫得到欄位A=1
時間點T3 更新資料庫欄位A = 2
時間點T4 失效快取
時間點T5 傳送延遲訊息
時間點T6 消費延遲訊息並失效快取
時間點T7設定快取A的值為1

由於訊息的消費和讀請求是併發發生的,因此消費延遲訊息後失效快取和讀請求中設定快取的時序依然是無法保證的,還是會出現資料不一致的可能性,只不過概率變得更低了。

同步失效並更新

結合以上幾種模式的優勢和不足,在實際的專案實踐中本人採用了另外一種模式,我把它命名為“同步失效並更新模式”,主要實現邏輯如下圖。

同步失效並更新-.png

這種模式的思路是在讀請求中只讀快取,把操作快取和資料庫都放在寫請求中,並且這些操作都是同步的,同時為了防止寫請求的併發,在寫操作上需要增加分散式鎖,獲取到鎖之後才能進行後續的操作,這樣一來,就消除了所有可能由於併發而導致出現資料不一致問題的可能性。

這裡的分散式鎖可以根據快取的維度來確定,不需要使用全域性鎖,比如快取是訂單緯度的,那麼鎖也可以是訂單緯度的,如果快取是使用者緯度的,那麼分散式鎖就可以是使用者緯度的。這裡以訂單為例,寫請求的實現虛擬碼如下:

// 獲取訂單緯度的分散式鎖
lock(orderID) {
      // 先刪除快取
    redis.delKey(key);
      // 再更新資料庫
      db.update(x);
      // 最後重新更新快取
    redis.setEx(key, 60s);
}

這種模式的好處是基本能保證快取和資料庫的資料一致性,在效能方面,讀請求基本是效能無損的,寫請求由於需要同步寫資料庫和快取,會有一定的影響,但是由於網際網路大部分業務都是讀多寫少,相對來說影響也不是很大。當然,這種模式同樣也有不足,主要有以下兩點:

寫請求強依賴分散式鎖

在這種模式下寫請求是強依賴分散式鎖的,如果第一步獲取分散式鎖失敗,那麼整個請求都失敗了。在正常的業務流程中一般是通過資料庫的事務來保證一致性的,在某些關鍵業務場景,除了事務,還會使用分散式鎖來保證一致性,所以這麼來看分散式鎖本來就有許多的業務場景下會使用,並不能完全算是額外的依賴。而且大廠基本都有成熟的分散式鎖服務或者元件,即使沒有,使用Redis或ZK簡單實現一個分散式鎖的成本也並不高,穩定性基本也有一定的保障。在我個人使用這種模式的專案實踐中,基本沒出現過因為分散式鎖而導致的問題。

寫請求更新快取失敗會導致快取擊穿

為了追求快取和資料庫的資料一致性,因此同步失效並更新模式將快取和資料庫的寫操作都放在的寫請求中,這樣避免了在併發環境下,由於多處操作快取和資料庫而導致的資料不一致問題,讀請求中對快取是隻讀的,即使發生快取Miss也不會重新載入快取。

但也正是因為這種設計,萬一發生在寫請求中更新快取失敗的情況,那麼如果沒有後續的寫請求,快取中的資料就不會再被載入,後續所有的讀請求會直接到DB,造成快取擊穿問題。基於網際網路業務的特點是讀多寫少,因此這種快取擊穿的可能性還是比較大的。

解決這個問題的方案是可以使用補償的方式,比如定時任務補償或者MQ訊息補償,可以是增量的補償,也可以是全量的補償,個人的經驗是建議最好要加上補償。

其它一些需要關注的問題

有了合理的快取讀寫模式後,再來看看為了保證快取和資料庫的資料一致性需要關注的一些其他問題。

避免其他問題導致快取伺服器崩潰,從而導致資料不一致問題

  1. 快取穿透、快取擊穿和快取雪崩

前面也提到了在“先刪除快取,再更新資料庫”的模式下會有快取擊穿問題,除了快取擊穿,相關的問題還有快取穿透和快取雪崩問題,這些問題都會導致快取伺服器奔潰,從而導致資料不一致,先來看看這些問題的定義和一些常規的解決方案。

問題描述解決方案
快取穿透查詢一個不存在的key,不可能命中快取,導致每次請求都到DB中,從而導致資料庫奔潰1. 快取空物件 2. 布隆過濾器
快取擊穿對於設定了過期時間的快取key,在某個時間點過期的時候,恰好有大量對這個key的併發請求,可能導致瞬間大量併發的請求把資料庫壓垮1. 使用互斥鎖(分散式鎖):每次只有1個請求能搶到鎖並重新載入快取 2. 永遠不過期:物理不過期,但邏輯上會過期(比如後臺任務定時重新整理等等)
快取雪崩設定快取的過期時間時採用了相同的值,快取在某一時刻大量過期,導致大量請求訪問資料庫。快取雪崩和快取擊穿的區別是:快取擊穿時針對單個key,快取雪崩是針對多個key1. 分散設定快取過期時間,比如增加隨機數等等。2. 使用互斥鎖(分散式鎖):每次只有1個請求能搶到鎖並重新載入快取

在實際的專案實踐中,一般不會追求100%的快取命中率,其次在使用“先刪除快取,再更新資料庫”的模式時,正常情況下兩步操作相隔時間是很短的,不會有大量請求擊穿到資料庫中,因此有一些快取擊穿也是可接受的。但如果是在秒殺等併發量特別高的系統,完全沒辦法接受快取擊穿的時候,那可以使用搶佔互斥鎖更新或者把快取操作放到資料庫事務中,這樣就可以使用“先更新資料庫,再更新快取”的模式,避免快取穿透問題。

  1. 大key/熱key

大key和熱key問題基本都是業務設計上的問題,需要從業務設計的角度來解決。大key更多影響的是效能,解決大key的思路是將大key拆分為多個key,這樣能有效降低一次網路傳輸資料量的大小,進而提升效能。
熱key容易造成快取伺服器單點負載過高,從而導致伺服器崩潰。熱key的解決方式是增加副本數量,或者將一個熱key拆分 多個key的方式來解決。

總結

需要說明的是,上面介紹的這些模式都不是完全的資料強一致性的,只能說是儘量做到業務意義上的資料最終一致性,如果一定要強一致性保證,那麼需要使用2PC、3PC、Paxos、Raft這一類分散式一致性演算法。
最後來對上面介紹的這幾種模式來個總結。

  • 併發量不大或者能接受一定時間內快取與資料庫資料不一致的系統:Cache Aside/Read Through/Write Through模式。
  • 有一定的併發量或者對快取與資料庫資料一致性要求中等的系統:延遲雙刪模式。
  • 併發量高或者對快取與資料庫資料一致性要求較高的系統:同步失效並更新模式。
  • 對資料庫資料一致性要求強一致性的系統:2PC、3PC、Paxos、Raft等分散式一致性演算法。

綜上可以看到,還是那句話,架構沒有銀彈,在做架構設計的時候需要做各種取捨,因此在選擇和設計快取讀寫模式時,需要結合具體的業務場景,比如併發量大還是小、資料一致性級別要求高還是低等等,靈活運用這些模式,必要時可以做一些變通,確定大的方向之後,再來補充細節,才能有一個好的架構設計。

參考

相關文章