首個徹底保證快取與資料庫一致性的開源方案

葉東富發表於2022-05-10

概述

大量的實際的專案中,都會引入 Redis 快取來緩解資料庫的查詢壓力,此時由於一個資料在 Redis 和資料庫兩處進行了儲存,就會有資料一致性的問題。目前業界尚未見到成熟的能夠確保最終一致性的方案,特別是當如下場景發生時,會直接導致快取資料與資料庫資料不一致,可能給應用帶來較大問題。

image.png

dtm-labs 致力於解決資料一致性問題,在分析了行業的現有做法後,提出了新解決方案dtm-labs/dtm+dtm-labs/rockscache,徹底解決了上述問題。另外作為一個成熟方案,該方案還可以防快取穿透,防快取擊穿,防快取雪崩,同時也可應用於要求資料強一致的場景。

關於管理快取的現有方案,本文不再贅述,不太瞭解的同學可以參考下面這兩篇文章

亂序產生的不一致

在上述這個時序圖中,由於服務1發生了程式暫停(例如由於GC導致),因此當它往快取當中寫入v1時,覆蓋了快取中的v2,導致了最終的不一致(DB中為v2,快取中為v1)。

對於上述這類問題應當如何解決?目前現存的方案,全都沒有徹底解決該問題,一般都是通過設定稍短的過期時間兜底。我們實現的快取延遲刪除方案,能夠徹底解決這個問題,確保快取與資料庫之間的資料保持一致。解決原理如下:

快取中的資料是一個hash,裡面有以下幾個欄位:

  • value: 資料本身
  • lockUtil: 資料鎖定到期時間,當某個程式查詢快取無資料,那麼先鎖定快取一小段時間,然後查詢DB,然後更新快取
  • owner: 資料鎖定者uuid

查詢快取時:

  1. 如果資料為空,且被鎖定,則睡眠1s後,重新查詢
  2. 如果資料為空,且未被鎖定,同步執行"取資料",返回結果
  3. 如果資料不為空,那麼立即返回結果,並非同步執行"取資料"

其中"取資料"的操作定義為:

  1. 判斷是否需要更新快取,下面兩個條件滿足其一,則需要更新快取

    • 資料為空,並且未被鎖定
    • 資料的鎖定已過期
  2. 如果需要更新,則鎖定快取,查詢DB,校驗鎖持有者無變化,寫入快取,解鎖快取

當DB資料更新時,通過dtm確保資料更新成功時,將快取延遲刪除(將在後面一節展開詳細講解)

  • 延遲刪除會將資料過期時間設定為10s,將鎖設定為已過期,觸發下一次查詢快取時的“取資料”

在上述的策略下:
假如最後寫入資料庫的版本為Vi,最後寫入到快取的版本為V,寫入V的uuid為uuidv,那麼一定存在以下事件序列:

資料庫寫入Vi -> 快取資料被標記為刪除 -> 某個查詢鎖定資料並寫入uuidv -> 查詢資料庫結果V -> 快取中的鎖定者為uuidv,寫入結果V

在這個序列中,V的讀取發生在寫入Vi之後,所以V等於Vi,保證了快取的資料的最終一致性。

dtm-labs/rockscache已經實現了上述方法,能夠確保快取資料的最終一致性。

  • Fetch函式實現了前面的查詢快取
  • DelayDelete函式實現了延遲刪除邏輯

感興趣的同學,可以參考dtm-cases/cache,裡面有詳細的例子

DB與快取操作的原子性

對於快取的管理,一般業界會採用寫完資料庫後,刪除/更新快取資料的策略。由於儲存到快取和儲存到資料庫兩個操作之間不是原子的,一定會有時間差,因此這兩個資料之間會有一個不一致的時間視窗,通常這個視窗不大,影響較小。但是兩個中間可能發生當機,也可能發生各種網路錯誤,因此就有可能發生完成了其中一個,但是未完成另一個,導致資料會出現長時間不一致。

舉一個場景來說明上述不一致的情況,資料使用者將資料 A 修改為 B ,應用修改完資料庫之後,再去刪除/更新快取,如果未發生異常,那麼資料庫和快取的資料是一致的,沒有問題。但是分散式系統中,可能會發生程式crash、當機等事件,因此如果更新完資料庫,尚未刪除/更新快取時,出現程式crash,那麼資料庫和快取的資料就可能出現長時間的不一致。

面對這裡的長時間不一致的情況,想要徹底解決,並不是一件容易的事,我們下面分各種應用情況來介紹解決方案。

方案一:較短的快取時間

這個方案,是最簡單的方案,適合併發量不大應用。如果應用的併發不高,那麼整個快取系統,只需要設定了一個較短的快取時間,例如一分鐘。這種情況下資料庫需要承擔的負載是:大約每一分鐘,需要將訪問到的快取資料全部生成一遍,在併發量不大的情況下,這種策略是可行的。

上述這種策略非常簡單,易於理解和實現,快取系統提供的語義是,大多數情況下,快取和資料庫之間不一致的時間視窗是很短的,在較低概率發生程式crash的情況下,不一致的時間視窗會達到一分鐘。

應用在上述約束下,需要將一致性要求不高的資料讀取,從快取讀取;而將一致性要求較高的讀,不走快取,直接從資料庫查詢。

方案二:訊息佇列保證一致

假如應用的併發量很高,快取過期時間需要比一分鐘更長,而且應用中的大量請求不能夠容忍較長時間的不一致,那麼這個時候,可以通過使用訊息佇列的方式,來更新快取。具體的做法是:

  • 更新資料庫時,同時將更新快取的訊息寫入本地表,隨著資料庫更新操作的提交而提交。
  • 寫一個輪詢任務,不斷輪詢這部分訊息,發給訊息佇列。
  • 消費訊息佇列中的訊息,更新/刪除快取

這種做法可以保證資料庫更新之後,快取一定會被更新。但這種這種架構方案很重,這幾個部分開發維護成本都不低:訊息佇列的維護;高效輪詢任務的開發與維護。

方案三:訂閱 binlog

這個方案適用場景與方案二非常類似,原理又與資料庫的主從同步類似,資料庫的主從同步是通過訂閱binlog,將主庫的更新應用到從庫上,而這個方案則是通過訂閱binlog,將資料庫的更新應用到快取上。具體做法是:

  • 部署並配置阿里開源的 canal ,讓它訂閱資料庫的binlog
  • 通過 canal等工具 監聽資料更新,同步更新/刪除快取

這種方案也可以保證資料庫更新之後,快取一定會被更新,但是這種架構方案跟前面的訊息佇列方案一樣,也非常重。一方面 canal 的學習維護成本不低,另一方面,開發者可能只需要少量資料更新快取,通過訂閱所有的 binlog 來做這個事情,浪費了很多資源。

方案四: dtm 二階段訊息方案

dtm 裡的二階段訊息模式,非常適合這裡的修改資料庫之後更新/刪除快取,主要程式碼如下:

msg := dtmcli.NewMsg(DtmServer, gid).
    Add(busi.Busi+"/UpdateRedis", &Req{Key: key1})
err := msg.DoAndSubmitDB(busi.Busi+"/QueryPrepared", db, func(tx *sql.Tx) error {
  // update db data with key1
})

這段程式碼,DoAndSubmitDB會進行本地資料庫操作,進行資料庫的資料修改,修改完成後,會提交一個二階段訊息事務,訊息事務將會非同步呼叫 UpdateRedis。假如本地事務執行之後,就立刻發生了程式 crash 事件,那麼 dtm 會進行回查呼叫 QueryPrepared ,保證本地事務提交成功的情況下,UpdateRedis 會被最少成功執行一次。

回查的邏輯非常簡單,只需要copy類似下面這樣的程式碼即可:

    app.GET(BusiAPI+"/QueryPrepared", dtmutil.WrapHandler(func(c *gin.Context) interface{} {
        return MustBarrierFromGin(c).QueryPrepared(dbGet())
    }))

這種方案的優點:

  • 方案簡單易用,程式碼簡短易讀
  • dtm 本身是一個無狀態的普通應用,依賴的儲存引擎 redis/mysql 是常見的基礎設施,不需要額外維護訊息佇列或者 canal
  • 相關的操作模組化,易維護,不需要像訊息佇列或者 canal 在其他地方寫消費者的邏輯

從庫延時

上述的方案中,假定快取刪除後,服務進行資料查詢,總是能夠查到最新的資料。但是實際的生產環境中,可能會出現主從分離的架構,而主從延時並不是一個可控的變數,那麼這時候又要怎麼處理?

處理方案兩種:一是區分最終一致性很高和不高的快取資料,查詢資料時,將要求很高的資料必須從主庫讀取,而把要求不高的資料從從庫讀取。對於使用了rockscache的應用來說,高併發的請求都會在Redis這一層被攔截,對於一個資料,最多隻會有一個請求到達資料庫,因此資料庫的負載已大幅降低,採用主庫讀取是一個實際可行的方案。

另一種方案是,主從分離需要採用不分叉的單鏈架構,那麼鏈條末尾的從庫必定是延遲最長的從庫,此時採用監聽binlog的方案,需要監聽鏈條做末端的從庫binlog,當收到資料變更通知時,按照上述方案將快取標記為延遲刪除。

這兩個方案各有優缺點,業務可以根據自己的特點採用。

防快取擊穿

rockscache還可以防快取擊穿。當資料變更時,業界現有做法既可以選擇更新快取,也可以選擇刪除快取,各有優劣。而延遲刪除綜合了兩種方法的優勢,並克服了兩種方法的劣勢:

更新快取

採取更新快取策略,那麼會為所有的DB資料更新生成快取,不區分冷熱資料,那麼會存在以下問題:

  • 記憶體上,即使一個資料沒有被讀取,也會儲存在快取裡,浪費了寶貴的記憶體資源;
  • 在計算上,即使一個資料沒有被讀取,也可能因為多次更新,被多次計算,浪費了寶貴的計算資源。
  • 上述的亂序不一致發生的概率會較高,當兩個臨近的更新中出現延遲,就可能觸發。

刪除快取

因為前面的更新快取做法問題較多,因此大多數的實踐採用的是刪除快取策略,查詢時再按需生成快取。這種做法解決了更新快取中的問題,但是又帶來新問題:

  • 那麼在高併發的情況下,如果刪除了一個熱點資料,那麼此時會有大量請求會無法命中快取,產生快取擊穿。

為了防止快取擊穿,通用的做法是使用分散式 Redis 鎖保證只有一個請求到資料庫,等快取生成之後,其他請求進行共享。這種方案能夠適合很多的場景,但有些場景卻不適合。

  • 例如有一個重要的熱點資料,計算代價比較高,需要3s才能夠獲得結果,那麼上述方案在刪除一個這種熱點資料之後,就會在這個時刻,有大量請求3s才返回結果,一方面可能造成大量請求超時,另一方面3s沒有釋放連結,會導致併發連線數量突然升高,可能造成系統不穩定。
  • 另外使用 Redis 鎖時,未獲得鎖的這部分使用者,通常會定時輪詢,而這個睡眠時間不好設定。如果設定比較大的睡眠時間1s,那麼對於10ms就計算出結果的快取資料,返回太慢了;如果設定的睡眠時間太短,那麼很消耗 CPU 和 Redis 效能

延遲刪除法的應對策略

前面介紹的dtm-labs/rockscache實現的延時刪除法也屬於刪除法,但它徹底解決了刪除快取中的擊穿問題,以及擊穿帶來的附帶問題。

  1. 快取擊穿問題:延遲刪除法中,如果快取中的資料不存在,那麼會鎖定快取中的這條資料,因此避免了多個請求打到後端資料庫。
  2. 上述大量請求3s才返回資料,以及定時輪詢的問題,在延時刪除中也不存在,因為熱點資料被延時刪除時,舊版本的資料還在快取中,會被立即返回,無需等待。

我們來看看不同的資料訪問頻率下,延遲刪除法的表現如何:

  1. 熱點資料,每秒1K qps,計算快取時間5ms,此時延遲刪除法,大約5~8ms左右的時間裡,會返回過期資料,而先更新DB,再更新快取,因為更新快取需要時間,也會有大約0~3ms返回過期資料,因此兩者差別不大。
  2. 熱點資料,每秒1K qps,計算快取時間3s,此時延遲刪除法,大約3s的時間裡,會返回過期資料。對比於等待3s後再返回資料,那麼返回舊資料,通常是更好的行為。
  3. 普通資料,每秒50 qps,計算快取時間1s,此時延遲刪除法的行為分析,類似2,沒有問題。
  4. 低頻資料,5秒訪問一次,計算快取時間3s,此時延遲刪除法的行為與刪除快取策略基本一樣,沒有問題
  5. 冷資料,10分鐘訪問一次,此時延遲刪除法,與刪除快取策略基本一樣,只是資料比刪除快取的方式多儲存10s,佔用空間不大,沒有問題

有一種極端情況是,那就是原先快取中沒有資料,突然大量請求到來,這種場景對,更新快取法刪除快取法,延遲刪除法,都是不友好的。這種的場景是開發人員需要避免的,需要通過預熱來解決,而不應當直接扔給快取系統。當然,由於延遲刪除法已經把打到資料庫的請求量降到最低,因此表現也不弱於任何其他方案。

防快取穿透與快取雪崩

dtm-labs/rockscache還實現了防快取穿透與快取雪崩。

快取穿透是指,快取和資料庫都沒有的資料,被大量請求。由於資料不存在,快取就也不會存在該資料,所有的請求都會直接穿透到資料庫。rockscache中可以設定EmptyExipire設定對空結果的快取時間,如果設定為0,那麼不快取空資料,關閉防快取穿透

快取雪崩是指快取中有大量的資料,在同一個時間點,或者較短的時間段內,全部過期了,這個時候請求過來,快取沒有資料,都會請求資料庫,則資料庫的壓力就會突增,扛不住就會當機。rockscache可以設定RandomExpireAdjustment,對過期時間加上隨機值,避免同時過期。

應用能否做到強一致?

上面已經介紹了快取一致性的各種場景,以及相關的解決方案,那麼是否可以保證使用快取的同時,還提供強一致的資料讀寫呢?強一致的讀寫需求比前面的最終一致的需求場景少,但是在金融領域,也是有不少場景的。

當我們在這裡討論強一致時,我們需要先把一致性的含義做一下明確。

開發者最直觀的強一致性很可能理解為,資料庫和快取保持完全一致,寫資料的過程中以及寫完之後,無論從資料庫直接讀,或者從快取直接讀,都能夠獲得最新寫入的結果。對於這種兩個獨立系統之間的“強一致性”,可以非常明確的說,理論上是不可能的,因為更新資料庫和更新快取在不同的機器上,無法做到同時更新,無論如何都會有時間間隔,在這個時間間隔裡,一定是不一致的。

但是應用層的強一致性,則是可以做到的。可以簡單考慮我們熟悉的場景:CPU的快取作為記憶體的快取,記憶體作為磁碟的快取,這些都是快取的場景,從來沒有發生過一致性問題。為什麼?其實很簡單,要求所有的資料使用方,只能夠從快取讀取資料,而不能同時從快取和底層儲存同時讀取資料。

對於DB和Redis,如果所有的資料讀取,只能夠由快取提供,就可以很容易的做到強一致,不會出現不一致的情況。下面我們來根據DB和Redis的特點,來分析其中的設計:

先更新快取還是DB

類比CPU快取與記憶體,記憶體快取與磁碟,這兩個系統都是先修改快取,再修改底層儲存,那麼到了現在的DB快取場景是否也先修改快取再修改DB?

在絕大多數的應用場景下,開發者會認為Redis作為快取,當Redis出現故障時,那麼應用需要支援降級處理,依舊能夠訪問資料庫,提供一定的服務能力。考慮這種場景,一旦出現降級,先寫快取再寫DB方案就有問題,一方面會丟失資料,另一方面會發生先讀取到快取中的新版本v2,再讀取到舊版本v1。因此在Redis作為快取的場景下,絕大部分系統會採取先寫入DB,再寫入快取的這種設計

寫入DB成功快取失敗情況

假如因為程式crash,導致寫入DB成功,但是標記延遲刪除第一次失敗怎麼辦?雖然間隔幾秒之後,會重試成功,但這幾秒鐘的時間裡,使用者去讀取快取,依舊還是舊版本的資料。例如使用者發起了一筆充值,資金已經進入到DB,只是更新快取失敗,導致從快取看到的餘額還是舊值。這種情況的處理很簡單,使用者充值時,寫入DB成功時,應用不要給使用者返回成功,而是等快取更新也成功了,再給使用者返回成功;使用者查詢充值交易時,要查詢DB和快取是否都成功了(可以查詢二階段訊息全域性事務是否已成功),只有兩者都成功了,才返回成功。

在上述的處理策略下,當使用者發起充值後,在快取更新完成之前,使用者看到的是,這筆交易還在處理中,結果未知,此時是符合強一致要求的;當使用者看到交易已經處理成功,也就是快取已更新成功,那麼所有從快取中拿到的資料都是更新後的資料,那麼也符合強一致的要求。

dtm-labs/rockscache也實現了強一致的讀取需求。當開啟StrongConsistency選項,那麼rockscache裡Fetch函式就提供了強一致的快取讀取。其原理與延遲刪除差別不大,僅做了很小的改變,就是不再返回舊版本的資料,而是同步等待“取資料”的最新結果

當然這個改變會帶來效能上的下降,對比與最終一致的資料讀取,強一致的讀取一方面要等待當前“取資料”的最新結果,增加了返回延遲,另一方面要等待其他程式的結果,會產生sleep等待,耗費資源。

快取降級升級中的強一致

上述的強一致方案中,說明了其強一致的前提是:“所有的資料讀取,只能夠由快取”。不過如果Redis如果發生故障,需要進行降級,那麼降級的過程可能很短只有幾秒,但是這個幾秒內如果不能接受不可訪問,還嚴苛的要求提供訪問的話,就會出現讀取快取和讀取DB混用情況,就不滿足這個前提。不過因為Redis故障的頻率不高,要求強一致性的應用通常配備專有Redis,因此遇見故障降級的概率很低,很多應用不會在這個地方提出苛刻的要求。

不過dtm-labs作為資料一致性領域的領導者,也深入研究了這個問題,並給出這種苛刻條件下的解決方案。

升降級的過程

現在我們來考慮應用在Redis快取出現問題的升降級處理。一般情況下這個升降級的開關在配置中心,當修改配置後,各個應用程式會陸續收到降級配置變更通知,然後在行為上降級。在降級的過程中,會出現快取與DB混合訪問的情況,這時我們上面的方案就有可能出現不一致。那麼如何處理才能夠保證在這種混合訪問的情況下,依舊能夠讓應用獲取到強一致的結果呢?

混合訪問的過程中,我們可以採取下面這個策略,來保證DB和快取混合訪問時的資料一致性。

  • 更新資料時,使用分散式事務,保證以下操作為原子操作

    • 將快取標記為“鎖定中”
    • 更新DB
    • 將快取“鎖定中”標記去除,標記為延遲刪除
  • 讀取快取資料時,對於標記為“鎖定中”的資料,睡眠等待後再次讀取;對於延遲刪除的資料,不返回舊資料,等待新資料完成再返回。
  • 讀取DB資料時,直接讀取,無需任何額外操作

這個策略跟前面不考慮降級場景的強一致方案,差別不大,讀資料部分完全不變,需要變的是更新資料。rockscache假定更新DB是一個業務上可能失敗的操作,於是採用一個SAGA事務來保證原子操作,詳情參見例子dtm-cases/cache

升降級的開啟關閉有順序要求,不能夠同時開啟快取讀和寫,而是需要在開啟快取讀的時候,所有的寫操作都已經確保會更新快取。

降級的詳細過程如下:

  1. 最初狀態:

    • 讀:混合讀
    • 寫:DB+快取
  2. 讀降級:

    • 讀:關閉快取讀。混合讀 => 全部DB讀
    • 寫:DB+快取
  3. 寫降級:

    • 讀:全部DB讀;
    • 寫:關閉快取寫。DB+快取 => 只寫DB

升級的過程與此相反,如下:

  1. 最初狀態:

    • 讀:全部讀DB
    • 寫:全部只寫DB
  2. 寫升級:

    • 讀:全部讀DB
    • 寫:開啟寫快取。只寫DB => 寫DB+快取
  3. 讀升級:

    • 讀:部分讀快取。全部讀DB => 混合讀
    • 寫:寫DB+快取

dtm-labs/rockscache已實現了上述強一致的快取管理方法。

感興趣的同學,可以參考dtm-cases/cache,裡面有詳盡的例子

小結

這篇文章很長,許多的分析比較晦澀,最後將Redis快取的使用方式做個總結:

  • 最簡單的方式為:較短的快取時間,允許少量資料庫修改,未同步刪除快取
  • 保證最終一致,並且可防快取擊穿的方式為:二階段訊息+延遲刪除(rockscache)
  • 強一致:二階段訊息+強一致(rockscache)
  • 一致性要求最嚴苛的方式為:二階段訊息+強一致(rockscache)+升降級相容

對於後兩種方式,我們都推薦使用dtm-labs/rockscache來作為您的快取方案

歡迎訪問dtm-labs/rockscachedtm-labs/dtm,並star支援我們

相關文章