目前工做中用到的分佈式緩存技術有redis
和memcached
兩種,緩存的目的是爲了在高併發系統中有效下降DB
的壓力,可是在使用的時候可能會由於緩存結構設計不當形成一些問題,這裏會把可能遇到的坑整理出來,方便往後查找。java
Memcache
(下面簡稱mc)服務端是沒有集羣概念的,全部的存儲分發所有交由mc client
去作,我這裏使用的是xmemcached
,這個客戶端支持多種哈希策略,默認使用key
與實例數取模來進行簡單的數據分片。mysql
這種分片方式會致使一個問題,那就是新增或者減小節點後會在一瞬間致使大量key失效,最終致使緩存雪崩的發生,給DB帶來巨大壓力,因此咱們的mc client
啓用了xmemcached
的一致性哈希算法來進行數據分片:redis
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
。sql
redis服務端有集羣模式,key
的路由交由redis服務端作處理,除此以外redis有主從配置以達到服務高可用。數據庫
redis服務端是單線程處理模式,這意味着若是有一個指令致使redis處理過慢,會阻塞其餘指令的響應,因此redis禁止在生產環境使用重量級操做(例如keys
,再例如緩存較大的值致使傳輸過慢)緩存
redis服務端並無採用一致性哈希來作數據分片,而是採用了哈希槽的概念來作數據分片,一個redis cluster
總體擁有16384
個哈希槽(slot
),這些哈希槽按照編號區間的不一樣,分佈在不一樣節點上,而後一個key
進來,經過內部哈希算法(CRC16(key))計算出槽位置;服務器
而後將數據存放進對應的哈希槽對應的空間,redis在新增或者減小節點時,其實就是對這些哈希槽進行從新分配,以新增節點爲例,新增節點意味着原先節點上的哈希槽區間會相對縮小,被減去的那些哈希槽裏的數據將會順延至下一個對應節點,這個過程由redis服務端協調完成,過程以下:markdown
遷移過程是以槽爲單位,將槽內的
key
按批次進行遷移的(migrate)。網絡
mc提供簡單的k-v
存儲,value最大能夠存儲1M的數據,多線程處理模式,不會出現由於某次處理慢而致使其餘請求排隊等待的狀況,適合存儲數據的文本信息。
redis提供豐富的數據結構,服務端是單線程處理模式,雖然處理速度很快,可是若是有一次查詢出現瓶頸,那麼後續的操做將被阻塞,因此相比k-v
這種可能由於數據過大而致使網絡交互產生瓶頸的結構來講,它更適合處理一些數據結構的查詢、排序、分頁等操做,這些操做每每複雜度不高,且耗時極短,所以不太可能會阻塞redis的處理。
使用這兩種緩存服務來構建咱們的緩存數據,目前提倡全部數據按照標誌性字段(例如id)組成本身的信息緩存存儲,這個通常由mc的k-v結構來完成存儲。
而redis提供了不少好用的數據結構,通常構建結構化的緩存數據都使用redis的數據結構來保存數據的基本結構,而後組裝數據時根據redis裏緩存的標誌性字段去mc裏查詢具體數據,例如一個排行榜接口的獲取:
上圖redis提供排行榜的結構存儲,排行榜裏存儲的是id
和score
,經過redis
能夠獲取到結構內全部信息的id
,而後利用得到的id
能夠從mc
中查出詳細信息,redis
在這個過程負責分頁、排序,mc
則負責存儲詳細信息。
上面是比較合適的緩存作法,建議每條數據都有一個本身的基本緩存數據,這樣便於管理,而不是把一個接口的巨大結構徹底緩存到mc或者redis裏,這樣劃分太粗,日積月累下來每一個接口或者巨大方法都有一個緩存,key會愈來愈多,愈來愈雜。
Redis若是作緩存使用,始終會有過時時間存在,若是到了過時時間,使用redis構建的索引將會消失,這個時候回源,若是存在大批量的數據須要構建redis索引,就會存在回源方法過慢的問題,這裏以某個評論系統爲例;
評論系統採用有序集合做爲評論列表的索引,存儲的是評論id
,用於排序的score
值則按照排序維度拆分,好比發佈時間、點贊數等,這也意味着一個資源下的評論列表根據排序維度不一樣存在着多個redis索引列表,而具體評論內容存mc,正常狀況下結構以下:
上面是正常觸發一個資源的評論區,每次觸發讀緩存,都會順帶延長一次緩存的過時時間,這樣能夠保證較熱的內容不會輕易過時,可是若是一個評論區時間過長沒人訪問過,redis索引就會過時,若是一個評論區有數萬條評論數據,長時間沒人訪問,忽然有人過去考古,那麼在回源構建redis索引時會很緩慢,若是沒有控制措施,還會形成下面緩存穿透的問題,從而致使這種重量級操做反覆被多個線程執行,對DB形成巨大壓力。
對於上面這種回源構建索引緩慢的問題,處理方式能夠是下面這樣:
相比直接執行回源方法,這種經過消息隊列構造redis索引的方法更加適合,首先僅構建單頁或者前面幾頁的索引數據,而後經過隊列通知job(這裏能夠理解爲消費者)進行完整索引構造,固然,這隻適合對一致性要求不高的場景。
通常狀況下緩存內的數據要和數據庫源數據保持一致性,這就涉及到更新DB後主動失效緩存策略(通俗叫法:清緩存),大部分會通過以下過程:
假如如今有兩個服務,服務A
和服務B
,如今假設服務A會觸發某個數據的寫操做,而服務B
則是隻讀程序,數據被緩存在一個Cache
服務內,如今假如服務A
更新了一次數據庫,那麼結合上圖得出如下流程:
服務A觸發更新數據庫的操做
更新完成後刪除數據對應的緩存key
只讀服務(服務B)讀取緩存時發現緩存miss
服務B
讀取數據庫源信息
寫入緩存並返回對應信息
這個過程乍一看是沒什麼問題的,可是每每多線程運轉的程序會致使意想不到的結果,如今來想象下服務A和服務B被多個線程運行着,這個時候重複上述過程,就會存在一致性問題。
運行着服務A
的線程1首先修改數據,而後刪除緩存
運行着服務B
的線程3讀緩存時發現緩存miss
,開始讀取DB
中的源數據,須要注意的是此次讀出來的數據是線程1修改後的那份
這個時候運行着服務A
的線程2上線,開始修改數據庫,一樣的,刪除緩存,須要注意的是,此次刪除的實際上是一個空緩存,沒有意義,由於原本線程3那邊尚未回源完成
運行着服務B
的線程3將讀到的由線程1寫的那份數據回寫進Cache
上述過程完成後,最終結果就是DB
裏保存的最終數據是線程2寫進去的那份,而Cache
通過線程3的回源後保存的倒是線程1寫的那份數據,不一致問題出現。
這種狀況要稍微修改下程序的流程圖,多出一個從庫:
如今讀操做走從庫,這個時候若是在主庫寫操做刪除緩存後,因爲主從同步有可能稍微慢於回源流程觸發,回源時讀取從庫仍然會讀到老數據。
每次作新需求時更新了原有的緩存結構,或去除幾個屬性,或新增幾個屬性,假如新需求是給某個緩存對象O
新增一個屬性B
,若是新邏輯已經在預發或者處於灰度中,就會出現生產環境回源後的緩存數據沒有B
屬性的狀況,而預發和灰度時,新邏輯須要使用B
屬性,就會致使生產&預發緩存污染。過程大體以下:
緩存一致性問題大體分爲如下幾個解決方案,下面一一介紹。
上圖是如今經常使用的清緩存策略,每次表發生變更,經過mysql產生的binlog
去給消息隊列發送變更消息,這裏監聽DB
變更的服務由canal提供,canal
能夠簡單理解成一個實現了mysql通訊協議的從庫,經過mysql主從配置完成binlog
同步,且它只接收binlog,經過這種機制,就能夠很天然的監聽數據庫表數據變更了,能夠保證每次數據庫發生的變更,都會被順序發往消費者去清除對應的緩存key
。
上面的過程能保證寫庫時清緩存的順序問題,看似並無什麼問題,可是生產環境每每存在主從分離的狀況,也就是說上面的圖中若是回源時讀的是從庫,那上面的過程仍然是存在一致性問題的:
從庫延遲致使的髒讀問題,如何解決這類問題呢?
只須要將canal
監聽的數據庫設置成從庫便可,保證在canal
推送過來消息時,全部的從庫和主庫徹底一致,不過這隻針對一主一從的狀況,若是一主多從,且回源讀取的從庫有多個,那麼上述也是存在必定的風險的(一主多從須要訂閱每一個從節點的binlog
,找出最後發過來的那個節點,而後清緩存,確保全部的從節點所有和主節點一致)。
不過,正常狀況下,從庫binlog
的同步速度都要比canal
發消息快,由於canal
要接收binlog
,而後組裝數據變更實體(這一步是有額外開銷的),而後經過消息隊列推送給各消費者(這一步也是有開銷的),因此即使是訂閱的master
庫的表變動,出問題的機率也極小。
針對上面的一致性問題(緩存污染),修改某個緩存結構可能致使在預發或者灰度中狀態時和實際生產環境的緩存相互污染,這個時候建議每次更新結構時都進行一次key升級(好比在原有的key名稱基礎上加上_v2的後綴)。
⚡⚡⚡binlog是否真的是準確無誤的呢?⚡⚡⚡
並非,好比上面的狀況:
首先線程1
走到服務A
,寫DB
,發binlog
刪除緩存
而後線程3
運行的服務B這時cache miss
,而後讀取DB
回源(這時讀到的數據是線程1寫入的那份數據)
此時線程2
再次觸發服務A
寫DB
,一樣發送binlog
刪除緩存
最後線程3
把讀到的數據寫入cache
,最終致使DB裏存儲的是線程2
寫入的數據,可是cache
裏存儲的倒是線程1
寫入的數據,不一致達成
這種狀況比較難以觸發,由於極少會出現線程3
那裏寫cache
的動做會晚於第二次binlog
發送的,除非在回源時作了別的帶有阻塞性質的操做;
因此根據現有的策略,沒有特別完美的解決方案,只能儘量保證一致性,但因爲實際生產環境,處於多線程併發讀寫的環境,即使有binlog
作最終的保證,也不能保證最後回源方法寫緩存那裏的順序性。除非回源所有交由binlog
消費者來作,不過這本就不太現實,這樣等於說服務B沒有回源方法了。
針對這個問題,出現機率最大的就是那種寫併發機率很大的狀況,這個時候伴隨而來的還有命中率問題。
經過前面的流程,拋開特殊因素,已經解決了一致性的問題,但隨着清緩存而來的另外一個問題就是命中率問題。
好比一個數據變動過於頻繁,以致於產生過多的binlog
消息,這個時候每次都會觸發消費者的清緩存操做,這樣的話緩存的命中率會瞬間降低,致使大部分用戶訪問直接訪問DB;
並且這種頻繁變動的數據還會加大問題①
出現的機率,因此針對這種頻繁變動的數據,再也不刪除緩存key
,而是直接在binlog
消費者那裏直接回源更新緩存,這樣即使表頻繁變動,用戶訪問時每次都是消費者更新好的那份緩存數據,只是這時候消費者要嚴格按照消息順序來處理;
不然也會有寫髒的危險,好比開兩個線程同時消費binlog消息,線程1接收到了第一次數據變動的binlog,而線程2接收到了第二次數據變動的binlog,這時線程1讀出數據(舊數據),線程2讀出數據(新數據)更新緩存,而後線程1再執行更新,這時緩存又會被寫髒;
因此爲了保證消費順序,必須是單線程處理,若是想要啓用多線程均攤壓力,能夠利用key
、id
等標識性字段作任務分組,這樣同一個id
的binlog
消息始終會被同一個線程執行。
正常狀況下用戶請求一個數據時會攜帶標記性的參數(好比id),而咱們的緩存key則會以這些標記性的參數來劃分不一樣的cache value,而後咱們根據這些參數去查緩存,查到就返回,不然回源,而後寫入cache服務後返回。
這個過程看起來也沒什麼問題,可是某些狀況下,根據帶進來的參數,在數據庫裏並不能找到對應的信息,這個時候每次帶有這種參數的請求,都會走到數據庫回源,這種現象叫作緩存穿透,比較典型的出現這種問題的狀況有:
惡意攻擊或者爬蟲,攜帶數據庫裏本就不存在的數據作參數回源
公司內部別的業務方調用我方的接口時,因爲溝通不當或其餘緣由致使的參數大量誤傳
客戶端bug致使的參數大量誤傳
目前咱們提倡的作法是回源查不到信息時直接緩存空數據(注意:空數據緩存的過時時間要儘量小,防止無心義內容過多佔用Cache內存),這樣即使是有參數誤傳、惡意攻擊等狀況,也不會每次都打進DB。
可是目前這種作法仍然存在被攻擊的風險,若是惡意攻擊時攜帶少許參數還好,這樣不存在的空數據緩存僅僅會佔用少許內存,可是若是攻擊者使用大量穿透攻擊,攜帶的參數千奇百怪,這樣就會產生大量無心義的空對象緩存,使得咱們的緩存服務器內存暴增。
這個時候就須要服務端來進行簡單的控制:按照業務內本身的估算,合理的id
大體在什麼範圍內,好比按照用戶id作標記的緩存,就直接在獲取緩存前判斷所傳用戶id參數是否超過了某個閾值,超過直接返回空。(好比用戶總量才幾十萬或者上百萬,結果用戶id傳過來個幾千萬甚至幾億明顯不合理的狀況)
緩存擊穿是指在一個key
失效後,大量請求打進回源方法,多線程併發回源的問題。
這種狀況在少許訪問時不能算做一個問題,可是當一個熱點key
失效後,就會發生回源時涌進過多流量,所有打在DB上,這樣會致使DB在這一時刻壓力劇增。
回源方法內追加互斥鎖:這個能夠避免屢次回源,可是n臺實例羣模式下,仍然會存在實例併發回源的狀況,這個量級相比以前大量打進,已經大量下降了。
回源方法內追加分佈式鎖:這個能夠徹底避免上面多實例下併發回源的狀況,可是缺點也很明顯,那就是又引入了一個新的服務,這意味着發生異常的風險會加大。
緩存雪崩是指緩存數據某一時刻出現大量失效的狀況,全部請求所有打進DB,致使短時間內DB負載暴增的問題,通常來講形成緩存雪崩有如下幾種狀況:
緩存服務擴縮容:這個是由緩存的數據分片策略的而致使的,若是採用簡單的取模運算進行數據分片,那麼服務端擴縮容就會致使雪崩的發生。
緩存服務宕機:某一時刻緩存服務器出現大量宕機的狀況,致使緩存服務不可用,根據現有的實現,是直接打到DB上的。
緩存服務端的高可用配置:上面mc
和redis
的分片策略已經說過,因此擴縮容帶來的雪崩概率很小,其次redis服務實現了高可用配置:啓用cluster
模式,一主一從配置。因爲對一致性哈希算法的優化,mc
宕機、擴縮容對總體影響不大,因此緩存服務器服務端自己目前是能夠保證良好的可用性的,儘量的避免了雪崩的發生(除非大規模宕機,機率很小)。
數據分片策略調整:調整緩存服務器的分片策略,好比上面第一部分所講的,給mc開啓一致性哈希算法的分片策略,防止緩存服務端擴縮容後緩存數據大量不可用。
回源限流:若是緩存服務真的掛掉了,請求全打在DB
上,以致於超出了DB
所能承受之重,這個時候建議回源時進行總體限流,被限到的請求紫自動走降級邏輯,或者直接報錯。
瞭解了緩存服務端的實現,能夠知道某一個肯定的key始終會落到某一臺服務器上,若是某個key在生產環境被大量訪問,就致使了某個緩存服務節點流量暴增,等訪問超出單節點負載,就可能會出現單點故障,單點故障後轉移該key的數據到其餘節點,單點問題依舊存在,則可能繼續會讓被轉移到的節點也出現故障,最終影響整個緩存服務集羣。
key
,生成多個副本key
,這樣能夠保證熱點key會被多個緩存服務器持有,而後回源方法公用一個,請求時按照必定的算法隨機訪問某個副本key。若是以爲文章不錯,點個贊分享轉發下,謝謝支持!!!