資料庫與快取雙寫一致性

超大隻烏龜發表於2022-03-20

問題

你只要用快取,就可能會涉及到快取與資料庫雙儲存雙寫,你只要是雙寫,就一定會有資料一致性的問題,那麼你如何解決一致性問題?

分析

先做一個說明,從理論上來說,有兩種處理思維,一種需保證資料強一致性,這樣效能肯定大打折扣;另外我們可以採用最終一致性,保證效能的基礎上,允許一定時間內的資料不一致,但最終資料是一致的。

一致性問題是如何產生的?

對於讀取過程:

  • 首先,讀快取;
  • 如果快取裡沒有值,那就讀取資料庫的值;
  • 同時把這個值寫進快取中。

雙更新模式:操作不合理,導致資料一致性問題

我們來看下常見的一個錯誤編碼方式:

public void putValue(key,value){
    // 儲存到redis
    putToRedis(key,value);
    // 儲存到MySQL
    putToDB(key,value);//操作失敗了
}

比如我要更新一個值,首先刷了快取,然後把資料庫也更新了。但過程中,更新資料庫可能會失敗,發生了回滾。所以,最後“快取裡的資料”和“資料庫的資料”就不一樣了,也就是出現了資料一致性問題。

image.png

你或許會說:我先更新資料庫,再更新快取不就行了?

public void putValue(key,value){
    // 儲存到MySQL
    putToDB(key,value);
    // 儲存到redis
    putToRedis(key,value);
}

這依然會有問題。

考慮到下面的場景:操作 A 更新 a 的值為 1,操作 B 更新 a 的值為 2。由於資料庫和 Redis 的操作,並不是原子的,它們的執行時長也不是可控制的。當兩個請求的時序發生了錯亂,就會發生快取不一致的情況。

image.png

放到實操中,就如上圖所示:A 操作在更新資料庫成功後,再更新 Redis;但在更新 Redis 之前,另外一個更新操作 B 執行完畢。那麼操作 A 的這個 Redis 更新動作,就和資料庫裡面的值不一樣了。

那麼怎麼辦呢?其實,我們把“快取更新”改成“刪除”就好了

不再更新快取,直接刪除,為什麼?

  • 業務角度考慮

原因很簡單,很多時候,在複雜點的快取場景,快取不單單是資料庫中直接取出來的值。比如可能更新了某個表的一個欄位,然後其對應的快取,是需要查詢另外兩個表的資料並進行運算,才能計算出快取最新的值的。

  • 價效比角度考慮

更新快取的代價有時候是很高的。如果頻繁更新快取,需要考慮這個快取到底會不會被頻繁訪問?

舉個例子,一個快取涉及的表的欄位,在 1 分鐘內就修改了 20 次,或者是 100 次,那麼快取更新 20 次、100 次;但是這個快取在 1 分鐘內只被讀取了 1 次,有大量的冷資料。實際上,如果你只是刪除快取的話,那麼在 1 分鐘內,這個快取不過就重新計算一次而已,開銷大幅度降低。用到快取才去算快取。

“後刪快取”能解決多數不一致

因為每次讀取時,如果判斷 Redis 裡沒有值,就會重新讀取資料庫,這個邏輯是沒問題的。

唯一的問題是:我們是先刪除快取?還是後刪除快取?

答案是後刪除快取。

1.如果先刪快取

我們來看一下先刪除快取會有什麼問題:

public void putValue(key,value){
    // 刪除redis資料
    deleteFromRedis(key);
    // 儲存到資料庫
    putToDB(key,value);
}

image.png

就和上面的圖一樣。操作 B 刪除了某個 key 的值,這時候有另外一個請求 A 到來,那麼它就會擊穿到資料庫,讀取到舊的值, 然後寫入redis,無論操作 B 更新資料庫的操作持續多長時間,都會產生不一致的情況。

2.如果後刪快取

而把刪除的動作放在後面,就能夠保證每次讀到的值都是最新的。

public void putValue(key,value){
    // 儲存到資料庫
    putToDB(key,value);
    // 刪除redis資料
    deleteFromRedis(key);
}

這就是我們通常說的Cache-Aside Pattern,也是我們平常使用最多的模式。我們看一下它的具體方式。

先看一下資料的讀取過程,規則是“先讀 cache,再讀 db”,詳細步驟如下:

  • 每次讀取資料,都從 cache 裡讀;
  • 如果讀到了,則直接返回,稱作 cache hit;
  • 如果讀不到 cache 的資料,則從 db 裡面撈一份,稱作 cache miss;
  • 將讀取到的資料塞入到快取中,下次讀取時,就可以直接命中。

再來看一下寫請求,規則是“先更新 db,再刪除快取”,詳細步驟如下:

  • 將變更寫入到資料庫中;
  • 刪除快取裡對應的資料。

大廠高併發,“後刪快取”依舊不一致

這種情況不存在併發問題麼?不是的。假設這會有兩個請求,一個請求A做查詢操作,一個請求B做更新操作,那麼會有如下情形產生

  1. 快取剛好失效
  2. 請求A查詢資料庫,得一箇舊值
  3. 請求B將新值寫入資料庫
  4. 請求B刪除快取
  5. 請求A將查到的舊值寫入快取

如果發生上述情況,確實是會發生髒資料。

然而,發生這種情況的概率又有多少呢?
發生上述情況有一個先天性條件,就是步驟(3)的寫資料庫操作比步驟(2)的讀資料庫操作耗時更短,才有可能使得步驟(4)先於步驟(5)。可是,大家想想,資料庫的讀操作的速度遠快於寫操作的,因此步驟(3)耗時比步驟(2)更短,這一情形很難出現。

一般情況下,讀取操作都是比寫入操作快的,但我們要考慮兩種極端情況:

- 一種是這個讀取操作 A,發生在更新操作 B 的尾部。(比如寫操作執行1s,讀操作耗時100ms,讀操作在寫操作執行到800ms的時候開始執行,在寫操作執行到900ms的時候結束,所以實際上讀操作僅僅比寫操作快了100ms而已)

  • 一種是操作 A 的這個 Redis 的操作時長,耗費了非常多的時間。比如,這個節點正好發生了 STW。(Java中Stop-The-World機制簡稱STW,是在執行垃圾收集演算法時,Java應用程式的其他所有執行緒都被掛起(除了垃圾回收器之外)。Java中一種全域性暫停現象,全域性停頓,所有Java程式碼停止,native程式碼可以執行,但不能與JVM互動;這些現象多半是由於gc引起)

那麼很容易地,讀操作 A 的結束時間就超過了操作 B 刪除的動作。

這種場景的出現,不僅需要快取失效且讀寫併發執行,而且還需要讀請求查詢資料庫的執行早於寫請求更新資料庫,同時讀請求的執行完成晚於寫請求。這種不一致場景產生的條件非常嚴格,一般業務是達不到這個量級的,所以一般公司不去處理這種情況,但高併發業務就非常常見了。

image.png

那如果是讀寫分離的場景下呢?如果按照如下所述的執行序列,一樣會出問題:

  1. 請求A更新主庫
  2. 請求A刪除快取
  3. 請求B查詢快取,沒有命中,查詢從庫得到舊值
  4. 從庫同步完畢
  5. 請求B將舊值寫入快取

如果資料庫主從同步比較慢的話,同樣會出現資料不一致的問題。事實上就是如此,畢竟我們操作的是兩個系統,在高併發的場景下,我們很難去保證多個請求之間的執行順序,或者就算做到了,也可能會在效能上付出極大的代價。

加鎖?

可以採用加鎖在寫請求中保證“更新資料庫&刪除快取”的序列執行為原子性操作(同理也可對讀請求中快取的更新加鎖)。加鎖勢必會導致吞吐量的下降,故採取加鎖的方案應該對效能的損耗有所預期。

image.png

如何解決高併發的不一致問題?

大家看上面這種不一致情況發生的場景,歸根結底還是“刪除操作”發生在“更新操作”之前了。

延時雙刪

假如我有一種機制,能夠確保刪除動作一定被執行,那就可以解決問題,起碼能縮小資料不一致的時間視窗。

常用的方法就是延時雙刪,依然是先更新再刪除,唯一不同的是:我們把這個刪除動作,在不久之後再執行一次,比如 5 秒之後。

public void putValue(key,value){
    putToDB(key,value);
    deleteFromRedis(key);
    // 數秒後重新執行刪除操作
    deleteFromRedis(key,5);
}

這個休眠時間 = 讀業務邏輯資料的耗時 + 幾百毫秒。為了確保讀請求結束,寫請求可以刪除讀請求可能帶來的快取髒資料。

這種方案還算可以,只有休眠那一會,可能有髒資料,一般業務也會接受的。

其實在討論最後一個方案時,我們沒有考慮運算元據庫或者操作快取可能失敗的情況,而這種情況也是客觀存在的。

那麼在這裡我們簡單討論下,首先是如果更新資料庫失敗了,其實沒有太大關係,因為此時資料庫和快取中都還是老資料,不存在不一致的問題。假設刪除快取失敗了呢?此時確實會存在資料不一致的情況。除了設定快取過期時間這種兜底方案之外,如果我們希望儘可能保證快取可以被及時刪除,那麼我們必須要考慮對刪除操作進行重試。

刪除快取重試機制

你當然可以直接在程式碼中對刪除操作進行重試,但是要知道如果是網路原因導致的失敗,立刻進行重試操作很可能也是失敗的,因此在每次重試之間你可能需要等待一段時間,比如幾百毫秒甚至是秒級等待。為了不影響主流程的正常執行,你可能會將這個事情交給一個非同步執行緒來執行。

而刪除動作也有多種選擇:

  • 如果開執行緒去執行,會有隨著 JVM 程式的死亡,丟失更新的風險;
  • 如果放在 MQ 中,會增加編碼的複雜性。

所以到了這個時候,並沒有一個能夠行走天下的解決方案。我們得綜合評價很多因素去做設計,比如團隊的水平、工期、不一致的忍受程度等。

非同步優化方式:訊息佇列

  1. 寫請求更新資料庫
  2. 快取因為某些原因,刪除失敗
  3. 把刪除失敗的key放到訊息佇列
  4. 消費訊息佇列的訊息,獲取要刪除的key
  5. 重試刪除快取操作

image.png

非同步優化方式:基於訂閱binlog的同步機制

那如果是讀寫分離場景呢?我們知道資料庫(以Mysql為例)主從之間的資料同步是通過binlog同步來實現的,因此這裡可以考慮訂閱binlog(可以使用canal之類的中介軟體實現),提取出要刪除的快取項,然後作為訊息寫入訊息佇列,然後再由消費端進行慢慢的消費和重試。

image.png

  1. 更新資料庫資料
  2. 資料庫會將操作資訊寫入binlog日誌當中
  3. 訂閱程式提取出所需要的資料以及key
  4. 另起一段非業務程式碼,獲得該資訊
  5. 嘗試刪除快取操作,發現刪除失敗
  6. 將這些資訊傳送至訊息佇列
  7. 重新從訊息佇列中獲得該資料,重試操作。

小結

針對 Redis 的快取一致性問題,我們聊了很多。可以看到,無論你怎麼做,一致性問題總是存在,只是機率慢慢變小了。

隨著對不一致問題的忍受程度越來越低、併發量越來越高,我們所採用的方案也越來越極端。一般情況下,到了延時雙刪這一步,就證明你的併發量已經夠大了;再往下走,無不是對高可用、成本、一致性的權衡,進入到了特事特辦的場景,甚至要考慮基礎設施,關於這些每個公司的策略都是不一樣的。

除了 Cache-Aside Pattern,一致性常見的還有 Read-Through、Write-Through、Write-Behind 等模式,它們都有自己的應用場景,你可以再深入瞭解一下。

參考

資料庫與快取的雙寫一致性
資料庫與快取的一致性問題
Redis快取一致性設計

相關文章