趣說 | 數據庫和緩存如何保證一致性?

作者:小林coding

圖解計算機基礎網站:https://xiaolincoding.com/

一天,老闆說「最近公司的用戶越來越多了,但是服務器的訪問速度越來越差的,阿旺幫我優化下,做好了給你畫個餅!」。

程序員阿旺聽到老闆口中的「畫餅」後就非常期待,沒有任何猶豫就接下了老闆給的這個任務。

阿旺登陸到了服務器,經過一番排查後,確認服務器的性能瓶頸是在數據庫

這好辦,給服務器加上 Redis,讓其作爲數據庫的緩存。

這樣,在客戶端請求數據時,如果能在緩存中命中數據,那就查詢緩存,不用在去查詢數據庫,從而減輕數據庫的壓力,提高服務器的性能。

先更新數據庫,還是先更新緩存?

阿旺有了這個想法後,就準備開始着手優化服務器,但是擋在在他前面的是這樣的一個問題。

由於引入了緩存,那麼在數據更新時,不僅要更新數據庫,而且要更新緩存,這兩個更新操作存在前後的問題

  • 先更新數據庫,再更新緩存;
  • 先更新緩存,再更新數據庫;

阿旺沒想到太多,他覺得最新的數據肯定要先更新數據庫,這樣纔可以確保數據庫裏的數據是最新的,於是他就採用了「先更新數據庫,再更新緩存」的方案。

阿旺經過幾個夜晚的折騰,終於「優化好了服務器」,然後就直接上線了,自信心滿滿跑去跟老闆彙報。

老闆不懂技術,自然也沒多慮,就讓後續阿旺觀察下服務器的情況,如果效果不錯,就跟阿旺談畫餅的事情。

阿旺觀察了好幾天,發現數據庫的壓力大大減少了,訪問速度也提高了不少,心想這事肯定成的了。

好景不長,突然老闆收到一個客戶的投訴,客戶說他剛發起了兩次更新年齡的操作,但是顯示的年齡確還是第一次更新時的年齡,而第二次更新年齡並沒有生效。

老闆立馬就找了阿旺,訓斥着阿旺說:「這麼簡單的更新操作,都有 bug?我臉往哪兒放?你的餅還要不要了?

聽到自己準備到手的餅要沒了的阿旺瞬間就慌了,立馬登陸服務器排查問題,阿旺查詢緩存和數據庫的數據後發現了問題。

數據庫的數據是客戶第二次更新操作的數據,而緩存確還是第一次更新操作的數據,也就是出現了數據庫和緩存的數據不一致的問題

這個問題可大了,阿旺經過一輪的分析,造成緩存和數據庫的數據不一致的現象,是因爲併發問題

先更新數據庫,再更新緩存

舉個例子,比如「請求 A 」和「請求 B 」兩個請求,同時更新「同一條」數據,則可能出現這樣的順序:

A 請求先將數據庫的數據更新爲 1,然後在更新緩存前,請求 B 將數據庫的數據更新爲 2,緊接着也把緩存更新爲 2,然後 A 請求更新緩存爲 1。

此時,數據庫中的數據是 2,而緩存中的數據卻是 1,出現了緩存和數據庫中的數據不一致的現象

先更新緩存,再更新數據庫

那換成「先更新緩存,再更新數據庫」這個方案,還會有問題嗎?

依然還是存在併發的問題,分析思路也是一樣。

假設「請求 A 」和「請求 B 」兩個請求,同時更新「同一條」數據,則可能出現這樣的順序:

A 請求先將緩存的數據更新爲 1,然後在更新數據庫前,B 請求來了, 將緩存的數據更新爲 2,緊接着把數據庫更新爲 2,然後 A 請求將數據庫的數據更新爲 1。

此時,數據庫中的數據是 1,而緩存中的數據卻是 2,出現了緩存和數據庫中的數據不一致的現象

所以,無論是「先更新數據庫,再更新緩存」,還是「先更新緩存,再更新數據庫」,這兩個方案都存在併發問題,當兩個請求併發更新同一條數據的時候,可能會出現緩存和數據庫中的數據不一致的現象

先更新數據庫,還是先刪除緩存?

阿旺定位出問題後,思考了一番後,決定在更新數據時,不更新緩存,而是刪除緩存中的數據。然後,到讀取數據時,發現緩存中沒了數據之後,再從數據庫中讀取數據,更新到緩存中。

阿旺想的這個策略是有名字的,是叫 Cache Aside 策略,中文是叫旁路緩存策略。

該策略又可以細分爲「讀策略」和「寫策略」。

寫策略的步驟:

  • 更新數據庫中的數據;
  • 刪除緩存中的數據。

讀策略的步驟:

  • 如果讀取的數據命中了緩存,則直接返回數據;
  • 如果讀取的數據沒有命中緩存,則從數據庫中讀取數據,然後將數據寫入到緩存,並且返回給用戶。

阿旺在想到「寫策略」的時候,又陷入更深層次的思考,到底該選擇哪種順序呢?

  • 先刪除緩存,再更新數據庫;
  • 先更新數據庫,再刪除緩存。

阿旺這次經過上次教訓,不再「想當然」的亂選方案,因爲老闆這次給的餅很大啊,必須把握住。

於是阿旺用併發的角度來分析,看看這兩種方案哪個可以保證數據庫與緩存的數據一致性。

先刪除緩存,再更新數據庫

阿旺還是以用戶表的場景來分析。

假設某個用戶的年齡是 20,請求 A 要更新用戶年齡爲 21,所以它會刪除緩存中的內容。這時,另一個請求 B 要讀取這個用戶的年齡,它查詢緩存發現未命中後,會從數據庫中讀取到年齡爲 20,並且寫入到緩存中,然後請求 A 繼續更改數據庫,將用戶的年齡更新爲 21。

最終,該用戶年齡在緩存中是 20(舊值),在數據庫中是 21(新值),緩存和數據庫的數據不一致。

可以看到,先刪除緩存,再更新數據庫,在「讀 + 寫」併發的時候,還是會出現緩存和數據庫的數據不一致的問題

先更新數據庫,再刪除緩存

繼續用「讀 + 寫」請求的併發的場景來分析。

假如某個用戶數據在緩存中不存在,請求 A 讀取數據時從數據庫中查詢到年齡爲 20,在未寫入緩存中時另一個請求 B 更新數據。它更新數據庫中的年齡爲 21,並且清空緩存。這時請求 A 把從數據庫中讀到的年齡爲 20 的數據寫入到緩存中。

最終,該用戶年齡在緩存中是 20(舊值),在數據庫中是 21(新值),緩存和數據庫數據不一致。

從上面的理論上分析,先更新數據庫,再刪除緩存也是會出現數據不一致性的問題,但是在實際中,這個問題出現的概率並不高

因爲緩存的寫入通常要遠遠快於數據庫的寫入,所以在實際中很難出現請求 B 已經更新了數據庫並且刪除了緩存,請求 A 才更新完緩存的情況。

而一旦請求 A 早於請求 B 刪除緩存之前更新了緩存,那麼接下來的請求就會因爲緩存不命中而從數據庫中重新讀取數據,所以不會出現這種不一致的情況。

所以,「先更新數據庫 + 再刪除緩存」的方案,是可以保證數據一致性的

而且阿旺爲了確保萬無一失,還給緩存數據加上了「過期時間」,就算在這期間存在緩存數據不一致,有過期時間來兜底,這樣也能達到最終一致。

阿旺思考到這一步後,覺得自己真的是個小天才,因爲他竟然想到了個「天衣無縫」的方案,他二話不說就採用了這個方案,又經過幾天的折騰,終於完成了。

他自信滿滿的向老闆彙報,已經解決了上次客戶的投訴的問題了。老闆覺得阿旺這小夥子不錯,這麼快就解決問題了,然後讓阿旺在觀察幾天。

事情哪有這麼順利呢?結果又沒過多久,老闆又收到客戶的投訴了,說自己明明更新了數據,但是數據要過一段時間才生效,客戶接受不了。

老闆面無表情的找上阿旺,讓阿旺儘快查出問題。

阿旺得知又有 Bug 就更慌了,立馬就登錄服務器去排查問題,查看日誌後得知了原因。

「先更新數據庫, 再刪除緩存」其實是兩個操作,前面的所有分析都是建立在這兩個操作都能同時執行成功,而這次客戶投訴的問題就在於,在****刪除緩存(第二個操作)的時候失敗了,導致緩存中的數據是舊值

好在之前給緩存加上了過期時間,所以纔會出現客戶說的過一段時間才更新生效的現象,假設如果沒有這個過期時間的兜底,那後續的請求讀到的就會一直是緩存中的舊數據,這樣問題就更大了。

所以新的問題來了,如何保證「先更新數據庫 ,再刪除緩存」這兩個操作能執行成功?

阿旺分析出問題後,慌慌張張的向老闆彙報了問題。

老闆知道事情後,又給了阿旺幾天來解決這個問題,畫餅的事情這次沒有再提了。

阿旺會用什麼方式來解決這個問題呢?

老闆畫的餅事情,能否兌現給阿旺呢?

預知後事,且聽下回阿旺的故事。

小結

阿旺的事情就聊到這,我們繼續說點其他。

「先更新數據庫,再刪除緩存」的方案雖然保證了數據庫與緩存的數據一致性,但是每次更新數據的時候,緩存的數據都會被刪除,這樣會對緩存的命中率帶來影響。

所以,如果我們的業務對緩存命中率有很高的要求,我們可以採用「更新數據庫 + 更新緩存」的方案,因爲更新緩存並不會出現緩存未命中的情況

但是這個方案前面我們也分析過,在兩個更新請求併發執行的時候,會出現數據不一致的問題,因爲更新數據庫和更新緩存這兩個操作是獨立的,而我們又沒有對操作做任何併發控制,那麼當兩個線程併發更新它們的話,就會因爲寫入順序的不同造成數據的不一致。

所以我們得增加一些手段來解決這個問題,這裏提供兩種做法:

  • 在更新緩存前先加個分佈式鎖,保證同一時間只運行一個請求更新緩存,就會不會產生併發問題了,當然引入了鎖後,對於寫入的性能就會帶來影響。
  • 在更新完緩存時,給緩存加上較短的過期時間,這樣即時出現緩存不一致的情況,緩存的數據也會很快過期,對業務還是能接受的。

對了,針對「先刪除緩存,再刪除數據庫」方案在「讀 + 寫」併發請求而造成緩存不一致的解決辦法是「延遲雙刪」。

延遲雙刪實現的僞代碼如下:

#刪除緩存
redis.delKey(X)
#更新數據庫
db.update(X)
#睡眠
Thread.sleep(N)
#再刪除緩存
redis.delKey(X)

加了個睡眠時間,主要是爲了確保請求 A 在睡眠的時候,請求 B 能夠在這這一段時間完成「從數據庫讀取數據,再把缺失的緩存寫入緩存」的操作,然後請求 A 睡眠完,再刪除緩存。

所以,請求 A 的睡眠時間就需要大於請求 B 「從數據庫讀取數據 + 寫入緩存」的時間。

但是具體睡眠多久其實是個玄學,很難評估出來,所以這個方案也只是儘可能保證一致性而已,極端情況下,依然也會出現緩存不一致的現象。

因此,還是比較建議用「先更新數據庫,再刪除緩存」的方案。


前情回顧

上回程序員阿旺爲了提升數據訪問的性能,引入 Redis 作爲 MySQL 緩存層,但是這件事情並不是那麼簡單,因爲還要考慮 Redis 和 MySQL 雙寫一致性的問題。

阿旺經過一番周折,最終選用了「先更新數據庫,再刪緩存」的策略,原因是這個策略即使在併發讀寫時,也能最大程度保證數據一致性。

聰明的阿旺還搞了個兜底的方案,就是給緩存加上了過期時間。

本以爲就這樣不會在出現數據一致性的問題,結果將功能上線後,老闆還是收到用戶的投訴「說自己明明更新了數據,但是數據要過一段時間才生效」,客戶接受不了。

老闆轉告給了阿旺,阿旺得知又有 Bug 就更慌了,立馬就登錄服務器去排查問題,查看日誌後得知了原因。

「先更新數據庫, 再刪除緩存」其實是兩個操作,這次客戶投訴的問題就在於,在刪除緩存(第二個操作)的時候失敗了,導致緩存中的數據是舊值,而數據庫是最新值

好在之前給緩存加上了過期時間,所以纔會出現客戶說的過一段時間才更新生效的現象,假設如果沒有這個過期時間的兜底,那後續的請求讀到的就會一直是緩存中的舊數據,這樣問題就更大了。

所以新的問題來了,如何保證「先更新數據庫 ,再刪除緩存」這兩個操作能執行成功?

阿旺分析出問題後,慌慌張張的向老闆彙報了問題。

老闆知道事情後,又給了阿旺幾天來解決這個問題,畫餅的事情這次沒有再提了。

  • 阿旺會用什麼方式來解決這個問題呢?
  • 老闆畫的餅事情,能否兌現給阿旺呢?

如何保證兩個操作都能執行成功?

這次用戶的投訴是因爲在刪除緩存(第二個操作)的時候失敗了,導致緩存還是舊值,而數據庫是最新值,造成數據庫和緩存數據不一致的問題,會對敏感業務造成影響。

舉個例子,來說明下。

應用要把數據 X 的值從 1 更新爲 2,先成功更新了數據庫,然後在 Redis 緩存中刪除 X 的緩存,但是這個操作卻失敗了,這個時候數據庫中 X 的新值爲 2,Redis 中的 X 的緩存值爲 1,出現了數據庫和緩存數據不一致的問題。

那麼,後續有訪問數據 X 的請求,會先在 Redis 中查詢,因爲緩存並沒有 誒刪除,所以會緩存命中,但是讀到的卻是舊值 1。

其實不管是先操作數據庫,還是先操作緩存,只要第二個操作失敗都會出現數據一致的問題。

問題原因知道了,該怎麼解決呢?有兩種方法:

  • 重試機制。
  • 訂閱 MySQL binlog,再操作緩存。

先來說第一種。

重試機制

我們可以引入消息隊列,將第二個操作(刪除緩存)要操作的數據加入到消息隊列,由消費者來操作數據。

  • 如果應用刪除緩存失敗,可以從消息隊列中重新讀取數據,然後再次刪除緩存,這個就是重試機制。當然,如果重試超過的一定次數,還是沒有成功,我們就需要向業務層發送報錯信息了。
  • 如果刪除緩存成功,就要把數據從消息隊列中移除,避免重複操作,否則就繼續重試。

舉個例子,來說明重試機制的過程。

訂閱 MySQL binlog,再操作緩存

先更新數據庫,再刪緩存」的策略的第一步是更新數據庫,那麼更新數據庫成功,就會產生一條變更日誌,記錄在 binlog 裏。

於是我們就可以通過訂閱 binlog 日誌,拿到具體要操作的數據,然後再執行緩存刪除,阿里巴巴開源的 Canal 中間件就是基於這個實現的。

Canal 模擬 MySQL 主從複製的交互協議,把自己僞裝成一個 MySQL 的從節點,向 MySQL 主節點發送 dump 請求,MySQL 收到請求後,就會開始推送 Binlog 給 Canal,Canal 解析 Binlog 字節流之後,轉換爲便於讀取的結構化數據,供下游程序訂閱使用。

下圖是 Canal 的工作原理:

所以,如果要想保證「先更新數據庫,再刪緩存」策略第二個操作能執行成功,我們可以使用「消息隊列來重試緩存的刪除」,或者「訂閱 MySQL binlog 再操作緩存」,這兩種方法有一個共同的特點,都是採用異步操作緩存。

老闆發餅啦

阿旺由於對消息隊列比較熟悉,所以他決定採用「消息隊列來重試緩存的刪除」的方案,來解決這次的用戶問題。

經過幾天幾夜的操作,服務器搞定啦,立馬向老闆彙報工作。

老闆讓阿旺再觀察些時間,如果沒問題,到中秋節就商量“餅”的事情。

時間過的很快,中秋佳節到了,這期間一直都沒有用戶反饋數據不一致的問題。

老闆見這次阿旺表現很好,沒有再出現任何差錯,服務器的訪問性能也上來了,於是給阿旺發了這個超級大的月餅,你看這個餅又大又圓,就像你的代碼又長又多。

阿旺看到這個月餅,哭笑不得,沒想到這就是老闆畫的餅,是真的很大餅。。。。

以上故事純屬虛擬,如有巧合,以你爲準。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章