Redis 擁有高效能的資料讀寫功能,被我們廣泛用在快取場景,一是能提高業務系統的效能,二是為資料庫抵擋了高並行的流量請求,點我 -> 解密 Redis 為什麼這麼快的祕密。
把 Redis 作為快取元件,需要防止出現以下的一些問題,否則可能會造成生產事故。
今天「碼哥」跟大家一起深入探索快取的工作機制和快取一致性應對方案。
在本文正式開始之前,我覺得我們需要先取得以下兩點的共識:
目錄如下:
資料一致性指的是:
反推快取與資料庫不一致:
為何會出現資料一致性問題呢?
把 Redis 作為快取的時候,當資料發生改變我們需要雙寫來保證快取與資料庫的資料一致。
資料庫跟快取,畢竟是兩套系統,如果要保證強一致性,勢必要引入 2PC
或 Paxos
等分散式一致性協定,或者分散式鎖等等,這個在實現上是有難度的,而且一定會對效能有影響。
如果真的對資料的一致性要求這麼高,那引入快取是否真的有必要呢?
在使用快取時,通常有以下幾種快取使用策略用於提升系統效能:
Cache-Aside Pattern
(旁路快取,業務系統常用)Read-Through Pattern
Write-Through Pattern
Write-Behind Pattern
所謂「旁路快取」,就是讀取快取、讀取資料庫和更新快取的操作都在應用系統來完成,業務系統最常用的快取策略。
讀取資料邏輯如下:
時序圖如下:
實現的虛擬碼如下:
String cacheKey = "公眾號:碼哥位元組";
String cacheValue = redisCache.get(cacheKey);
//快取命中
if (cacheValue != null) {
return cacheValue;
} else {
//快取缺失, 從資料庫獲取資料
cacheValue = getDataFromDB();
// 將資料寫到快取中
redisCache.put(cacheValue)
}
由於資料僅在快取未命中後才載入到快取中,因此初次呼叫的資料請求響應時間會增加一些開銷,因為需要額外的快取填充和資料庫查詢耗時。
使用 cache-aside
模式寫資料時,如下流程。
使用 cache-aside
時,最常見的寫入策略是直接將資料寫入資料庫,但是快取可能會與資料庫不一致。
我們應該給快取設定一個過期時間,這個是保證最終一致性的解決方案。
如果過期時間太短,應用程式會不斷地從資料庫中查詢資料。同樣,如果過期時間過長,並且更新時沒有使快取失效,快取的資料很可能是髒資料。
最常用的方式是刪除快取使快取資料失效。
為啥不是更新快取呢?
效能問題
當快取的更新成本很高,需要存取多張表聯合計算,建議直接刪除快取,而不是更新快取資料來保證一致性。
安全問題
在高並行場景下,可能會造成查詢查到的資料是舊值,具體待會碼哥會分析,大家別急。
當快取未命中,也是從資料庫載入資料,同時寫到快取中並返回給應用系統。
雖然 read-through
和 cache-aside
非常相似,在 cache-aside
中應用系統負責從資料庫獲取資料和填充快取。
而 Read-Through 將獲取資料儲存中的值的責任轉移到了快取提供者身上。
Read-Through 實現了關注點分離原則。程式碼只與快取互動,由快取元件來管理自身與資料庫之間的資料同步。
與 Read-Through 類似,發生寫請求時,Write-Through 將寫入責任轉移到快取系統,由快取抽象層來完成快取資料和資料庫資料的更新,時序流程圖如下:
Write-Through
的主要好處是應用系統的不需要考慮故障處理和重試邏輯,交給快取抽象層來管理實現。
單獨直接使用該策略是沒啥意義的,因為該策略要先寫快取,再寫資料庫,對寫入操作帶來了額外延遲。
當Write-Through
與 Read-Through
配合使用,就能成分發揮 Read-Through
的優勢,同時還能保證資料一致性,不需要考慮如何將快取設定失效。
這個策略顛倒了 Cache-Aside
填充快取的順序,並不是在快取未命中後延遲載入到快取,而是在資料先寫快取,接著由快取元件將資料寫到資料庫。
不經常請求的資料也會寫入快取,從而導致快取更大、成本更高。
這個圖一眼看去似乎與 Write-Through
一樣,其實不是的,區別在於最後一個箭頭的箭頭:它從實心變為線。
這意味著快取系統將非同步更新資料庫資料,應用系統只與快取系統互動。
應用程式不必等待資料庫更新完成,從而提高應用程式效能,因為對資料庫的更新是最慢的操作。
這種策略下,快取與資料庫的一致性不強,對一致性高的系統不建議使用。
業務場景用的最多的就是 Cache-Aside
(旁路快取) 策略,在該策略下,使用者端對資料的讀取流程是先讀取快取,如果命中則返回;未命中,則從資料庫讀取並把資料寫到快取中,所以讀操作不會導致快取與資料庫的不一致。
重點是寫操作,資料庫和快取都需要修改,而兩者就會存在一個先後順序,可能會導致資料不再一致。針對寫,我們需要考慮兩個問題:
將這兩個問題排列組合,會出現四種方案:
接下來的分析大家不必死記硬背,關鍵在於在推演的過程中大家只需要考慮以下兩個場景會不會帶來嚴重問題即可:
為啥不考慮第一個失敗,第二個成功的情況呀?
你猜?
既然第一個都失敗了,第二個就不用執行了,直接在第一步返回 50x 等異常資訊即可,不會出現不一致問題。
只有第一個成功,第二個失敗才讓人頭痛,想要保證他們的原子性,就涉及到分散式事務的範疇了。
如果先更新快取成功,寫資料庫失敗,就會導致快取是最新資料,資料庫是舊資料,那快取就是髒資料了。
之後,其他查詢立馬請求進來的時候就會獲取這個資料,而這個資料資料庫中卻不存在。
資料庫都不存在的資料,快取並返回使用者端就毫無意義了。
該方案直接 Pass
。
一切正常的情況如下:
這時候我們來推斷下,假如這兩個操作的原子性被破壞:第一步成功,第二步失敗會導致什麼問題?
會導致資料庫是最新資料,快取是舊資料,出現一致性問題。
該圖我就不畫了,與上一個圖類似,對調下 Redis 和 MySQL 的位置即可。
謝霸歌經常 996,腰痠脖子疼,bug 越寫越多,想去按摩推拿放提升下程式設計技巧。
疫情影響,單子來之不易,高階會所的技師都爭先恐後想接這一單,高並行啊兄弟們。
在進店以後,前臺會將顧客資訊錄入系統,執行 set xx的服務技師 = 待定
的初始值表示目前無人接待儲存到資料庫和快取中,之後再安排技師按摩服務。
如下圖所示:
set 謝霸歌的服務技師 = 98
的指令寫入資料庫,這時候系統的網路出現波動,卡頓了,資料還沒來得及寫到快取。set 謝霸哥的服務技師 = 520
寫到資料庫中,並且也把這個資料寫到快取中了。set 謝霸歌的服務技師 = 98
寫到快取中。最後發現,資料庫的值 = set 謝霸哥的服務技師 = 520
,而快取的值= set 謝霸歌的服務技師 = 98
。
520 號技師在快取中的最新資料被 98 號技師的舊資料覆蓋了。
所以,在高並行的場景中,多執行緒同時寫資料再寫快取,就會出現快取是舊值,資料庫是最新值的不一致情況。
該方案直接 pass。
如果第一步就失敗,直接返回 50x 異常,並不會出現資料不一致。
按照「碼哥」前面說的套路,假設第一個操作成功,第二個操作失敗推斷下會發生什麼?高並行場景下又會發生什麼?
假設現在有兩個請求:寫請求 A,讀請求 B。
寫請求 A 第一步先刪除快取成功,寫資料到資料庫失敗,就會導致該次寫資料丟失,資料庫儲存的是舊值。
接著另一個讀請 B 求進來,發現快取不存在,從資料庫讀取舊資料並寫到快取中。
set 肖菜雞的服務技師 = 98
寫到資料庫的時候發生卡頓,來不及寫入。set 肖菜雞的服務技師 = 待定
,並寫到快取中。set 肖菜雞的服務技師 = 98
到資料庫的操作完成。這樣子會出現快取的是舊資料,在快取過期之前無法讀取到最資料。肖菜雞本就被 98 號技師接單了,但是大堂經理卻以為沒人接待。
該方案 pass,因為第一步成功,第二步失敗,會造成資料庫是舊資料,快取中沒資料繼續從資料庫讀取舊值寫入快取,造成資料不一致,還會多一次 cahche。
不論是異常情況還是高並行場景,會導致資料不一致。 miss。
經過前面的三個方案,全都被 pass 了,分析下最後的方案到底行不行。
按照「套路」,分別判斷異常和高並行會造成什麼問題。
該策略可以知道,在寫資料庫階段失敗的話就直返返回使用者端異常,不需要執行快取操作了。
所以第一步失敗不會出現資料不一致的情況。
重點在於第一步寫最新資料到資料庫成功,刪除快取失敗怎麼辦?
可以把這兩個操作放在一個事務中,當快取刪除失敗,那就把寫資料庫回滾。
高並行場景下不合適,容易出現大事務,造成死鎖問題。
如果不回滾,那就出現資料庫是新資料,快取還是舊資料,資料不一致了,咋辦?
所以,我們要想辦法讓快取刪除成功,不然只能等到有效期失效那可不行。
使用重試機制。
比如重試三次,三次都失敗則記錄紀錄檔到資料庫,使用分散式排程元件 xxl-job 等實現後續的處理。
在高並行的場景下,重試最好使用非同步方式,比如傳送訊息到 mq 中介軟體,實現非同步解耦。
亦或是利用 Canal 框架訂閱 MySQL binlog 紀錄檔,監聽對應的更新請求,執行刪除對應快取操作。
再來分析下高並行讀寫會有什麼問題……
set 肖菜雞的服務技師 = 98
;還是網路卡頓了下,沒來得及執行刪除快取操作。肖菜雞的服務技師 = 待定
,直接返回資訊給使用者端,主管以為沒人接待。讀請求可能出現少量讀取舊資料的情況,但是很快舊資料就會被刪除,之後的請求都能獲取最新資料,問題不大。
還有一種比較極端的情況,快取自動失效的時候又遇到了高並行讀寫的情況,假設這會有兩個請求,一個執行緒 A 做查詢操作,一個執行緒 B 做更新操作,那麼會有如下情形產生:
碼哥,這咋玩,還是出現了不一致的情況啊。
不要慌,發生這個情況的概率微乎其微,發生上述情況的必要條件是:
通常 MySQL 單機的 QPS 大概 5K 左右,而 TPS 大概 1k 左右,(ps:Tomcat 的 QPS 4K 左右,TPS = 1k 左右)。
資料庫讀操作是遠快於寫操作的(正是因為如此,才做讀寫分離),所以步驟(3)要比步驟(2)更快這個情景很難出現,同時還要配合快取剛好失效。
所以,在用旁路快取策略的時候,對於寫操作推薦使用:先更新資料庫,再刪除快取。
最後,針對 Cache-Aside (旁路快取) 策略,寫操作使用先更新資料庫,再刪除快取的情況下,我們來分析下資料一致性解決方案都有哪些?
如果採用先刪除快取,再更新資料庫如何避免出現髒資料?
採用延時雙刪策略。
這樣子最多隻會出現 500 毫秒的髒資料讀取時間。關鍵是這個休眠時間怎麼確定呢?
延遲時間的目的就是確保讀請求結束,寫請求可以刪除讀請求造成的快取髒資料。
所以我們需要自行評估專案的讀資料業務邏輯的耗時,在讀耗時的基礎上加幾百毫秒作為延遲時間即可。
快取刪除失敗怎麼辦?比如延遲雙刪的第二次刪除失敗,那豈不是無法刪除髒資料。
使用重試機制,保證刪除快取成功。
比如重試三次,三次都失敗則記錄紀錄檔到資料庫並行送警告讓人工介入。
在高並行的場景下,重試最好使用非同步方式,比如傳送訊息到 mq 中介軟體,實現非同步解耦。
第(5)步如果刪除失敗且未達到重試最大次數則將訊息重新入隊,直到刪除成功,否則就記錄到資料庫,人工介入。
該方案有個缺點,就是對業務程式碼中造成侵入,於是就有了下一個方案,啟動一個專門訂閱 資料庫 binlog 的服務讀取需要刪除的資料進行快取刪除操作。
快取策略的最佳實踐是 Cache Aside Pattern。分別分為讀快取最佳實踐和寫快取最佳實踐。
讀快取最佳實踐:先讀快取,命中則返回;未命中則查詢資料庫,再寫到資料庫。
寫快取最佳實踐:
在以上最佳實踐下,為了儘可能保證快取與資料庫的一致性,我們可以採用延遲雙刪。
防止刪除失敗,我們採用非同步重試機制保證能正確刪除,非同步機制我們可以傳送刪除訊息到 mq 訊息中介軟體,或者利用 canal 訂閱 MySQL binlog 紀錄檔監聽寫請求刪除對應快取。
那麼,如果我非要保證絕對一致性怎麼辦,先給出結論:
沒有辦法做到絕對的一致性,這是由 CAP 理論決定的,快取系統適用的場景就是非強一致性的場景,所以它屬於 CAP 中的 AP。
所以,我們得委曲求全,可以去做到 BASE 理論中說的最終一致性。
其實一旦在方案中使用了快取,那往往也就意味著我們放棄了資料的強一致性,但這也意味著我們的系統在效能上能夠得到一些提升。
所謂 tradeoff 正是如此。
最後,大家可以在評論區叫我靚仔麼?不想叫我靚仔的「點贊」和「在看」也是一種鼓勵。
加「碼哥」微信:MageByte1024,進入專屬讀者技術群一起談天說地聊技術。
鳴謝
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/