掘地三尺搞定 Redis 與 MySQL 資料一致性問題

2022-06-17 18:02:05

Redis 擁有高效能的資料讀寫功能,被我們廣泛用在快取場景,一是能提高業務系統的效能,二是為資料庫抵擋了高並行的流量請求,點我 -> 解密 Redis 為什麼這麼快的祕密

把 Redis 作為快取元件,需要防止出現以下的一些問題,否則可能會造成生產事故。

今天「碼哥」跟大家一起深入探索快取的工作機制和快取一致性應對方案

在本文正式開始之前,我覺得我們需要先取得以下兩點的共識:

  1. 快取必須要有過期時間;
  2. 保證資料庫跟快取的最終一致性即可,不必追求強一致性。

目錄如下:

1. 什麼是資料庫與快取一致性

資料一致性指的是:

  • 快取中存有資料,快取的資料值 = 資料庫中的值;
  • 快取中沒有該資料,資料庫中的值 = 最新值。

反推快取與資料庫不一致:

  • 快取的資料值 ≠ 資料庫中的值;
  • 快取或者資料庫存在舊的資料,導致執行緒讀取到舊資料。

為何會出現資料一致性問題呢?

把 Redis 作為快取的時候,當資料發生改變我們需要雙寫來保證快取與資料庫的資料一致。

資料庫跟快取,畢竟是兩套系統,如果要保證強一致性,勢必要引入 2PCPaxos 等分散式一致性協定,或者分散式鎖等等,這個在實現上是有難度的,而且一定會對效能有影響。

如果真的對資料的一致性要求這麼高,那引入快取是否真的有必要呢?

2. 快取的使用策略

在使用快取時,通常有以下幾種快取使用策略用於提升系統效能:

  • Cache-Aside Pattern(旁路快取,業務系統常用)
  • Read-Through Pattern
  • Write-Through Pattern
  • Write-Behind Pattern

2.1 Cache-Aside (旁路快取)

所謂「旁路快取」,就是讀取快取、讀取資料庫和更新快取的操作都在應用系統來完成業務系統最常用的快取策略

2.1.1 讀取資料

讀取資料邏輯如下:

  1. 當應用程式需要從資料庫讀取資料時,先檢查快取資料是否命中。
  2. 如果快取未命中,則查詢資料庫獲取資料,同時將資料寫到快取中,以便後續讀取相同資料會命中快取,最後再把資料返回給呼叫者。
  3. 如果快取命中,直接返回。

時序圖如下:

優點

  • 快取中僅包含應用程式實際請求的資料,有助於保持快取大小的成本效益。
  • 實現簡單,並且能獲得效能提升。

實現的虛擬碼如下:

String cacheKey = "公眾號:碼哥位元組";
String cacheValue = redisCache.get(cacheKey);
//快取命中
if (cacheValue != null) {
  return cacheValue;
} else {
  //快取缺失, 從資料庫獲取資料
  cacheValue = getDataFromDB();
  // 將資料寫到快取中
  redisCache.put(cacheValue)
}

缺點

由於資料僅在快取未命中後才載入到快取中,因此初次呼叫的資料請求響應時間會增加一些開銷,因為需要額外的快取填充和資料庫查詢耗時。

2.1.2 更新資料

使用 cache-aside 模式寫資料時,如下流程。

  1. 寫資料到資料庫;
  2. 將快取中的資料失效或者更新快取資料;

使用 cache-aside 時,最常見的寫入策略是直接將資料寫入資料庫,但是快取可能會與資料庫不一致。

我們應該給快取設定一個過期時間,這個是保證最終一致性的解決方案。

如果過期時間太短,應用程式會不斷地從資料庫中查詢資料。同樣,如果過期時間過長,並且更新時沒有使快取失效,快取的資料很可能是髒資料。

最常用的方式是刪除快取使快取資料失效

為啥不是更新快取呢?

效能問題

當快取的更新成本很高,需要存取多張表聯合計算,建議直接刪除快取,而不是更新快取資料來保證一致性。

安全問題

在高並行場景下,可能會造成查詢查到的資料是舊值,具體待會碼哥會分析,大家別急。

2.2 Read-Through(直讀)

當快取未命中,也是從資料庫載入資料,同時寫到快取中並返回給應用系統。

雖然 read-throughcache-aside 非常相似,在 cache-aside應用系統負責從資料庫獲取資料和填充快取。

而 Read-Through 將獲取資料儲存中的值的責任轉移到了快取提供者身上。

Read-Through 實現了關注點分離原則。程式碼只與快取互動,由快取元件來管理自身與資料庫之間的資料同步。

2.3 Write-Through 同步直寫

與 Read-Through 類似,發生寫請求時,Write-Through 將寫入責任轉移到快取系統,由快取抽象層來完成快取資料和資料庫資料的更新,時序流程圖如下:

Write-Through 的主要好處是應用系統的不需要考慮故障處理和重試邏輯,交給快取抽象層來管理實現。

優缺點

單獨直接使用該策略是沒啥意義的,因為該策略要先寫快取,再寫資料庫,對寫入操作帶來了額外延遲。

Write-ThroughRead-Through 配合使用,就能成分發揮 Read-Through 的優勢,同時還能保證資料一致性,不需要考慮如何將快取設定失效。

這個策略顛倒了 Cache-Aside 填充快取的順序,並不是在快取未命中後延遲載入到快取,而是在資料先寫快取,接著由快取元件將資料寫到資料庫

優點

  • 快取與資料庫資料總是最新的;
  • 查詢效能最佳,因為要查詢的資料有可能已經被寫到快取中了。

缺點

不經常請求的資料也會寫入快取,從而導致快取更大、成本更高。

2.4 Write-Behind

這個圖一眼看去似乎與 Write-Through 一樣,其實不是的,區別在於最後一個箭頭的箭頭:它從實心變為線。

這意味著快取系統將非同步更新資料庫資料,應用系統只與快取系統互動

應用程式不必等待資料庫更新完成,從而提高應用程式效能,因為對資料庫的更新是最慢的操作。

這種策略下,快取與資料庫的一致性不強,對一致性高的系統不建議使用。

3. 旁路快取下的一致性問題分析

業務場景用的最多的就是 Cache-Aside (旁路快取) 策略,在該策略下,使用者端對資料的讀取流程是先讀取快取,如果命中則返回;未命中,則從資料庫讀取並把資料寫到快取中,所以讀操作不會導致快取與資料庫的不一致。

重點是寫操作,資料庫和快取都需要修改,而兩者就會存在一個先後順序,可能會導致資料不再一致。針對寫,我們需要考慮兩個問題:

  • 先更新快取還是更新資料庫?
  • 當資料發生變化時,選擇修改快取(update),還是刪除快取(delete)?

將這兩個問題排列組合,會出現四種方案:

  1. 先更新快取,再更新資料庫;
  2. 先更新資料庫,再更新快取;
  3. 先刪除快取,再更新資料庫;
  4. 先更新資料庫,再刪除快取。

接下來的分析大家不必死記硬背,關鍵在於在推演的過程中大家只需要考慮以下兩個場景會不會帶來嚴重問題即可:

  • 其中第一個操作成功,第二個失敗會導致什麼問題?
  • 在高並行情況下會不會造成讀取資料不一致?

為啥不考慮第一個失敗,第二個成功的情況呀?

你猜?

既然第一個都失敗了,第二個就不用執行了,直接在第一步返回 50x 等異常資訊即可,不會出現不一致問題。

只有第一個成功,第二個失敗才讓人頭痛,想要保證他們的原子性,就涉及到分散式事務的範疇了。

3.1 先更新快取,再更新資料庫

如果先更新快取成功,寫資料庫失敗,就會導致快取是最新資料,資料庫是舊資料,那快取就是髒資料了。

之後,其他查詢立馬請求進來的時候就會獲取這個資料,而這個資料資料庫中卻不存在。

資料庫都不存在的資料,快取並返回使用者端就毫無意義了。

該方案直接 Pass

3.2 先更新資料庫,再更新快取

一切正常的情況如下:

  • 先寫資料庫,成功;
  • 再 update 快取,成功。

更新快取失敗

這時候我們來推斷下,假如這兩個操作的原子性被破壞:第一步成功,第二步失敗會導致什麼問題?

會導致資料庫是最新資料,快取是舊資料,出現一致性問題。

該圖我就不畫了,與上一個圖類似,對調下 Redis 和 MySQL 的位置即可。

高並行場景

謝霸歌經常 996,腰痠脖子疼,bug 越寫越多,想去按摩推拿放提升下程式設計技巧。

疫情影響,單子來之不易,高階會所的技師都爭先恐後想接這一單,高並行啊兄弟們。

在進店以後,前臺會將顧客資訊錄入系統,執行 set xx的服務技師 = 待定的初始值表示目前無人接待儲存到資料庫和快取中,之後再安排技師按摩服務。

如下圖所示:

  1. 98 號技師先下手為強,向系統傳送 set 謝霸歌的服務技師 = 98 的指令寫入資料庫,這時候系統的網路出現波動,卡頓了,資料還沒來得及寫到快取
  2. 接下來,520 號技師也向系統傳送 set 謝霸哥的服務技師 = 520寫到資料庫中,並且也把這個資料寫到快取中了。
  3. 這時候之前的 98 號技師的寫快取請求開始執行,順利將資料 set 謝霸歌的服務技師 = 98 寫到快取中。

最後發現,資料庫的值 = set 謝霸哥的服務技師 = 520,而快取的值= set 謝霸歌的服務技師 = 98

520 號技師在快取中的最新資料被 98 號技師的舊資料覆蓋了。

所以,在高並行的場景中,多執行緒同時寫資料再寫快取,就會出現快取是舊值,資料庫是最新值的不一致情況。

該方案直接 pass。

如果第一步就失敗,直接返回 50x 異常,並不會出現資料不一致。

3.3 先刪快取,再更新資料庫

按照「碼哥」前面說的套路,假設第一個操作成功,第二個操作失敗推斷下會發生什麼?高並行場景下又會發生什麼?

第二步寫資料庫失敗

假設現在有兩個請求:寫請求 A,讀請求 B。

寫請求 A 第一步先刪除快取成功,寫資料到資料庫失敗,就會導致該次寫資料丟失,資料庫儲存的是舊值

接著另一個讀請 B 求進來,發現快取不存在,從資料庫讀取舊資料並寫到快取中。

高並行下的問題

  1. 還是 98 號技師先下手為強,系統接收請求把快取資料刪除,當系統準備將 set 肖菜雞的服務技師 = 98寫到資料庫的時候發生卡頓,來不及寫入。
  2. 這時候,大堂經理向系統執行讀請求,查下肖菜雞有沒有技師接待,方便安排技師服務,系統發現快取中沒資料,於是乎就從資料庫讀取到舊資料 set 肖菜雞的服務技師 = 待定,並寫到快取中。
  3. 這時候,原先卡頓的 98 號技師寫資料 set 肖菜雞的服務技師 = 98到資料庫的操作完成。

這樣子會出現快取的是舊資料,在快取過期之前無法讀取到最資料。肖菜雞本就被 98 號技師接單了,但是大堂經理卻以為沒人接待。

該方案 pass,因為第一步成功,第二步失敗,會造成資料庫是舊資料,快取中沒資料繼續從資料庫讀取舊值寫入快取,造成資料不一致,還會多一次 cahche。

不論是異常情況還是高並行場景,會導致資料不一致。 miss。

3.4 先更新資料庫,再刪快取

經過前面的三個方案,全都被 pass 了,分析下最後的方案到底行不行。

按照「套路」,分別判斷異常和高並行會造成什麼問題。

該策略可以知道,在寫資料庫階段失敗的話就直返返回使用者端異常,不需要執行快取操作了。

所以第一步失敗不會出現資料不一致的情況。

刪快取失敗

重點在於第一步寫最新資料到資料庫成功,刪除快取失敗怎麼辦?

可以把這兩個操作放在一個事務中,當快取刪除失敗,那就把寫資料庫回滾。

高並行場景下不合適,容易出現大事務,造成死鎖問題。

如果不回滾,那就出現資料庫是新資料,快取還是舊資料,資料不一致了,咋辦?

所以,我們要想辦法讓快取刪除成功,不然只能等到有效期失效那可不行。

使用重試機制。

比如重試三次,三次都失敗則記錄紀錄檔到資料庫,使用分散式排程元件 xxl-job 等實現後續的處理。

在高並行的場景下,重試最好使用非同步方式,比如傳送訊息到 mq 中介軟體,實現非同步解耦。

亦或是利用 Canal 框架訂閱 MySQL binlog 紀錄檔,監聽對應的更新請求,執行刪除對應快取操作。

高並行場景

再來分析下高並行讀寫會有什麼問題……

  1. 98 號技師先下手為強,接下肖菜雞的這筆生意,資料庫執行 set 肖菜雞的服務技師 = 98;還是網路卡頓了下,沒來得及執行刪除快取操作
  2. 主管 Candy 向系統執行讀請求,查下肖菜雞有沒有技師接待,發現快取中有資料 肖菜雞的服務技師 = 待定,直接返回資訊給使用者端,主管以為沒人接待。
  3. 原先 98 號技師接單,由於卡頓沒刪除快取的操作現在執行刪除成功。

讀請求可能出現少量讀取舊資料的情況,但是很快舊資料就會被刪除,之後的請求都能獲取最新資料,問題不大。

還有一種比較極端的情況,快取自動失效的時候又遇到了高並行讀寫的情況,假設這會有兩個請求,一個執行緒 A 做查詢操作,一個執行緒 B 做更新操作,那麼會有如下情形產生:

  1. 快取的過期時間到期,快取失效。
  2. 執行緒 A 讀請求讀取快取,沒命中,則查詢資料庫得到一箇舊的值(因為 B 會寫新值,相對而言就是舊的值了),準備把資料寫到快取時傳送網路問題卡頓了
  3. 執行緒 B 執行寫操作,將新值寫資料庫。
  4. 執行緒 B 執行刪除快取。
  5. 執行緒 A 繼續,從卡頓中醒來,把查詢到的舊值寫到入快取。

碼哥,這咋玩,還是出現了不一致的情況啊。

不要慌,發生這個情況的概率微乎其微,發生上述情況的必要條件是:

  1. 步驟 (3)的寫資料庫操作要比步驟(2)讀操作耗時短速度快,才可能使得步驟(4)先於步驟(5)。
  2. 快取剛好到達過期時限。

通常 MySQL 單機的 QPS 大概 5K 左右,而 TPS 大概 1k 左右,(ps:Tomcat 的 QPS 4K 左右,TPS = 1k 左右)。

資料庫讀操作是遠快於寫操作的(正是因為如此,才做讀寫分離),所以步驟(3)要比步驟(2)更快這個情景很難出現,同時還要配合快取剛好失效。

所以,在用旁路快取策略的時候,對於寫操作推薦使用:先更新資料庫,再刪除快取。

4. 一致性解決方案有哪些?

最後,針對 Cache-Aside (旁路快取) 策略,寫操作使用先更新資料庫,再刪除快取的情況下,我們來分析下資料一致性解決方案都有哪些?

4.1 快取延時雙刪

如果採用先刪除快取,再更新資料庫如何避免出現髒資料?

採用延時雙刪策略。

  1. 先刪除快取。
  2. 寫資料庫。
  3. 休眠 500 毫秒,再刪除快取。

這樣子最多隻會出現 500 毫秒的髒資料讀取時間。關鍵是這個休眠時間怎麼確定呢?

延遲時間的目的就是確保讀請求結束,寫請求可以刪除讀請求造成的快取髒資料。

所以我們需要自行評估專案的讀資料業務邏輯的耗時,在讀耗時的基礎上加幾百毫秒作為延遲時間即可

4.2 刪除快取重試機制

快取刪除失敗怎麼辦?比如延遲雙刪的第二次刪除失敗,那豈不是無法刪除髒資料。

使用重試機制,保證刪除快取成功。

比如重試三次,三次都失敗則記錄紀錄檔到資料庫並行送警告讓人工介入。

在高並行的場景下,重試最好使用非同步方式,比如傳送訊息到 mq 中介軟體,實現非同步解耦。

第(5)步如果刪除失敗且未達到重試最大次數則將訊息重新入隊,直到刪除成功,否則就記錄到資料庫,人工介入。

該方案有個缺點,就是對業務程式碼中造成侵入,於是就有了下一個方案,啟動一個專門訂閱 資料庫 binlog 的服務讀取需要刪除的資料進行快取刪除操作。

4.3 讀取 binlog 非同步刪除

  1. 更新資料庫;
  2. 資料庫會把操作資訊記錄在 binlog 紀錄檔中;
  3. 使用 canal 訂閱 binlog 紀錄檔獲取目標資料和 key;
  4. 快取刪除系統獲取 canal 的資料,解析目標 key,嘗試刪除快取。
  5. 如果刪除失敗則將訊息傳送到訊息佇列;
  6. 快取刪除系統重新從訊息佇列獲取資料,再次執行刪除操作。

總結

快取策略的最佳實踐是 Cache Aside Pattern。分別分為讀快取最佳實踐和寫快取最佳實踐。

讀快取最佳實踐:先讀快取,命中則返回;未命中則查詢資料庫,再寫到資料庫。

寫快取最佳實踐:

  • 先寫資料庫,再操作快取;
  • 直接刪除快取,而不是修改,因為當快取的更新成本很高,需要存取多張表聯合計算,建議直接刪除快取,而不是更新,另外,刪除快取操作簡單,副作用只是增加了一次 chache miss,建議大家使用該策略。

在以上最佳實踐下,為了儘可能保證快取與資料庫的一致性,我們可以採用延遲雙刪。

防止刪除失敗,我們採用非同步重試機制保證能正確刪除,非同步機制我們可以傳送刪除訊息到 mq 訊息中介軟體,或者利用 canal 訂閱 MySQL binlog 紀錄檔監聽寫請求刪除對應快取。

那麼,如果我非要保證絕對一致性怎麼辦,先給出結論:

沒有辦法做到絕對的一致性,這是由 CAP 理論決定的,快取系統適用的場景就是非強一致性的場景,所以它屬於 CAP 中的 AP。

所以,我們得委曲求全,可以去做到 BASE 理論中說的最終一致性

其實一旦在方案中使用了快取,那往往也就意味著我們放棄了資料的強一致性,但這也意味著我們的系統在效能上能夠得到一些提升。

所謂 tradeoff 正是如此。

最後,大家可以在評論區叫我靚仔麼?不想叫我靚仔的「點贊」和「在看」也是一種鼓勵。

加「碼哥」微信:MageByte1024,進入專屬讀者技術群一起談天說地聊技術。

鳴謝

https://docs.aws.amazon.com/whitepapers/latest/database-caching-strategies-using-redis/caching-patterns.html

https://codeahoy.com/2017/08/11/caching-strategies-and-how-to-choose-the-right-one/

https://blog.cdemi.io/design-patterns-cache-aside-pattern/

https://hazelcast.com/blog/a-hitchhikers-guide-to-caching-patterns/

https://developer.aliyun.com/article/712285