Redis 擁有高性能的資料讀寫功能,被我們廣泛用在緩存場景,一是能提高業務系統的性能,二是為資料庫抵擋了高并發的流量請求,點我 -> 解密 Redis 為什麼這麼快的秘密。
把 Redis 作為緩存元件,需要防止出現以下的一些問題,否則可能會造成生産事故。
今天「碼哥」跟大家一起深入探索緩存的工作機制和緩存一緻性應對方案。
在本文正式開始之前,我覺得我們需要先取得以下兩點的共識:
- 緩存必須要有過期時間;
- 保證資料庫跟緩存的最終一緻性即可,不必追求強一緻性。
目錄如下:
1. 什麼是資料庫與緩存一緻性
資料一緻性指的是:
- 緩存中存有資料,緩存的資料值 = 資料庫中的值;
- 緩存中沒有該資料,資料庫中的值 = 最新值。
反推緩存與資料庫不一緻:
- 緩存的資料值 ≠ 資料庫中的值;
- 緩存或者資料庫存在舊的資料,導緻線程讀取到舊資料。
為何會出現資料一緻性問題呢?
把 Redis 作為緩存的時候,當資料發生改變我們需要雙寫來保證緩存與資料庫的資料一緻。
資料庫跟緩存,畢竟是兩套系統,如果要保證強一緻性,勢必要引入
2PC
或
Paxos
等分布式一緻性協定,或者分布式鎖等等,這個在實作上是有難度的,而且一定會對性能有影響。
如果真的對資料的一緻性要求這麼高,那引入緩存是否真的有必要呢?
2. 緩存的使用政策
在使用緩存時,通常有以下幾種緩存使用政策用于提升系統性能:
-
(旁路緩存,業務系統常用)Cache-Aside Pattern
-
Read-Through Pattern
-
Write-Through Pattern
-
Write-Behind Pattern
2.1 Cache-Aside (旁路緩存)
所謂「旁路緩存」,就是讀取緩存、讀取資料庫和更新緩存的操作都在應用系統來完成,業務系統最常用的緩存政策。
2.1.1 讀取資料

讀取資料邏輯如下:
- 當應用程式需要從資料庫讀取資料時,先檢查緩存資料是否命中。
- 如果緩存未命中,則查詢資料庫擷取資料,同時将資料寫到緩存中,以便後續讀取相同資料會命中緩存,最後再把資料傳回給調用者。
- 如果緩存命中,直接傳回。
時序圖如下:

優點
- 緩存中僅包含應用程式實際請求的資料,有助于保持緩存大小的成本效益。
- 實作簡單,并且能獲得性能提升。
實作的僞代碼如下:
String cacheKey = "公衆号:碼哥位元組";
String cacheValue = redisCache.get(cacheKey);
//緩存命中
if (cacheValue != null) {
return cacheValue;
} else {
//緩存缺失, 從資料庫擷取資料
cacheValue = getDataFromDB();
// 将資料寫到緩存中
redisCache.put(cacheValue)
}
缺點
由于資料僅在緩存未命中後才加載到緩存中,是以初次調用的資料請求響應時間會增加一些開銷,因為需要額外的緩存填充和資料庫查詢耗時。
2.1.2 更新資料
使用
cache-aside
模式寫資料時,如下流程。

- 寫資料到資料庫;
- 将緩存中的資料失效或者更新緩存資料;
使用
cache-aside
時,最常見的寫入政策是直接将資料寫入資料庫,但是緩存可能會與資料庫不一緻。
我們應該給緩存設定一個過期時間,這個是保證最終一緻性的解決方案。
如果過期時間太短,應用程式會不斷地從資料庫中查詢資料。同樣,如果過期時間過長,并且更新時沒有使緩存失效,緩存的資料很可能是髒資料。
最常用的方式是删除緩存使緩存資料失效。
為啥不是更新緩存呢?
性能問題
當緩存的更新成本很高,需要通路多張表聯合計算,建議直接删除緩存,而不是更新緩存資料來保證一緻性。
安全問題
在高并發場景下,可能會造成查詢查到的資料是舊值,具體待會碼哥會分析,大家别急。
2.2 Read-Through(直讀)
當緩存未命中,也是從資料庫加載資料,同時寫到緩存中并傳回給應用系統。
雖然
read-through
和
cache-aside
非常相似,在
cache-aside
中應用系統負責從資料庫擷取資料和填充緩存。
而 Read-Through 将擷取資料存儲中的值的責任轉移到了緩存提供者身上。

Read-Through 實作了關注點分離原則。代碼隻與緩存互動,由緩存元件來管理自身與資料庫之間的資料同步。
2.3 Write-Through 同步直寫
與 Read-Through 類似,發生寫請求時,Write-Through 将寫入責任轉移到緩存系統,由緩存抽象層來完成緩存資料和資料庫資料的更新,時序流程圖如下:

Write-Through
的主要好處是應用系統的不需要考慮故障處理和重試邏輯,交給緩存抽象層來管理實作。
優缺點
單獨直接使用該政策是沒啥意義的,因為該政策要先寫緩存,再寫資料庫,對寫入操作帶來了額外延遲。
當
Write-Through
與
Read-Through
配合使用,就能成分發揮
Read-Through
的優勢,同時還能保證資料一緻性,不需要考慮如何将緩存設定失效。

這個政策颠倒了
Cache-Aside
填充緩存的順序,并不是在緩存未命中後延遲加載到緩存,而是在資料先寫緩存,接着由緩存元件将資料寫到資料庫。
優點
- 緩存與資料庫資料總是最新的;
- 查詢性能最佳,因為要查詢的資料有可能已經被寫到緩存中了。
缺點
不經常請求的資料也會寫入緩存,進而導緻緩存更大、成本更高。
2.4 Write-Behind
這個圖一眼看去似乎與
Write-Through
一樣,其實不是的,差別在于最後一個箭頭的箭頭:它從實心變為線。
這意味着緩存系統将異步更新資料庫資料,應用系統隻與緩存系統互動。
應用程式不必等待資料庫更新完成,進而提高應用程式性能,因為對資料庫的更新是最慢的操作。

這種政策下,緩存與資料庫的一緻性不強,對一緻性高的系統不建議使用。
3. 旁路緩存下的一緻性問題分析
業務場景用的最多的就是
Cache-Aside
(旁路緩存) 政策,在該政策下,用戶端對資料的讀取流程是先讀取緩存,如果命中則傳回;未命中,則從資料庫讀取并把資料寫到緩存中,是以讀操作不會導緻緩存與資料庫的不一緻。
重點是寫操作,資料庫和緩存都需要修改,而兩者就會存在一個先後順序,可能會導緻資料不再一緻。針對寫,我們需要考慮兩個問題:
- 先更新緩存還是更新資料庫?
- 當資料發生變化時,選擇修改緩存(update),還是删除緩存(delete)?
将這兩個問題排列組合,會出現四種方案:
- 先更新緩存,再更新資料庫;
- 先更新資料庫,再更新緩存;
- 先删除緩存,再更新資料庫;
- 先更新資料庫,再删除緩存。
接下來的分析大家不必死記硬背,關鍵在于在推演的過程中大家隻需要考慮以下兩個場景會不會帶來嚴重問題即可:
- 其中第一個操作成功,第二個失敗會導緻什麼問題?
- 在高并發情況下會不會造成讀取資料不一緻?
為啥不考慮第一個失敗,第二個成功的情況呀?
你猜?
既然第一個都失敗了,第二個就不用執行了,直接在第一步傳回 50x 等異常資訊即可,不會出現不一緻問題。
隻有第一個成功,第二個失敗才讓人頭痛,想要保證他們的原子性,就涉及到分布式事務的範疇了。
3.1 先更新緩存,再更新資料庫

如果先更新緩存成功,寫資料庫失敗,就會導緻緩存是最新資料,資料庫是舊資料,那緩存就是髒資料了。
之後,其他查詢立馬請求進來的時候就會擷取這個資料,而這個資料資料庫中卻不存在。
資料庫都不存在的資料,緩存并傳回用戶端就毫無意義了。
該方案直接
Pass
。
3.2 先更新資料庫,再更新緩存
一切正常的情況如下:
- 先寫資料庫,成功;
- 再 update 緩存,成功。
更新緩存失敗
這時候我們來推斷下,假如這兩個操作的原子性被破壞:第一步成功,第二步失敗會導緻什麼問題?
會導緻資料庫是最新資料,緩存是舊資料,出現一緻性問題。
該圖我就不畫了,與上一個圖類似,對調下 Redis 和 MySQL 的位置即可。
高并發場景
謝霸歌經常 996,腰酸脖子疼,bug 越寫越多,想去按摩推拿放提升下程式設計技巧。
疫情影響,單子來之不易,高端會所的技師都争先恐後想接這一單,高并發啊兄弟們。
在進店以後,前台會将顧客資訊錄入系統,執行
set xx的服務技師 = 待定
的初始值表示目前無人接待儲存到資料庫和緩存中,之後再安排技師按摩服務。
如下圖所示:

- 98 号技師先下手為強,向系統發送
的指令寫入資料庫,這時候系統的網絡出現波動,卡頓了,資料還沒來得及寫到緩存。set 謝霸歌的服務技師 = 98
- 接下來,520 号技師也向系統發送
寫到資料庫中,并且也把這個資料寫到緩存中了。set 謝霸哥的服務技師 = 520
- 這時候之前的 98 号技師的寫緩存請求開始執行,順利将資料
寫到緩存中。set 謝霸歌的服務技師 = 98
最後發現,資料庫的值 =
set 謝霸哥的服務技師 = 520
,而緩存的值=
set 謝霸歌的服務技師 = 98
。
520 号技師在緩存中的最新資料被 98 号技師的舊資料覆寫了。
是以,在高并發的場景中,多線程同時寫資料再寫緩存,就會出現緩存是舊值,資料庫是最新值的不一緻情況。
該方案直接 pass。
如果第一步就失敗,直接傳回 50x 異常,并不會出現資料不一緻。
3.3 先删緩存,再更新資料庫
按照「碼哥」前面說的套路,假設第一個操作成功,第二個操作失敗推斷下會發生什麼?高并發場景下又會發生什麼?
第二步寫資料庫失敗
假設現在有兩個請求:寫請求 A,讀請求 B。
寫請求 A 第一步先删除緩存成功,寫資料到資料庫失敗,就會導緻該次寫資料丢失,資料庫儲存的是舊值。
接着另一個讀請 B 求進來,發現緩存不存在,從資料庫讀取舊資料并寫到緩存中。
高并發下的問題

- 還是 98 号技師先下手為強,系統接收請求把緩存資料删除,當系統準備将
寫到資料庫的時候發生卡頓,來不及寫入。set 肖菜雞的服務技師 = 98
- 這時候,大堂經理向系統執行讀請求,查下肖菜雞有沒有技師接待,友善安排技師服務,系統發現緩存中沒資料,于是乎就從資料庫讀取到舊資料
,并寫到緩存中。set 肖菜雞的服務技師 = 待定
- 這時候,原先卡頓的 98 号技師寫資料
到資料庫的操作完成。set 肖菜雞的服務技師 = 98
**這樣子會出現緩存的是舊資料,在緩存過期之前無法讀取到最資料。**肖菜雞本就被 98 号技師接單了,但是大堂經理卻以為沒人接待。
該方案 pass,因為第一步成功,第二步失敗,會造成資料庫是舊資料,緩存中沒資料繼續從資料庫讀取舊值寫入緩存,造成資料不一緻,還會多一次 cahche。
不論是異常情況還是高并發場景,會導緻資料不一緻。 miss。
3.4 先更新資料庫,再删緩存
經過前面的三個方案,全都被 pass 了,分析下最後的方案到底行不行。
按照「套路」,分别判斷異常和高并發會造成什麼問題。
該政策可以知道,在寫資料庫階段失敗的話就直返傳回用戶端異常,不需要執行緩存操作了。
是以第一步失敗不會出現資料不一緻的情況。
删緩存失敗
重點在于第一步寫最新資料到資料庫成功,删除緩存失敗怎麼辦?
可以把這兩個操作放在一個事務中,當緩存删除失敗,那就把寫資料庫復原。
高并發場景下不合适,容易出現大事務,造成死鎖問題。
如果不復原,那就出現資料庫是新資料,緩存還是舊資料,資料不一緻了,咋辦?
是以,我們要想辦法讓緩存删除成功,不然隻能等到有效期失效那可不行。
使用重試機制。
比如重試三次,三次都失敗則記錄日志到資料庫,使用分布式排程元件 xxl-job 等實作後續的處理。
在高并發的場景下,重試最好使用異步方式,比如發送消息到 mq 中間件,實作異步解耦。
亦或是利用 Canal 架構訂閱 MySQL binlog 日志,監聽對應的更新請求,執行删除對應緩存操作。
高并發場景
再來分析下高并發讀寫會有什麼問題……

- 98 号技師先下手為強,接下肖菜雞的這筆生意,資料庫執行
;還是網絡卡頓了下,沒來得及執行删除緩存操作。set 肖菜雞的服務技師 = 98
- 主管 Candy 向系統執行讀請求,查下肖菜雞有沒有技師接待,發現緩存中有資料
,直接傳回資訊給用戶端,主管以為沒人接待。肖菜雞的服務技師 = 待定
- 原先 98 号技師接單,由于卡頓沒删除緩存的操作現在執行删除成功。
讀請求可能出現少量讀取舊資料的情況,但是很快舊資料就會被删除,之後的請求都能擷取最新資料,問題不大。
還有一種比較極端的情況,緩存自動失效的時候又遇到了高并發讀寫的情況,假設這會有兩個請求,一個線程 A 做查詢操作,一個線程 B 做更新操作,那麼會有如下情形産生:

- 緩存的過期時間到期,緩存失效。
- 線程 A 讀請求讀取緩存,沒命中,則查詢資料庫得到一個舊的值(因為 B 會寫新值,相對而言就是舊的值了),準備把資料寫到緩存時發送網絡問題卡頓了。
- 線程 B 執行寫操作,将新值寫資料庫。
- 線程 B 執行删除緩存。
- 線程 A 繼續,從卡頓中醒來,把查詢到的舊值寫到入緩存。
碼哥,這咋玩,還是出現了不一緻的情況啊。
不要慌,發生這個情況的機率微乎其微,發生上述情況的必要條件是:
- 步驟 (3)的寫資料庫操作要比步驟(2)讀操作耗時短速度快,才可能使得步驟(4)先于步驟(5)。
- 緩存剛好到達過期時限。
通常 MySQL 單機的 QPS 大概 5K 左右,而 TPS 大概 1k 左右,(ps:Tomcat 的 QPS 4K 左右,TPS = 1k 左右)。
資料庫讀操作是遠快于寫操作的(正是因為如此,才做讀寫分離),是以步驟(3)要比步驟(2)更快這個情景很難出現,同時還要配合緩存剛好失效。
是以,在用旁路緩存政策的時候,對于寫操作推薦使用:先更新資料庫,再删除緩存。
4. 一緻性解決方案有哪些?
最後,針對 Cache-Aside (旁路緩存) 政策,寫操作使用先更新資料庫,再删除緩存的情況下,我們來分析下資料一緻性解決方案都有哪些?
4.1 緩存延時雙删
如果采用先删除緩存,再更新資料庫如何避免出現髒資料?
采用延時雙删政策。
- 先删除緩存。
- 寫資料庫。
- 休眠 500 毫秒,再删除緩存。
這樣子最多隻會出現 500 毫秒的髒資料讀取時間。關鍵是這個休眠時間怎麼确定呢?
延遲時間的目的就是確定讀請求結束,寫請求可以删除讀請求造成的緩存髒資料。
是以我們需要自行評估項目的讀資料業務邏輯的耗時,在讀耗時的基礎上加幾百毫秒作為延遲時間即可。
4.2 删除緩存重試機制
緩存删除失敗怎麼辦?比如延遲雙删的第二次删除失敗,那豈不是無法删除髒資料。
使用重試機制,保證删除緩存成功。
比如重試三次,三次都失敗則記錄日志到資料庫并發送警告讓人工介入。
在高并發的場景下,重試最好使用異步方式,比如發送消息到 mq 中間件,實作異步解耦。

第(5)步如果删除失敗且未達到重試最大次數則将消息重新入隊,直到删除成功,否則就記錄到資料庫,人工介入。
該方案有個缺點,就是對業務代碼中造成侵入,于是就有了下一個方案,啟動一個專門訂閱 資料庫 binlog 的服務讀取需要删除的資料進行緩存删除操作。
4.3 讀取 binlog 異步删除

- 更新資料庫;
- 資料庫會把操作資訊記錄在 binlog 日志中;
- 使用 canal 訂閱 binlog 日志擷取目标資料和 key;
- 緩存删除系統擷取 canal 的資料,解析目标 key,嘗試删除緩存。
- 如果删除失敗則将消息發送到消息隊列;
- 緩存删除系統重新從消息隊列擷取資料,再次執行删除操作。
總結
緩存政策的最佳實踐是 **Cache Aside Pattern。**分别分為讀緩存最佳實踐和寫緩存最佳實踐。
讀緩存最佳實踐:先讀緩存,命中則傳回;未命中則查詢資料庫,再寫到資料庫。
寫緩存最佳實踐:
- 先寫資料庫,再操作緩存;
- 直接删除緩存,而不是修改,因為當緩存的更新成本很高,需要通路多張表聯合計算,建議直接删除緩存,而不是更新,另外,删除緩存操作簡單,副作用隻是增加了一次 chache miss,建議大家使用該政策。
在以上最佳實踐下,為了盡可能保證緩存與資料庫的一緻性,我們可以采用延遲雙删。
防止删除失敗,我們采用異步重試機制保證能正确删除,異步機制我們可以發送删除消息到 mq 消息中間件,或者利用 canal 訂閱 MySQL binlog 日志監聽寫請求删除對應緩存。
那麼,如果我非要保證絕對一緻性怎麼辦,先給出結論:
沒有辦法做到絕對的一緻性,這是由 CAP 理論決定的,緩存系統适用的場景就是非強一緻性的場景,是以它屬于 CAP 中的 AP。
是以,我們得委曲求全,可以去做到 BASE 理論中說的最終一緻性。