Redis 與 MySQL 的數據同步

※ 好歹趕上了20世紀20年代的第一天發第一篇文章~


關於如何做Redis與MySQL的數據同步,網上有非常多的解決方案。

下面主要說一下我關於這件事的一點想法,以及簡要的實現思路。

一、大前提

1-1、場景

出技術方案,必須要有對應的業務場景。

方案具體到業務纔有意義,有的業務在秒級別上存在髒數據都沒有影響,而有的涉及到錢的業務必須數據強一致,不容絲毫妥協,那麼該上鎖就上鎖不要猶豫。

網上有太多的拋開業務場景,給一個大一統的完美方案。有一個算一個,都是耍流氓。

1-2、是否強一致

繼續上一節的話題,拋開業務執意要求數據強一致的就是自己給自己挖坑。

但是總歸有強一致的需要發生的時候。

那麼這時心裏必須清楚,強一致——換句話說就是一致性,與高可用性、性能是永遠不可兼得的。

Redis與MySQL是兩個獨立的存儲系統,我們姑且可以將其看做是一套分佈式系統。

那麼運用分佈式系統的CAP理論來理解這件事就是順理成章的。

  • C:Consistency,一致性, 數據一致更新,所有數據變動都是同步的。
  • A:Availability,可用性, 好的響應性能,每次請求都能獲取到非錯的響應——但是不保證獲取的數據爲最新數據。
  • P:Partition tolerance,分區容錯性,就是可靠性。以實際效果而言,相當於對通信的時限要求。

對於一個分佈式系統來說,P是前提條件,系統如果不能在時限內達成數據一致性,就意味着發生了分區的情況,必須就當前操作在C和A之間做出選擇。

最經典的就是用於服務治理的ZooKeeper和Eureka,ZooKeeper保證CP,而Eureka選擇了AP。

同理,在設計數據同步方案時,必須有這個意識,適當做出取捨。

1-3、套路永不過時

網上有人提出“刪緩存 → 更新數據庫 → 讀沒有命中時會讀庫寫緩存”的方案。然而這種方案沒有考慮更新和讀的併發性問題,發生下面這種併發操作時會出現髒數據。

  1. 更新操作刪緩存
  2. 讀操作沒有命中
  3. 讀操作從庫中讀到了舊數據,放入緩存
  4. 更新操作更新了數據庫

由於寫操作通常比讀要慢,所以出現這種問題的概率還是挺大的。

仔細想一下,我們要解決的問題,只是因爲有了緩存而帶來的問題。

這種事情,有必要費盡腦汁自己造輪子嗎?

現代計算機系統中,哪兒沒有緩存啊?

內存裏有緩存,各類文件系統都有緩存。那麼它們也必然面臨數據同步的問題。那就把人家的解決方案拿過來用唄。

許多基礎設計和思想都是想通的,而且這些基礎架構經歷了長期的歷練,遵從就好了。

我們完全可以把已有的各種現成的套路拿過來直接用在我們的工程實踐中。最多就是根據自己的業務需求稍加變化。

二、數據強一致的應對

這個基本沒啥好說的了,上分佈式鎖吧,寫數據的時候直接阻塞讀操作,就像是SQL的“select xxx for update”一樣。

難點在於根據實際情況權衡鎖的失效時間。失效時間太長,萬一寫操作出了什麼意外,大量的讀操作被阻塞,弄不好連線程池也崩掉;失效時間太短,寫操作還沒完成,讀操作就被開門放了進來,取到的是舊數據。

缺點當然很明顯,高併發的時候一定會出現大量的讀超時,嚴重降低了吞吐量。

同時還要考慮分佈式事務問題,比如使用“兩階段提交協議”(prepare, commit/rollback)。還需要注意Redis是不支持事務回滾的,必須手工處理。

三、套路一:Cache Aside

3-1、Cache Aside

在沒有數據強一致要求的場景下,我們需要的是一個儘可能同時兼顧一致性和可用性的方案。

優先推薦facebook提出的“Cache Aside”模式。

(原文供參考《Scaling Memcache at Facebook》

這種方案的處理邏輯很簡單:

  • 前提:緩存中的數據需要設定失效時間。
  • 命中:從緩存取數據,取到返回。
  • 失效:從緩存取數據,沒有得到,則從數據庫中取數據,成功後,將值寫入緩存,同時返回結果。
  • 更新:先把數據存到數據庫中,成功後,再讓緩存失效。

這裏最核心的思路是更新數據庫成功以後,再讓緩存失效。

之所以是讓緩存失效,而不是直接更新緩存,是爲了防止兩個併發的寫操作帶來髒數據(先發起的寫操作最後完成,覆蓋了後發起的寫操作寫入的最新數據)。

這個方案並不是100%保險的。

發生下面這種併發操作時同樣會出現髒數據。

  1. 讀操作沒有命中
  2. 讀操作從庫中讀到了舊數據
  3. 更新操作更新了數據庫
  4. 更新操作讓緩存失效
  5. 讀操作將舊數據寫入緩存

這種情況理論上存在,但是請注意其發生的概率極低,低到可以忽略不計。

爲什麼呢?因爲通常情況下寫操作慢呀(要鎖表的),上面的第3和第4步幾乎不可能插入到一個讀操作中間並且先行完成。而且還要加上一個緩存剛好失效的時機點。

退一步說,就算你人品衰到極點,真發生了這種情況,我們不是還設置了數據的失效時間麼。這一輪的失效時間一到,新數據還是會被刷到緩存裏,最終一致性是一定可以保證的。

3-2、CPU緩存的 Write Through

我們來順便看一下CPU的緩存是怎麼實現與主內存的同步的。

其中的一種回寫策略叫做“Write through”(寫通),wiki中對於寫通是這樣解釋的:

寫通是指,每當緩存接收到寫數據指令,都直接將數據寫回到內存。如果此數據地址也在緩存中,則必須同時更新緩存。

這裏寫的是“同時更新緩存”,但實際上的操作是進行了類似標記的操作,讓其他CPU核所屬的緩存失效。

所以,facebook的這個“Cache Aside”模式,與“Write through”沒有什麼本質區別。套路就是套路,一種套路大家都在用,那就必定是真理啦。

四、套路二:Write back

4-1、CPU緩存的 Write back

還是準備用CPU緩存來類比。CPU緩存的數據同步,除了上面的Write through,另一種模式就是“Write back”(寫回)。

寫回是指,僅當一個緩存塊需要被替換回內存時,纔將其內容寫入內存。如果緩存命中,則總是不用更新內存。爲了減少內存寫操作,緩存塊通常還設有一個髒位(dirty bit),用以標識該塊在被載入之後是否發生過更新。如果一個緩存塊在被置換回內存之前從未被寫入過,則可以免去回寫操作。

用人話來解釋一下,就是應用方直接讀寫緩存,不理會內存。至於緩存的數據何時以及如何落地到內存中,由另外的機制來保證。

4-2、Redis 的 Write back

回到 Redis 與 MySQL 上來。

其思路是,應用方眼裏只有Redis,讀寫都只操作Redis,把Redis當做唯一的存儲層。

寫到Redis上的數據,由額外的線程以各種異步方式寫到數據庫上進行持久化。

可以模仿緩存,給每一個數據增加類似dirty bit的標識,減少寫庫次數;同時這樣的寫庫方法,還可以天然合併對同一個數據的多次寫操作。

這種方案的一個比較明顯的問題,是數據同步延遲可能比較大,而且Redis崩掉的話,即使Redis本身有持久化機制,也有可能會丟失少量數據。

但是該方案有一個很合適的應用場景,就是秒殺。

秒殺時,OLTP系統只操作內存而不操作數據庫,那磁盤IO的瓶頸就不復存在了,快到飛起。

此時在OLAP系統上,對於數據的統計和分析延遲個幾分鐘在大多數情況下也不是什麼大事,完全可以接受。

OLTP = On-Line Transaction Processing

OLAP = On-Line Analytical Processing

當然了,壞處同樣是顯而易見的,實現邏輯過於複雜,OLTP要怎麼自己手動做事務回滾,持久化線程怎麼做優化,高併發下要考慮的各種問題等等。

另外,在這種秒殺的場景下,我們可以認爲需要緩存的業務數據都是OLTP自己寫入的,OLTP不需要從MySQL中獲取信息;那麼我們就完全可以不給Redis中的數據設置失效時間,使其永遠生效,不會發生從MySQL中讀數據再寫入Redis的操作。

萬一(啥事應該都有這個萬一吧),真的需要從數據庫拿數據,那繼續沿用最基本的套路,設置失效時間,未命中時從數據庫中直接拿數據也完全沒問題。

5、套路三:主從複製

另外一種套路呢,是模擬MySQL的主從同步。

不設置失效時間,搞一個監控MySQL的binlog的中間件,一旦監測到有數據變化,判斷一下是否需要寫入Redis,然後進行寫操作。

這種方式增加了系統複雜度(追加中間件),從實現邏輯上來說其實也沒簡化太多,個人感覺費效比不是太高。

不過也是一種挺有意思的思路,記錄一下。

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