作者:小林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 再操作快取」,這兩種方法有一個共同的特點,都是採用非同步操作快取。
老闆發餅啦
阿旺由於對訊息佇列比較熟悉,所以他決定採用「訊息佇列來重試快取的刪除」的方案,來解決這次的使用者問題。
經過幾天幾夜的操作,伺服器搞定啦,立馬向老闆彙報工作。
老闆讓阿旺再觀察些時間,如果沒問題,到中秋節就商量“餅”的事情。
時間過的很快,中秋佳節到了,這期間一直都沒有使用者反饋資料不一致的問題。
老闆見這次阿旺表現很好,沒有再出現任何差錯,伺服器的訪問效能也上來了,於是給阿旺發了這個超級大的月餅,你看這個餅又大又圓,就像你的程式碼又長又多。
阿旺看到這個月餅,哭笑不得,沒想到這就是老闆畫的餅,是真的很大餅。。。。
以上故事純屬虛擬,如有巧合,以你為準。