目前,在工做中用到的分佈式緩存技術主要是redis和memcached兩種
緩存的目的是爲了在高併發系統中有效的下降DB數據庫的壓力mysql
memcache服務器是沒有集羣概念的。全部的存儲分發所有交給memcache client去作,這裏使用的是xmemcached,這個客戶端支持多種哈希策略,默認使用key與實例取模來進行簡單的數據分片。redis
這種分片方式會致使一個問題,那就是新增或者減小節點會在一瞬間致使大量的key失效,最終致使緩存雪崩的發生,給DB數據庫帶來巨大的壓力算法
因此最好使用memcache client使用xmemcached的一致性哈希算法,來進行數據分片,配置文件以下:sql
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一致性哈希算法進行數據分片
根據一致性哈希算法的特性,在新增或減小memcache的節點只會影響較少一部分的數據。可是這種模式下也意味着分配不均勻,新增的節點可能並不能及時達到均攤數據的效果,不過,memcache採用了虛擬節點的方式來優化原始一致性哈希算法(由ketama算法控制實現),實現新增物理節點後,也能夠均攤數據的能力,成功解決節點新增帶來的問題數據庫
最後,memcache服務器是多線程處理模式
memcache一個value最大隻能存儲1M的數據
key-value存在一個過時時間,也存在一個當前時間(當前緩存的訪問時間與距離上一次訪問時間),全部的key-value過時後不會自動移除,而是下次訪問時與當前時間作對比,過時時間小於當前時間則刪除,若是一個key-value產生後就沒有再次訪問了,那麼該數據將會一直存在於內存中,直到觸發LRU
緩存
redis服務器有集羣模式,key的路由交給redis服務器作處理,除此以外,redis還有主從配置來達到服務器的高可用 redis服務器是單線程處理模式,這也就意味着若是有一個指令致使redis處理過慢,就會阻塞其餘指令的響應,因此redis禁止在生產中使用重量級操做(例如:緩存較大的key-value值致使傳輸過慢) redis服務器並無採用一致性哈希來作數據分片,而是採用了哈希槽的概念來作數據分片,一個redis cluster集羣擁有0-16383,一共16384個槽位(slot),這些哈希槽按照編號區間的不一樣,分佈在不一樣的節點上 假如一個key進來,經過內部哈希算法(CRC16),計算出槽的位置,再把value存進去,存取過程相同 redis在新增節點時,其實就是對這些哈希槽進行從新平均分配,新增節點也就意味着原先節點上的哈希槽的數量會變少,這些減掉的哈希槽被轉移到這個新增的節點上,以此來實現槽位的平均分配,過程以下:
memcache提供簡單的key-value存儲,value最大能夠存儲1M的數據,多線程處理模式,不會出現某個指令處理過慢,而致使其餘請求排隊的狀況,適合存儲數據的文本信息 redis提供豐富的數據結構,服務器是單線程處理模式,雖然處理速度很快,可是若是有一次查詢出現瓶頸,那麼後續的操做將會被阻塞,因此相比key-value這種由於數據過大而致使網絡交互產生瓶頸的結構來講,他更適合處理一些數據結構的查詢、排序、分頁等操做,而且這些操做每每複雜度不高,且耗時極短,所以不太可能會阻塞redis的處理 使用這兩種緩存技術來構建咱們的緩存數據,目前提倡全部數據按照標誌性字段(例如id)組成本身的信息緩存存儲,這個通常由memcache的key-value結構來完成存儲 而redis提供了不少好用的數據結構,通常構建結構化的緩存數據都使用redis來構建保存數據的基本結構,而後組裝數據時根據redis裏緩存的標誌性字段去memcache裏查詢具體數據,例如一個排行榜接口的獲取:
上圖中redis提供排行榜的就夠存儲,排行榜裏存儲的是id和score,經過redis能夠獲取結構的id(與名字一一對應),而後利用得到的id能夠從memcache中查出詳細信息(score),而後再交給redis作最後的數據處理(排序) 上圖是通常的緩存的作法,建議每條數據都要有結構存儲服務器和數據存儲服務器,這樣便於數據的處理與維護,而不是把一個接口的大量數據直接緩存到memcache或者redis裏,這樣粗糙的劃分,日積月累下來每一個數據都有一個緩存,最終致使key愈來愈多,愈來愈複雜,不便於維護
redis若是做緩存使用,key始終會有過時時間的存在,若是到了過時時間,使用redis構建的索引將會消失,這個時候回源的話,若是存在大批量的數據須要構建redis索引,就會存在回源方法過慢的問題,下面以某個評論系統爲例: 評論系統採用有序集合做爲評論列表的索引,存儲的是評論id,用於排序的score值(點贊數),若是按照排序維度拆分,好比發佈時間、點贊數等,那麼一個資源下的評論列表根據排序維度的不一樣,存在多個redis索引列表,而具體評論內容存在memcache,緩存結構以下:
上圖能夠看到,當咱們訪問一個資源的評論區的時候,每次觸發讀緩存都會順帶延長一次緩存的過時時間,這樣能夠保證較熱的緩存內容不會輕易的過時,可是若是一個評論區時間過長沒人去訪問,redis索引就會過時,若是一個評論區有上萬條評論數據長時間沒有人訪問,忽然有人去考古,那麼在回源構建redis索引的時候就會很慢,若是沒有控制措施,還會形成下面緩存穿透的問題,從而致使這種重量級操做反覆被多個線程執行,對DB形成巨大的壓力
對於上面這種回源構建索引緩慢的問題,處理方式以下:
相比直接執行回源方法,這種經過消息隊列構造redis索引的方法更加適合,首先僅構建單頁或者前面幾頁的索引數據,而後經過隊列通知job(這裏能夠理解爲消費者),進行完整索引構造,固然,這隻適合對緩存一致性要求不高的場景bash
通常狀況下,緩存內的數據要和數據庫保持一致性,這就涉及到更新DB後,緩存數據的主動失效策略(通俗的說法是清緩存),大部分會通過以下過程:
假如如今有兩個服務,服務A和服務B,如今假設服務A會觸發某個數據的寫操做,而服務B則是隻讀程序,數據被緩存在一個cache服務內,如今假設服務A更新了一次數據庫,那麼結合上圖得出如下流程: 1.服務A觸發更新數據庫的操做 2.更新操做完成後,刪除數據對應的緩存key 3.只讀服務B讀取緩存時,發現這個緩存miss 4.服務B讀取數據庫源信息 5.寫入服務B的緩存,並返回對應的信息 這個過程乍一看沒什麼問題,可是多線程運轉的程序每每會致使意想不到的後果,如今想象一下服務A和服務B同時被多個線程運行着,這個時候重複上述過程的話,就會出現數據一致性的問題
1.運行着服務A的線程1首先修改數據,而後刪除緩存
2.運行着服務B的線程3緩存時發現miss,開始讀取DB中的源數據,須要注意的是此次讀出來的數據是線程1修改後的那份
3.這個時候運行着服務A的線程2開始運行,開始修改數據庫,一樣的刪除緩存,須要注意的是,此次刪除的實際上是一個空緩存,沒有意義,由於原本線程3那邊尚未回源完成
4.運行着服務B的線程3將讀到的由線程1寫的那份數據寫進cache服務器
上述過程完成後,最終的結果就是DB裏保存的最終數據是線程2寫進去的那份,而cache通過線程3的回源後,保存的倒是線程1寫的那份數據,數據緩存不一致的問題出現
流程圖以下:
如今數據庫讀操做走從庫,這個時候若是在主庫寫操做刪除緩存後,因爲主從同步有可能稍微慢於回源流程,致使讀取從庫時仍然會讀到老數據,並把該數據的緩存從新寫入cache
數據修改更新了原有的緩存結構,或去除幾個屬性,或新增幾個屬性,假如新需求是給某個緩存對象O新增一個屬性B,若是新邏輯已經在預發佈或者處於灰度中,就會出現生產環境回源後的緩存數據沒有B屬性的狀況,而預發佈和灰度發佈時,新邏輯須要使用B屬性,就會致使生產環境和預發佈環境的緩存污染問題,過程大體以下:
緩存一致性問題大體分爲如下幾個解決方案,下面一一介紹
上圖是如今經常使用的清除緩存策略,每次表發生變更,經過mysql產生的binlog去給消息隊列發送變更消息,這裏監聽DB變更的服務由cache提供,canal能夠簡單理解成一個實現了mysql通訊協議的從庫,經過mysql主從配置完成binlog同步,切只接受binlog,經過這種機制,就能夠很天然的監聽數控的數據變更了,能夠保證每次數據庫發生的變更,都會被順序發往消費者去清除對應的緩存key
上面的過程能保證寫庫時清緩存的順序問題,看似並無什麼問題,可是生產環境每每存在主從分離的狀況,也就是說上圖中若是回源時讀的是從庫,那上面的過程仍然是存在一致性問題的
從庫延遲致使的髒讀問題,如何解決這類問題呢? 只須要將canal監聽的數據庫設置成從庫便可,保證在canal推送過來消息時,全部的從庫和主庫徹底一致,不過這隻針對一主一從的狀況,若是一主多從,且回源讀取的從庫有多個,那麼上述也是存在必定的風險的(一主多從須要訂閱每一個從節點的binlog,找出最後發過來的那個節點,而後清緩存,確保全部的從節點所有和主節點一致)。 不過,正常狀況下,從庫binlog的同步速度都要比canal發消息快,由於canal要接收binlog,而後組裝數據變更實體(這一步是有額外開銷的),而後經過消息隊列推送給各消費者(這一步也是有開銷的),因此即使是訂閱的master庫的表變動,出問題的機率也極小
針對上面的一致性問題(緩存污染),修改某個緩存結構可能致使在預發或者灰度中狀態時和實際生產環境的緩存相互污染,這個時候建議每次更新結構時都進行一次key升級(好比在原有的key名稱基礎上加上_v2的後綴)。 binlog是否真的是準確無誤的呢?
並非,好比上面的狀況: 1.首先線程1走到服務A,寫DB,發binlog刪除緩存 2.而後線程3運行的服務B這時cache miss,而後讀取DB回源(這時讀到的數據是線程1寫入的那份數據) 3.此時線程2再次觸發服務A寫DB,一樣發送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再執行更新,這時緩存又會被寫髒; 因此爲了保證消費順序,必須是單線程處理,若是想要啓用多線程均攤壓力,能夠利用key、id等標識性字段作任務分組,這樣同一個id的binlog消息始終會被同一個線程執行。
正常狀況下用戶請求一個數據時會攜帶標記性的參數(好比id),而咱們的緩存key則會以這些標記性的參數來劃分不一樣的cache value,而後咱們根據這些參數去查緩存,查到就返回,不然回源,而後寫入cache服務後返回。 這個過程看起來也沒什麼問題,可是某些狀況下,根據帶進來的參數,在數據庫裏並不能找到對應的信息,這個時候每次帶有這種參數的請求,都會走到數據庫回源,這種現象叫作緩存穿透,比較典型的出現這種問題的狀況有: 1.惡意攻擊或者爬蟲,攜帶數據庫裏本就不存在的數據作參數回源 2.公司內部別的業務方調用我方的接口時,因爲溝通不當或其餘緣由致使的參數大量誤傳 3.客戶端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