分佈式緩存靈魂十連,你能堅持幾個?

今天無聊來撩一下分佈式緩存,希望你們喜歡~

目錄

前言

目前工作中用到的分佈式緩存技術有redismemcached兩種,緩存的目的是爲了在高併發系統中有效降低DB的壓力,但是在使用的時候可能會因爲緩存結構設計不當造成一些問題,這裏會把可能遇到的坑整理出來,方便日後查找。

一. 常用的兩種緩存技術的服務端特點

1. Memcache服務端

Memcache(下面簡稱mc)服務端是沒有集羣概念的,所有的存儲分發全部交由mc client去做,我這裏使用的是xmemcached,這個客戶端支持多種哈希策略,默認使用key與實例數取模來進行簡單的數據分片。

這種分片方式會導致一個問題,那就是新增或者減少節點後會在一瞬間導致大量key失效,最終導致緩存雪崩的發生,給DB帶來巨大壓力,所以我們的mc client啓用了xmemcached的一致性哈希算法來進行數據分片:

XMemcachedClientBuilder builder = new XMemcachedClientBuilder(AddrUtil.getAddresses(servers));
builder.setOpTimeout(opTimeout);
builder.setConnectTimeout(connectTimeout);
builder.setTranscoder(transcoder);
builder.setConnectionPoolSize(connectPoolSize);
builder.setKeyProvider(keyProvider);
builder.setSessionLocator(new KetamaMemcachedSessionLocator()); //啓用ketama一致性哈希算法進行數據分片

根據一致性哈希算法的特性,在新增或減少mc的節點只會影響較少一部分的數據。但這種模式下也意味着分配不均勻,新增的節點可能並不能及時達到均攤數據的效果,不過mc採用了虛擬節點的方式來優化原始一致性哈希算法(由ketama算法控制實現),實現了新增物理節點後也可以均攤數據的能力。

最後,mc服務端是多線程處理模式,mc一個value最大隻能存儲1M的數據,所有的k-v過期後不會自動移除,而是下次訪問時與當前時間做對比,過期時間小於當前時間則刪除,如果一個k-v產生後就沒有再次訪問了,那麼數據將會一直存在在內存中,直到觸發LRU

2. Redis服務端

redis服務端有集羣模式,key的路由交由redis服務端做處理,除此之外redis有主從配置以達到服務高可用。

redis服務端是單線程處理模式,這意味着如果有一個指令導致redis處理過慢,會阻塞其他指令的響應,所以redis禁止在生產環境使用重量級操作(例如keys,再例如緩存較大的值導致傳輸過慢)

redis服務端並沒有採用一致性哈希來做數據分片,而是採用了哈希槽的概念來做數據分片,一個redis cluster整體擁有16384個哈希槽(slot),這些哈希槽按照編號區間的不同,分佈在不同節點上,然後一個key進來,通過內部哈希算法(CRC16(key))計算出槽位置;

然後將數據存放進對應的哈希槽對應的空間,redis在新增或者減少節點時,其實就是對這些哈希槽進行重新分配,以新增節點爲例,新增節點意味着原先節點上的哈希槽區間會相對縮小,被減去的那些哈希槽裏的數據將會順延至下一個對應節點,這個過程由redis服務端協調完成,過程如下:

圖1

遷移過程是以槽爲單位,將槽內的key按批次進行遷移的(migrate)。

二. 緩存結構化選型

mc提供簡單的k-v存儲,value最大可以存儲1M的數據,多線程處理模式,不會出現因爲某次處理慢而導致其他請求排隊等待的情況,適合存儲數據的文本信息。

redis提供豐富的數據結構,服務端是單線程處理模式,雖然處理速度很快,但是如果有一次查詢出現瓶頸,那麼後續的操作將被阻塞,所以相比k-v這種可能因爲數據過大而導致網絡交互產生瓶頸的結構來說,它更適合處理一些數據結構的查詢、排序、分頁等操作,這些操作往往復雜度不高,且耗時極短,因此不太可能會阻塞redis的處理。

使用這兩種緩存服務來構建我們的緩存數據,目前提倡所有數據按照標誌性字段(例如id)組成自己的信息緩存存儲,這個一般由mc的k-v結構來完成存儲。

而redis提供了很多好用的數據結構,一般構建結構化的緩存數據都使用redis的數據結構來保存數據的基本結構,然後組裝數據時根據redis裏緩存的標誌性字段去mc裏查詢具體數據,例如一個排行榜接口的獲取:

圖2

上圖redis提供排行榜的結構存儲,排行榜裏存儲的是idscore,通過redis可以獲取到結構內所有信息的id,然後利用獲得的id可以從mc中查出詳細信息,redis在這個過程負責分頁、排序,mc則負責存儲詳細信息。

上面是比較合適的緩存做法,建議每條數據都有一個自己的基本緩存數據,這樣便於管理,而不是把一個接口的巨大結構完全緩存到mc或者redis裏,這樣劃分太粗,日積月累下來每個接口或者巨大方法都有一個緩存,key會越來越多,越來越雜

三. Redis構造大索引回源問題

Redis如果做緩存使用,始終會有過期時間存在,如果到了過期時間,使用redis構建的索引將會消失,這個時候回源,如果存在大批量的數據需要構建redis索引,就會存在回源方法過慢的問題,這裏以某個評論系統爲例;

評論系統採用有序集合作爲評論列表的索引,存儲的是評論id,用於排序的score值則按照排序維度拆分,比如發佈時間、點贊數等,這也意味着一個資源下的評論列表根據排序維度不同存在着多個redis索引列表,而具體評論內容存mc,正常情況下結構如下:

圖3

上面是正常觸發一個資源的評論區,每次觸發讀緩存,都會順帶延長一次緩存的過期時間,這樣可以保證較熱的內容不會輕易過期,但是如果一個評論區時間過長沒人訪問過,redis索引就會過期,如果一個評論區有數萬條評論數據,長時間沒人訪問,突然有人過去考古,那麼在回源構建redis索引時會很緩慢,如果沒有控制措施,還會造成下面緩存穿透的問題,從而導致這種重量級操作反覆被多個線程執行,對DB造成巨大壓力。

對於上面這種回源構建索引緩慢的問題,處理方式可以是下面這樣:

圖4

相比直接執行回源方法,這種通過消息隊列構造redis索引的方法更加適合,首先僅構建單頁或者前面幾頁的索引數據,然後通過隊列通知job(這裏可以理解爲消費者)進行完整索引構造,當然,這隻適合對一致性要求不高的場景。

四. 一致性問題

一般情況下緩存內的數據要和數據庫源數據保持一致性,這就涉及到更新DB後主動失效緩存策略(通俗叫法:清緩存),大部分會經過如下過程:

圖5

假如現在有兩個服務,服務A和服務B,現在假設服務A會觸發某個數據的寫操作,而服務B則是隻讀程序,數據被緩存在一個Cache服務內,現在假如服務A更新了一次數據庫,那麼結合上圖得出以下流程:

  1. 服務A觸發更新數據庫的操作

  2. 更新完成後刪除數據對應的緩存key

  3. 只讀服務(服務B)讀取緩存時發現緩存miss

  4. 服務B讀取數據庫源信息

  5. 寫入緩存並返回對應信息

這個過程乍一看是沒什麼問題的,但是往往多線程運轉的程序會導致意想不到的結果,現在來想象下服務A和服務B被多個線程運行着,這個時候重複上述過程,就會存在一致性問題。

1. 併發讀寫導致的一致性問題

圖6
  1. 運行着服務A的線程1首先修改數據,然後刪除緩存

  2. 運行着服務B的線程3讀緩存時發現緩存miss,開始讀取DB中的源數據,需要注意的是這次讀出來的數據是線程1修改後的那份

  3. 這個時候運行着服務A的線程2上線,開始修改數據庫,同樣的,刪除緩存,需要注意的是,這次刪除的其實是一個空緩存,沒有意義,因爲本來線程3那邊還沒有回源完成

  4. 運行着服務B的線程3將讀到的由線程1寫的那份數據回寫進Cache

上述過程完成後,最終結果就是DB裏保存的最終數據是線程2寫進去的那份,而Cache經過線程3的回源後保存的卻是線程1寫的那份數據,不一致問題出現。

2. 主從同步延時導致的一致性問題

這種情況要稍微修改下程序的流程圖,多出一個從庫:

圖7

現在讀操作走從庫,這個時候如果在主庫寫操作刪除緩存後,由於主從同步有可能稍微慢於回源流程觸發,回源時讀取從庫仍然會讀到老數據。

3. 緩存污染導致的一致性問題

每次做新需求時更新了原有的緩存結構,或去除幾個屬性,或新增幾個屬性,假如新需求是給某個緩存對象O新增一個屬性B,如果新邏輯已經在預發或者處於灰度中,就會出現生產環境回源後的緩存數據沒有B屬性的情況,而預發和灰度時,新邏輯需要使用B屬性,就會導致生產&預發緩存污染。過程大致如下:

圖8

五. 如何應對緩存一致性問題?

緩存一致性問題大致分爲以下幾個解決方案,下面一一介紹。

1. binlog+消息隊列+消費者del cache

圖9

上圖是現在常用的清緩存策略,每次表發生變動,通過mysql產生的binlog去給消息隊列發送變動消息,這裏監聽DB變動的服務由canal提供,canal可以簡單理解成一個實現了mysql通信協議的從庫,通過mysql主從配置完成binlog同步,且它只接收binlog,通過這種機制,就可以很自然的監聽數據庫表數據變動了,可以保證每次數據庫發生的變動,都會被順序發往消費者去清除對應的緩存key

2. 從庫binlog+消息隊列+消費者del cache

上面的過程能保證寫庫時清緩存的順序問題,看似並沒有什麼問題,但是生產環境往往存在主從分離的情況,也就是說上面的圖中如果回源時讀的是從庫,那上面的過程仍然是存在一致性問題的:

圖10

從庫延遲導致的髒讀問題,如何解決這類問題呢?

只需要將canal監聽的數據庫設置成從庫即可,保證在canal推送過來消息時,所有的從庫和主庫完全一致,不過這隻針對一主一從的情況,如果一主多從,且回源讀取的從庫有多個,那麼上述也是存在一定的風險的(一主多從需要訂閱每個從節點的binlog,找出最後發過來的那個節點,然後清緩存,確保所有的從節點全部和主節點一致)。

不過,正常情況下,從庫binlog的同步速度都要比canal發消息快,因爲canal要接收binlog,然後組裝數據變動實體(這一步是有額外開銷的),然後通過消息隊列推送給各消費者(這一步也是有開銷的),所以即便是訂閱的master庫的表變更,出問題的概率也極小。

3. 更新後key升級

針對上面的一致性問題(緩存污染),修改某個緩存結構可能導致在預發或者灰度中狀態時和實際生產環境的緩存相互污染,這個時候建議每次更新結構時都進行一次key升級(比如在原有的key名稱基礎上加上_v2的後綴)。

⚡⚡⚡binlog是否真的是準確無誤的呢?⚡⚡⚡

圖11

並不是,比如上面的情況:

  1. 首先線程1走到服務A,寫DB,發binlog刪除緩存

  2. 然後線程3運行的服務B這時cache miss,然後讀取DB回源(這時讀到的數據是線程1寫入的那份數據)

  3. 此時線程2再次觸發服務ADB,同樣發送binlog刪除緩存

  4. 最後線程3把讀到的數據寫入cache,最終導致DB裏存儲的是線程2寫入的數據,但是cache裏存儲的卻是線程1寫入的數據,不一致達成

這種情況比較難以觸發,因爲極少會出現線程3那裏寫cache的動作會晚於第二次binlog發送的,除非在回源時做了別的帶有阻塞性質的操作;

所以根據現有的策略,沒有特別完美的解決方案,只能儘可能保證一致性,但由於實際生產環境,處於多線程併發讀寫的環境,即便有binlog做最終的保證,也不能保證最後回源方法寫緩存那裏的順序性。除非回源全部交由binlog消費者來做,不過這本就不太現實,這樣等於說服務B沒有回源方法了。

針對這個問題,出現概率最大的就是那種寫併發概率很大的情況,這個時候伴隨而來的還有命中率問題。

六. 命中率問題

通過前面的流程,拋開特殊因素,已經解決了一致性的問題,但隨着清緩存而來的另一個問題就是命中率問題。

比如一個數據變更過於頻繁,以至於產生過多的binlog消息,這個時候每次都會觸發消費者的清緩存操作,這樣的話緩存的命中率會瞬間下降,導致大部分用戶訪問直接訪問DB;

而且這種頻繁變更的數據還會加大問題出現的概率,所以針對這種頻繁變更的數據,不再刪除緩存key,而是直接在binlog消費者那裏直接回源更新緩存,這樣即便表頻繁變更,用戶訪問時每次都是消費者更新好的那份緩存數據,只是這時候消費者要嚴格按照消息順序來處理;

否則也會有寫髒的危險,比如開兩個線程同時消費binlog消息,線程1接收到了第一次數據變更的binlog,而線程2接收到了第二次數據變更的binlog,這時線程1讀出數據(舊數據),線程2讀出數據(新數據)更新緩存,然後線程1再執行更新,這時緩存又會被寫髒;

所以爲了保證消費順序,必須是單線程處理,如果想要啓用多線程均攤壓力,可以利用keyid等標識性字段做任務分組,這樣同一個idbinlog消息始終會被同一個線程執行。

七. 緩存穿透

1. 什麼是緩存穿透?

正常情況下用戶請求一個數據時會攜帶標記性的參數(比如id),而我們的緩存key則會以這些標記性的參數來劃分不同的cache value,然後我們根據這些參數去查緩存,查到就返回,否則回源,然後寫入cache服務後返回。

這個過程看起來也沒什麼問題,但是某些情況下,根據帶進來的參數,在數據庫裏並不能找到對應的信息,這個時候每次帶有這種參數的請求,都會走到數據庫回源,這種現象叫做緩存穿透,比較典型的出現這種問題的情況有:

  1. 惡意攻擊或者爬蟲,攜帶數據庫裏本就不存在的數據做參數回源

  2. 公司內部別的業務方調用我方的接口時,由於溝通不當或其他原因導致的參數大量誤傳

  3. 客戶端bug導致的參數大量誤傳

2. 如何解決緩存穿透問題?

目前我們提倡的做法是回源查不到信息時直接緩存空數據(注意:空數據緩存的過期時間要儘可能小,防止無意義內容過多佔用Cache內存),這樣即便是有參數誤傳、惡意攻擊等情況,也不會每次都打進DB。

但是目前這種做法仍然存在被攻擊的風險,如果惡意攻擊時攜帶少量參數還好,這樣不存在的空數據緩存僅僅會佔用少量內存,但是如果攻擊者使用大量穿透攻擊,攜帶的參數千奇百怪,這樣就會產生大量無意義的空對象緩存,使得我們的緩存服務器內存暴增。

這個時候就需要服務端來進行簡單的控制:按照業務內自己的估算,合理的id大致在什麼範圍內,比如按照用戶id做標記的緩存,就直接在獲取緩存前判斷所傳用戶id參數是否超過了某個閾值,超過直接返回空。(比如用戶總量才幾十萬或者上百萬,結果用戶id傳過來個幾千萬甚至幾億明顯不合理的情況)

八. 緩存擊穿

1. 什麼是緩存擊穿?

緩存擊穿是指在一個key失效後,大量請求打進回源方法,多線程併發回源的問題。

這種情況在少量訪問時不能算作一個問題,但是當一個熱點key失效後,就會發生回源時湧進過多流量,全部打在DB上,這樣會導致DB在這一時刻壓力劇增。

2. 如何解決緩存擊穿?

  1. 回源方法內追加互斥鎖:這個可以避免多次回源,但是n臺實例羣模式下,仍然會存在實例併發回源的情況,這個量級相比之前大量打進,已經大量降低了。

  2. 回源方法內追加分佈式鎖:這個可以完全避免上面多實例下併發回源的情況,但是缺點也很明顯,那就是又引入了一個新的服務,這意味着發生異常的風險會加大。

九. 緩存雪崩

1. 什麼是緩存雪崩?

緩存雪崩是指緩存數據某一時刻出現大量失效的情況,所有請求全部打進DB,導致短期內DB負載暴增的問題,一般來說造成緩存雪崩有以下幾種情況:

  1. 緩存服務擴縮容:這個是由緩存的數據分片策略的而導致的,如果採用簡單的取模運算進行數據分片,那麼服務端擴縮容就會導致雪崩的發生。

  2. 緩存服務宕機:某一時刻緩存服務器出現大量宕機的情況,導致緩存服務不可用,根據現有的實現,是直接打到DB上的。

2. 如何避免雪崩的發生?

  1. 緩存服務端的高可用配置:上面mcredis的分片策略已經說過,所以擴縮容帶來的雪崩機率很小,其次redis服務實現了高可用配置:啓用cluster模式,一主一從配置。由於對一致性哈希算法的優化,mc宕機、擴縮容對整體影響不大,所以緩存服務器服務端本身目前是可以保證良好的可用性的,儘可能的避免了雪崩的發生(除非大規模宕機,概率很小)。

  2. 數據分片策略調整:調整緩存服務器的分片策略,比如上面第一部分所講的,給mc開啓一致性哈希算法的分片策略,防止緩存服務端擴縮容後緩存數據大量不可用。

  3. 回源限流:如果緩存服務真的掛掉了,請求全打在DB上,以至於超出了DB所能承受之重,這個時候建議回源時進行整體限流,被限到的請求紫自動走降級邏輯,或者直接報錯。

十. 熱key問題

1. 什麼是熱key問題?

瞭解了緩存服務端的實現,可以知道某一個確定的key始終會落到某一臺服務器上,如果某個key在生產環境被大量訪問,就導致了某個緩存服務節點流量暴增,等訪問超出單節點負載,就可能會出現單點故障,單點故障後轉移該key的數據到其他節點,單點問題依舊存在,則可能繼續會讓被轉移到的節點也出現故障,最終影響整個緩存服務集羣。

2. 如何解決熱key問題?

  1. 多緩存副本:預先感知到發生熱點訪問的key,生成多個副本key,這樣可以保證熱點key會被多個緩存服務器持有,然後回源方法公用一個,請求時按照一定的算法隨機訪問某個副本key。

圖12
  1. 本地緩存:針對熱點key外面包一層短存活期的本地緩存,用於緩衝熱點服務器的壓力。

推薦閱讀
分享基於 Spring Cloud +OAuth2 的權限管理系統

鏈家程序員刪公司9TB 數據 被判7年

工作10年後,再看String s = new String("xyz") 創建了幾個對象?

SpringBoot集成WebSocket,實現後臺向前端推送信息

SpringBoot 配置 ELK 環境

給代碼寫註釋時有哪些講究?

程序員該如何把 Windows 系統打造的跟 Mac 一樣牛逼?

基於 SpringBoot,來實現MySQL讀寫分離技術


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