1. 程式人生 > 程式設計 >聊聊資料庫與快取資料一致性問題

聊聊資料庫與快取資料一致性問題

引言

資料庫跟快取,或者用Mysql和Redis來代替,想必每個CRUD boy都不會陌生。本文要聊的也是一個經典問題,就是以怎樣的方式去操作資料庫和快取比較合理。

在本文正式開始之前,我覺得我們需要先取得以下兩點的共識:

  1. 快取必須要有過期時間
  2. 保證資料庫跟快取的最終一致性即可,不必追求強一致性

為什麼必須要有過期時間?首先對於快取來說,當它的命中率越高的時候,我們的系統效能也就越好。如果某個快取項沒有過期時間,而它命中的概率又很低,這就是在浪費快取的空間。而如果有了過期時間,且在某個快取項經常被命中的情況下,我們可以在每次命中的時候都重新整理一下它的過期時間,這樣也就保證了熱點資料會一直在快取中存在,從而保證了快取的命中率,提高了系統的效能。

設定過期時間還有一個好處,就是當資料庫跟快取出現資料不一致的情況時,這個可以作為一個最後的兜底手段。也就是說,當資料確實出現不一致的情況時,過期時間可以保證只有在出現不一致的時間點到快取過期這段時間之內,資料庫跟快取的資料是不一致的,因此也保證了資料的最終一致性。

那麼為什麼不應該追求資料強一致性呢?這個主要是個權衡的問題。資料庫跟快取,以Mysql跟Redis舉例,畢竟是兩套系統,如果要保證強一致性,勢必要引入2PC或Paxos等分散式一致性協議,或者是分散式鎖等等,這個在實現上是有難度的,而且一定會對效能有影響。而且如果真的對資料的一致性要求這麼高,那引入快取是否真的有必要呢?直接讀寫資料庫不是更簡單嗎?那究竟如何做到資料庫跟快取的資料強一致性呢?這是個比較複雜的問題,本文會在最後稍作展開。

本文主要在保證最終一致性的前提下進行方案討論。

資料庫和快取的讀寫順序

說到資料庫和快取的讀寫順序,最經典的方案就是這個所謂的Cache Aside Pattern了。其實這個方案一點也不高大上,基本上我們平時都在用,只是未必知道名字而已,下面簡單介紹一下這個方案的思路:

  1. 失效:程式先從快取中讀取資料,如果沒有命中,則從資料庫中讀取,成功之後將資料放到快取中
  2. 命中:程式先從快取中讀取資料,如果命中,則直接返回
  3. 更新:程式先更新資料庫,在刪除快取

前兩步跟資料讀取順序有關,我覺得大家對這樣的設計應該都沒有異議。讀資料的時候當然要優先從快取中讀取,讀不到當然要從資料庫中讀取,然後還要放到快取中,否則下次請求過來還得從資料庫中讀取。關鍵問題在於第三點,也就是資料更新流程,為什麼要先更新資料庫?為什麼之後要刪除快取而不是更新?這就是本文主要要討論的問題。

總共大概有四種可能的選項(你不可能把資料庫刪了吧...):

  1. 先更新快取,再更新資料庫
  2. 先更新資料庫,再更新快取
  3. 先刪除快取,再更新資料庫
  4. 先更新資料庫,再刪除快取

接下來我們分情況逐個討論一下:

先更新快取,再更新資料庫

我們都知道不管是操作資料庫還是操作快取,都有失敗的可能。如果我們先更新快取,再更新資料庫,假設更新資料庫失敗了,那資料庫中就存的是老資料。當然你可以選擇重試更新資料庫,那麼再極端點,負責更新資料庫的機器也宕機了,那麼資料庫中的資料將一直得不到更新,並且當快取失效之後,其他機器再從資料庫中讀到的資料是老資料,然後再放到快取中,這就導致先前的更新操作被丟失了,因此這麼做的隱患是很大的。

從資料持久化的角度來說,資料庫當然要比快取做的好,我們也應當以資料庫中的資料為主,所以需要更新資料的時候我們應當首先更新資料庫,而不是快取。

先更新資料庫,再更新快取

這裡主要有兩個問題,首先是併發的問題:假設執行緒A(或者機器A,道理是一樣的)和執行緒B需要更新同一個資料,A先於B但時間間隔很短,那麼就有可能會出現:

  1. 執行緒A更新了資料庫
  2. 執行緒B更新了資料庫
  3. 執行緒B更新了快取
  4. 執行緒A更新了快取

按理說執行緒B應該最後更新快取,但是可能因為網路等原因,導致執行緒B先於執行緒A對快取進行了更新,這就導致快取中的資料不是最新的。

第二個問題是,我們不確定要更新的這個快取項是否會被經常讀取,假設每次更新資料庫都會導致快取的更新,有可能資料還沒有被讀取過就已經再次更新了,這就造成了快取空間的浪費。另外,快取中的值可能是經過一系列計算的,而並不是直接跟資料庫中的資料對應的,頻繁更新快取會導致大量無效的計算,造成機器效能的浪費。

綜上所述,更新快取這一方案是不可取的,我們應當考慮刪除快取。

先刪除快取,再更新資料庫

這個方案的問題也是很明顯的,假設現在有兩個請求,一個是寫請求A,一個是讀請求B,那麼可能出現如下的執行序列:

  1. 請求A刪除快取
  2. 請求B讀取快取,發現不存在,從資料庫中讀取到舊值
  3. 請求A將新值寫入資料庫
  4. 請求B將舊值寫入快取

這樣就會導致快取中存的還是舊值,在快取過期之前都無法讀到新值。這個問題在資料庫讀寫分離的情況下會更明顯,因為主從同步需要時間,請求B獲取到的資料很可能還是舊值,那麼寫入快取中的也會是舊值。

先更新資料庫,再刪除快取

終於來到我們最常用的方案了,但是最常用並不是說就一定不會有任何問題,我們依然假設有兩個請求,請求A是查詢請求,請求B是更新請求,那麼可能會出現下述情形:

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

上述情況確實有可能出現,但是出現的概率可能不高,因為上述情形成立的條件是在讀取資料時,快取剛好失效,並且此時正好又有一個併發的寫請求。考慮到資料庫上的寫操作一般都會比讀操作要慢,(這裡指的是在寫資料庫時,資料庫一般都會上鎖,而普通的查詢語句是不會上鎖的。當然,複雜的查詢語句除外,但是這種語句的佔比不會太高)並且聯絡常見的資料庫讀寫分離的架構,可以合理認為在現實生活中,讀請求的比例要遠高於寫請求,因此我們可以得出結論。這種情況下快取中存在髒資料的可能性是不高的。

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

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

如果資料庫主從同步比較慢的話,同樣會出現資料不一致的問題。事實上就是如此,畢竟我們操作的是兩個系統,在高併發的場景下,我們很難去保證多個請求之間的執行順序,或者就算做到了,也可能會在效能上付出極大的代價。那為什麼我們還是應當採用先更新資料庫,再刪除快取這個策略呢?首先,為什麼要刪除而不是更新快取,這個在前面有分析,這裡不再贅述。那為什麼我們應當先更新資料庫呢?因為快取在資料持久化這方面往往沒有資料庫做得好,而且資料庫中的資料是不存在過期這個概念的,我們應當以資料庫中的資料為主,快取因為有著過期時間這一概念,最終一定會跟資料庫保持一致。

那如果我就是想解決上述說的這兩個問題,在不要求強一致性的情況下可以怎麼做呢?

有沒有更好的思路?

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

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

那要怎麼解決這個問題呢?首先可以考慮引入訊息佇列,OK我知道寫入訊息佇列一樣可能會失敗,但是這是建立在快取跟訊息佇列都不可用的情況下,應該說這樣的概率是不高的。引入訊息佇列之後,就由消費端負責刪除快取以及重試,可能會慢一些但是可以保證操作不會丟失。

回到上述的兩個問題中去,上述的兩個問題的核心其實都在於將舊值寫入了快取,那麼解決這個問題的辦法其實就是要將快取刪除,考慮到網路問題導致的執行失敗或執行順序的問題,這裡要進行的刪除操作應當是非同步延時操作。具體來說應該怎麼做呢?就是參考前面說的,引入訊息佇列,在刪除快取失敗的情況下,將刪除快取作為一條訊息寫入訊息佇列,然後由消費端進行慢慢的消費和重試。

那如果是讀寫分離場景呢?我們知道資料庫(以Mysql為例)主從之間的資料同步是通過binlog同步來實現的,因此這裡可以考慮訂閱binlog(可以使用canal之類的中介軟體實現),提取出要刪除的快取項,然後作為訊息寫入訊息佇列,然後再由消費端進行慢慢的消費和重試。在這種情況下,程式可以不去主動刪除快取,但如果你希望快取中儘快讀取到最新的值,也可以考慮將快取刪除,那麼就有可能出現又將舊值寫入快取,且快取被重複刪除的情況。但是一般來說這不會是個問題,首先舊值重新寫入快取,情況無非就是又退化到了程式沒有主動刪除快取的這一情況,另外,重複刪除快取保證了資料庫和快取之間不會存在長時間的資料不一致。(為什麼刪除了快取之後,還是有可能將舊值寫入快取?參見上面先更新資料庫,再刪除快取的方案下,讀寫分離場景下的執行序列)當然我個人的建議是,如果你可以忍受一段時間之內的資料不一致,那就沒必要自己再主動去刪除快取了。

要解決上述問題的核心就在於要實現非同步延時刪除這一策略,因此在這裡我們需要引入訊息佇列。如果資料庫採用讀寫分離架構,則需要考慮訂閱binlog,否則一樣可能會出現先刪除,後同步完畢的情況。

快取擊穿

可能會有同學注意到,如果採用刪除快取的方案,在高併發場景下可能會導致快取擊穿(這個跟快取穿透還有點區別),也就是大量的請求同時去查詢同一個快取,但是這個快取又剛好過期或者被刪除了,那麼所有的請求全部都會打到資料庫上,導致嚴重的效能問題。對於這個問題包括如何解決快取穿透,後面我可能會考慮單獨寫文章來闡釋一下,這裡先簡單說下解決思路,其實也就是上鎖。

當一個執行緒需要去訪問這個快取的時候,如果發現快取為空,則需要先去競爭一個鎖,如果成功則進行正常的資料庫讀取和寫入快取這一操作,然後再釋放鎖,否則就等待一段時間之後,重新嘗試讀取快取,如果還沒有資料就繼續去競爭鎖。這個是單機場景,如果有多臺機器同時去訪問同一個快取項該怎麼辦呢?如果機器數不是很多的話,這種情況一般來說也不會成為一個問題,不過這裡有個優化點,就是從資料庫讀取到資料之後,再對快取做一次判斷,如果快取中已經存在資料,就不需要再寫一遍快取了。但是如果機器數也很多的話,那麼就得考慮上分散式鎖了。此方案的問題是顯而易見的,加鎖尤其是加分散式鎖會對系統效能有重大影響,而且分散式鎖的實現非常考驗開發者的經驗和實力,在高併發場景下這一點顯得尤為重要,因此我建議各位,不到萬不得已的情況下,不要盲目上分散式鎖。

怎麼做到強一致性?

可能有同學就是要來抬槓,現有的這些方案還是不夠完美,如果我就是想要做到強一致性可以怎麼做?

上一致性協議當然是可以的,雖然成本也是非常客觀的。2PC甚至是3PC本身是存在一定程度的缺陷的,所以如果要採用這個方案,那麼在架構設計中要引入很多的容錯,回退和兜底措施。那如果是上Paxos和Raft呢?那麼你首先至少要看過這兩者的相關論文,並且調研清楚目前市面上有哪些開源方案,並做好充分的驗證,並且能夠做到出了問題自己有能力修復...對了,我還沒提到效能問題呢。

那除了一致性協議以外,有沒有其他的思路?

我們先回到"先更新資料庫,再刪除快取"這個方案本身上來,從字面上來看,這裡有兩步操作,因此在資料庫更新之前,到快取被刪除這段時間之內,讀請求讀取到的都是髒資料。如果要實現這兩者的強一致性,只能是在更新完資料庫之前,所有的讀請求都必須要被阻塞直到快取最終被刪除為止。如果是讀寫分離的場景,則要在更新完主庫之前就開始阻塞讀請求,直到主從同步完畢,且快取被刪除之後才能釋放。

這個思路其實就是一種序列化的思路,寫請求一定要在讀請求之前完成,才能保證最新的資料對所有讀請求來說是可見的。說到這裡是不是讓你想起了什麼?比如volatile,記憶體屏障,ReadWriteLock,或者是資料庫的共享鎖,排他鎖...當前場景可能不同,但是要面對的問題都是相似的。

現在回到問題本身,我們要怎麼實現這種阻塞呢?可能有同學已經發現了,我們需要的其實是一種 分散式讀寫鎖。對於寫請求來說,在更新資料庫之前,必須要先申請寫鎖,而其他執行緒或機器在讀取資料之前,必須要先申請讀鎖。讀鎖是共享的,寫鎖是排他的,即如果讀鎖存在,可以繼續申請讀鎖但無法申請寫鎖,如果寫鎖存在,則無論是讀鎖還是寫鎖都無法申請。只有實現了這種分散式讀寫鎖,才能保證寫請求在完成資料庫和快取的操作之前,讀請求不會讀取到髒資料。

注意,這裡用到的分散式讀寫鎖並沒有解決快取擊穿的問題,因為從讀請求的視角來看,如果發生了更新資料庫的情況,讀請求要麼被阻塞,要麼就是快取為空,需要從資料庫讀取資料再寫入快取。為了防止因快取失效或被刪除導致大量請求直接打到資料庫上導致資料庫崩潰,你只能考慮加鎖甚至是加分散式鎖,具體參見快取擊穿這一章節。

那麼說到分散式讀寫鎖,其實現一樣有一定的難度。如果確定要使用,我建議使用Curator提供的InterProcessReadWriteLock,或者是Redisson提供的RReadWriteLock。對分散式讀寫鎖的討論超出了本文的範圍,這裡就不做過多展開了。

這裡我只提出了我個人的想法,其他同學可能還會有自己的方案,但我相信不管是哪一種,為了要實現強一致性,系統的效能是一定要付出代價的,甚至可能會超出你引入快取所得到的效能提升。

總結

在我看來所謂的架構設計,往往是要在眾多的trade-off中選擇最適合當前場景的。其實一旦在方案中使用了快取,那往往也就意味著我們放棄了資料的強一致性,但這也意味著我們的系統在效能上能夠得到一些提升。在如何使用快取這個問題上有很多的講究,比如過期時間的合理設定,怎麼解決或規避快取穿透,擊穿甚至是雪崩的問題。後續有機會的話,我會逐步地闡釋清楚這些問題的來龍去脈,以及如何去解決比較合適。

作者簡介

呂亞東,某風控領域網際網路公司技術專家,主要關注高效能,高併發以及中介軟體底層原理和調優等領域。