經常使用緩存系統使用經驗總結

0. 前言

緩存系統是提高系統性能和處理能力的利器,經常使用的緩存系統各自的特性和使用場景有所不一樣,這裏總結下經常使用緩存系統時須要關注的點以及解決方案,以及業務中緩存系統的選型等。java

本文內容主要包括如下:
* 緩存使用中須要注意的點:熱點、驚羣、擊穿、併發、一致性、預熱、限流、序列化、壓縮、容災、統計、監控。
* spring cache、分佈式鎖。mysql

一、經常使用緩存系統

在日常的業務開發過程當中,通常會使用集團本身開發的tair分佈式緩存系統,tair有三種存儲引擎:mdb、ldb、rdb,從名字上就能夠看出,分別對應memcache、leveldb、redis。 在一些特定場景,還會使用到localcache,常見的會用到guava cache。
* mdb(memcache)
* ldb(leveldb)
* rdb(redis)
* localcache(guava cache)web

二、緩存使用中須要注意的點

2.1 熱點

緩存中的熱點key是指短期大量訪問同一個key,通常是高讀低寫。短期頻繁訪問同一個key,請求會打到同一臺緩存機器上,造成單點,沒法發揮分佈式緩存集羣的能力。redis

案例:商品信息,更新不多,可是讀取量很大,通常會以商品id爲key,value爲商品的基本信息。在大促期間有些熱門商品會被頻繁訪問(小米新品首發、秒殺場景),造成熱點商品。spring

解決方案:
* 使用localcache
在查詢分佈式緩存前再加一層localcache,更新是先刪除localcache中的key,查詢時先查localcache,查詢不到再查分佈式緩存,而後再回寫到localcache。
可是分佈式場景下使用localcache會有短暫的數據不一致,如key1在機器A、B的localcache中都有,機器A上更新key1時會刪除掉機器A上localcache中的key1,可是機器B上localcache中的key1沒有被刪除,這時候機器B上發生查詢key1的操做就會發送數據不一致的狀況。
此種狀況下,則須要考慮短暫的數據不一致是不是能夠接受的,若是能夠接受則能夠在localcache的key1上添加過時時間,如30ms。若是業務需求強一致場景,則localcache不適合。sql

  • 對熱點key散列
    某些業務場景下須要進行計數,好比對某個頁面的pv進行統計,這種高寫低讀的場景能夠對這key進行散列,好比講key散列成key一、key二、key3….keyn,計數時隨機選擇一個key,統計總數是讀出全部的key再進行合併統計,這種場景雖然會放大讀操做,可是因爲讀的訪問自己就不高的場景下,不會對集羣產生太大的影響。json

  • 緩存服務端熱點識別後端

使用localcache和熱點key散列都只是針對特定的場景,也須要應用端進行開發,tair的熱點散列機制則能在緩存服務端智能識別熱點key並對其進行散列,作到對應用端透明。api

2.2 驚羣

緩存系統中的驚羣效應 是指大併發狀況下某個key在失效瞬間,大量對這個key的請求會同時擊穿緩存,請求落到後端存儲(通常是db),致使db負載升高,rt升高。緩存

案例:熱點商品的過時,在緩存商品信息時通常會設置過時時間,在熱點商品過時的瞬間,大量對這個商品信息的請求會直接落到db上。

分析:緩存失效瞬間,大量擊穿的請求在從db獲取數據以後,通常會再回寫到緩存中,因此實際上只須要一個請求真正去db獲取數據便可,其餘請求等待它將數據回寫到緩存中再從緩存中獲取便可。

解決方案:
* 讀寫鎖
讀寫鎖的方法在key過時以後,多線程從緩存獲取不到數據時使用讀寫鎖,只有獲得寫鎖的線程才能去db中獲取數據,回寫緩存。但該方案沒法完成在應用機器集羣間的驚羣隔離,若是應用集羣機器數較少,則比較適合。
僞代碼以下:

Obj cacheData = cache.get(key);
  if(null != cacheData){
      return cacheData;
  }else{
      lock = getReadWriteLock(key);
      if (lock.writeLock().tryLock()) {
          try{
              Obj dbData = db.get(key);
              cache.put(key, newExpireTime);
              retrun dbData;
          }finally{
              lock.writeLock().unlock();//釋放寫鎖
              deleteReadWriteLock(key);
          }  
      }else{
          try{
              lock.readLock().lock();//沒拿到寫鎖的做爲讀鎖,必須等待�
              Obj cacheData = cache.get(key);
              return cacheData;
          }finally{
              lock.readLock().unlock();//釋放讀鎖
          }
      }
  }
  • 過時續期
    續期的方法是在key即將過時以前,使用一個線程對該key提早從db中獲取數據,回寫緩存,並增長key的過時時間。該方法的核心是如何保證一個線程去對key進行更新並續期,通常可使用3.2 分佈式鎖來實現來實現。改方案能夠實現應用集羣間的隔離,可是依賴分佈式鎖,增長了實現成本。
    僞代碼以下:
Obj cacheData = cache.get(key);
  if(cacheData.expireTime - currentTime < 10ms){
      bool lock = getDistriLock(key); //獲取分佈式鎖
      if(lock){
          Obj dbData = db.get(key);
          cache.put(key, newExpireTime);
          deleteDistriLock(key);
      }
  }
  retrun cacheData;

2.3 擊穿

緩存擊穿的場景有不少,如由緩存過時產生的驚羣,數據冷熱不均致使冷數據擊穿到db,還有一種狀況則是由空數據致使的緩存擊穿。

案例:手淘包裹card提供用戶最近30天的簽收和未簽收包裹列表,列表索引由redis zset構建,key爲用戶id,members爲包裹id,score爲包裹更新時間。查詢時若是redis中查詢不到用戶相關的包裹列表索引,則去db中查詢,查詢完成以後再將db返回的結果回寫到redis中,這是常規的處理方案。可是若是一個用戶在最近30天都沒有任何包裹,當他查詢的時候則會每次都擊穿緩存,落到db,而db中也沒有該用戶最近30天的包裹數據,緩存中依然爲空。不幸的是這個接口的調用時機是手淘-「個人淘寶「tab,雙十一調用峯值是8w qps,而大部分最近30天沒有買過東西(大部分是男性)用戶也會在大促的時候頻繁使用手淘,這部分用戶在每次查詢的時候都會擊穿緩存落到db,整個過程只能獲取到一堆空數據。

解決方案:
* 計數
增長一個單獨的計數key,記錄db中返回的列表數量,在查詢列表以前先查詢計數key,若是計數結果爲0則不用去查詢緩存和db。
該方案須要增長一個計數key,並須要保證計數key和數據key之間的一致性,增長了實現和維護成本。

  • 空對象
    在db返回的列表爲空的時候,向緩存的value中增長一個空的對象,下次查詢是若是從緩存中查的結果是空對象則不去db中獲取數據。
    該方案在數據key的value中增長了一個非業務的數據,容易形成數據污染,在支持複雜key的緩存中,如redis zset/list/set等數據結構時,對致使count的不許,特別是數據量爲1時,沒法區分究竟是正常數據仍是空對象,須要將真正的數據內容取出進行判別,總體上增長了實現和維護成本。

2.4 併發

併發請求會帶來不少問題,如以前討論的熱點key、驚羣的併發讀取,而併發寫入也是一個須要考慮的點。

案例:商品的庫存信息,大促期間有多個線程同時更新商品的庫存數量,如:線程A獲取庫存數爲10,作庫存-2操做,並將結果8寫入緩存;線程B在線程A寫入前獲取庫存數爲10,作庫存-1操做,將結果9寫入操做,這種狀況下,緩存中保存的庫存數量一定是有問題的。

解決方案:
* 分佈式鎖-悲觀鎖
在併發更新的狀況下線程A和線程B須要去競爭鎖,競爭到鎖的線程先去緩存中讀取數據如庫存數10,在作庫存-2操做,而後將結果寫入緩存,寫入成功以後釋放鎖。線程B再獲取到鎖,在作一樣的操做讀庫存減庫存,將結果寫入緩存,釋放鎖。

  • 引入版本號-樂觀鎖
    採用分佈式鎖須要在每次寫入操做前都要去搶鎖,即使沒有併發寫入產生,這是一種悲觀鎖的實現方式,利用數據版本號能夠實現樂觀鎖方案。
    利用tair數據的version能夠實現樂觀鎖的寫入實現,在併發更新的狀況下線程A和線程B都須要先去緩存中讀取庫存數據,可是這個時候會額外的多獲得一個數據的version,在寫入的時候須要帶上該version,tair的server端在寫入數據的時候會比較傳入的version和數據中原有的version,若是version一致則寫入成功,並將version+1,若是version不一樣則返回失敗。寫入失敗的線程須要從新讀取數據,得到version,完成操做再次寫入。
    樂觀鎖的方案在併發度低的狀況下,能夠下降鎖的爭搶,在方案上也更簡單,可是須要緩存服務端的支持。

2.5 一致性

使用緩存系統時,一致性是一個比較難解決的問題,須要在業務評估的時候就要考慮起來。通常業務對一致性的要求能夠分爲三檔:強一致性、弱一致性、最終一致性。

若是業務對數據的一致性很是敏感,如電商的交易訂單信息,其中涉及到交易的狀態、付款信息等頻繁變動的場景,而許多須要反查交易的系統對交易訂單的狀態的準確性要求很是高,即使是短暫的不一致也不能忍受。這種場景下,交易系統對數據的要求是強一致的,強一致場景下使用緩存系統則會極大的提升系統的複雜性,因此不建議使用獨立的分佈式緩存系統。使用mysql作後端存儲時,強一致場景下,能夠考慮mysql5.7 memcache plugin特性,便可以享受緩存帶來的高性能又不用爲數據一致性擔憂。

而大部分業務對數據的一致性要求不是很嚴格,如商品的名稱、評價系統中的評論、點讚的個數、包裹的物流狀態等,用戶對這些信息是否是和後端存儲中同樣是不敏感的,短暫的不一致不會帶來很嚴重的後果,這些場景下使用緩存系統比較合適。可是沒有強一致性的要求不表明沒有一致性的要求,一致性處理很差同樣會帶來用戶的困惑或者系統的bug,比較常見的場景是列表頁和詳情頁的不一致。

在處理緩存和後端存儲數據一致性的時候,須要考慮如下幾點:

  • 併發更新
    併發更新的場景和解決方案見2.4 併發。

  • 數據重建
    數據重建通常是在緩存系統崩潰或者不穩定,切換到容災方案,等到緩存系統再恢復以後,緩存中的數據已經和db中的數據有了較大的差別,須要依賴db中的數據進行所有重建。
    如手淘包裹列表的redis索引,在redis系統崩潰以後,切換到db的容災方案,等到redis恢復以後,redis中的數據已經和db中出現了較大的不一致,須要依賴db中的數據進行重建。
    方案上先暫停對redis的寫入,並清空redis中的所有數據。因爲包裹db採用分庫分表,共有4096表,不能在一臺機器上遍歷全部的數據,爲了充分利用分佈式集羣機器的能力,能夠將4096張表做爲4096個任務分發到包裹應用集羣的200多臺機器上,每臺機器處理20張表。分發過程可使用分佈式調度中間件也能夠簡單的使用消息中間件。因爲分表字段是uid,因此恰好每臺機器只要遍歷分到本身機器上的表,以uid爲key在redis中重建該用戶的全部數據。單表在200w條記錄,取最近一個月數據(總共3個月)分頁遍歷也只需3分鐘全部便可完成,單機20張表一個小時能夠完成,4096張表整個集羣在一個小時內完成數據重建。完成數據重建以後再打開redis寫和讀服務,系統從容災狀態切換緩存服務狀態。

  • 數據訂正
    有時候會有批量數據訂正的場景,如批量更新包裹的狀態、批量刪除違規的評論信息,可是若是隻更新了後端存儲沒有更新緩存,則會帶來數據不一致的問題。mysql下比較好的一個解決方案是,應用系統監聽binlog變動消息,直接失效掉對應的緩存。
    沒法監聽binlog消息或者暫時沒法實現的時候,那麼必定要注意使用封裝了緩存的數據操做接口來進行遍歷訂正。

2.6 預熱

使用分佈式緩存的目的是爲了替後端存儲擋下絕大部分的請求,可是在實際的業務場景中,數據的時候用頻率是不同的,有的數據請求高,有的數據請求低,這樣就形成數據的冷熱不均,並且這樣的冷熱數據每每也是跟實際的業務場景變化而變化,在電商場景中則更加明顯。

案例:家居大促、暑期電腦家電大促、秋冬服裝大促等。每次電商節,行業大促其側重點都有所不一樣,反應在應用系統的數據的緩存上,則是不一樣商品在緩存系統中的冷熱交替。如日常家居類商品訪問會不多,因此在緩存系統中因爲請求較少,一段時間後會被逐出或者過時掉,甚至在db中也是冷數據,在大促開始的時候則會因爲流量的涌入,致使緩存被擊穿,請求到達後端存儲,形成存儲系統壓力過大。

解決方案:
* 數據預熱
在大促前夕,根據大促的行業特色,活動商家分析出熱點商品,提早對這些商品進行讀取預熱。

2.7 限流

緩存系統雖然性能很高,單機幾萬到幾十萬qps也沒有問題,可是畢竟是有處理極限,對請求仍是須要有基本的限流措施,而應用也須要時刻關注是否觸發了緩存系統的限流,若是觸發須要當即中止調用並進行review,不然會拖垮緩存系統或者影響其餘使用同個緩存系統的業務。

2.8 序列化&壓縮

大併發下對緩存系統的請求qps通常都很是高,一個系統幾十萬甚至上百萬的請求也有可能的,序列化的性能以及序列化後的空間消耗則變得比較重要,因此須要選擇合適的序列化的方式。

案例:商品信息中包含了商品的名稱、商品圖片地址、商品類目、商品描述、商品視頻地址、商品屬性等,這些信息不多更新,可是會形成商品的size會很大,一個商品信息的DO在使用java原生序列化以後會有幾十K,若是一次批量獲取則有可能超過1M。

解決方案:
* 選擇合適的序列方式
從序列化的性能、序列化後的空間大小、序列方式的易用性等方面進行經常使用序列化方式對比,通常折中方案選擇json,若是對性能有更高的要求能夠選擇protoBuff。

  • 壓縮
    對序列化以後的內容進行壓縮能夠下降請求過程當中網絡的消耗,還能夠在緩存服務端用同等的容量存儲更多的key,提升緩存的命中率,經常使用的可使用zip,snappy。固然壓縮的代價是消耗更多應用機器的性能,因此在是否須要採用壓縮上須要根據實際狀況進行取捨。

2.9 容災

使用緩存系統的時候必定要明確一個思想,緩存不是存儲,它不能用來代替持久化的存儲方案,如db、hbase。即使是redis已經宣稱實現了持續久化的方案RDB和AOF,緩存系統後端仍是須要有一套持久的存儲。

若是數據是不可丟失的,那麼在使用緩存系統的時候,必定須要考慮當緩存系統崩潰或者網絡抖動時,緩存中數據丟失和不一致的容災方案,還有緩存恢復以後數據重建方案。

案例:手淘包裹列表的redis方案,使用redis的zset來實現包裹按時間的排序,查詢時先查redis拿到排好序的包裹id列表,再用id列表回表查詢具體數據。這樣作的好處是複雜的排序操做由原先db移到redis,db只須要完成簡單的主鍵id查詢便可,提高查詢的性能。可是須要考慮的是若是redis不可用,那麼仍是須要到db中完成複雜的查詢,只是這個時候須要對查詢的接口進行限流,防止壓垮db。而redis恢復以後數據恢復方案有兩種,一是直接清空掉redis中全部數據,一段時間內由db查詢支撐並緩慢重建用戶在redis中的包裹數據,二是清空redis數據並遍歷db重建全部數據。

2.10 統計&監控

主要是統計緩存的命中率、錯誤數、錯誤類型等指標。

緩存命中率直接反應了緩存的效果,若是命中率太低(30%如下)則加緩存帶來的受益不大,這個時候付出的緩存容量、代碼複雜度都得不償失,因此須要及時review使用緩存的場景、key的設計、冷熱數據、代碼的使用,逐步調優提高命中率(70%以上)。

緩存的錯誤數、錯誤類型則用於統計和監控分佈式緩存應用的健康狀態,在緩存崩潰或者網絡抖動的時候,錯誤數或者錯誤持續時長達到閾值則須要切換到容災方案。

3. 其餘

3.1 spring cache

緩存系統的引入必然會對原有的代碼結構帶來必定的衝擊,特別是在複雜場景下每每不僅會使用一套緩存系統,mdb、ldb、redis、localcache全上也有可能,還涉及到一致性、併發、擊穿等處理,代碼的複雜度會大大增長。

spring cache是一套基於註釋的緩存技術,它本質上不是一個具體的緩存實現方案(例如 EHCache 或者 OSCache),而是一個對緩存使用的抽象,經過在既有代碼中添加少許它定義的各類 annotation,即可以達到緩存方法的返回對象的效果。

經過使用spring cache的註解能夠在DO層進行橫切,讓緩存和DO操做隔離開,關注於各自的業務邏輯,從而實現對外高內聚,對內鬆耦合。spring cache的說明和各個註解的做用不作多的介紹,主要介紹下使用經驗。
* spring cache基於代理,須要區別jdk代理和cglib的代理實現方式,jdk代理時this調用不起做用。
* 在spring cache的實現類中須要避免直接或間接調用添加了註解的方法,避免緩存的循環調用。
* 基於spring cache的KeyGenerator能夠將添加了註解的方法的參數、方法名稱構建成key,實現多個接口的代理。

public class SpringCachePackInfoKeyGenerator implements KeyGenerator {
      @Override
      public Object generate(Object target, Method method, Object... params) {
          Map<String, Object> keyParam = new HashMap<String, Object>();

          keyParam.put(METHOD_NAME,   method.getName());
          keyParam.put(METHOD_PARAMS, Arrays.asList(params));

          return keyParam;
      }
  }


  public class SpringRedisMyTaobaoPackCache implements Cache {
      @Override
      public ValueWrapper get(Object key) {
          Map<String, Object> keyParam = (Map)key;

          List<Object> params = (List)keyParam.get(METHOD_PARAMS);
          String methodName   = keyParam.get(METHOD_NAME).toString();

          if("methodA".equals(methodName)){
              //do something with params
              retrun cacheObj;
          }

          if("methodB".equals(methodName)){
              //do something with params
              retrun cacheOjb;
          }
      }
  }

3.2 分佈式鎖

分佈式鎖是分佈式場景下一個典型的應用,其實現方式多種多樣,也有不少基於緩存系統的實現方式。
* redis的實現
redis的分佈式鎖實如今redis的官方文檔上有詳細的介紹。

  • tair incr/decr,經過計數api的上下限值約束來實現。
    Tair的incr遞增數據接口能夠經過設置上限爲1,客戶端請求鎖調用時若是數據是0,則遞增成1,請求成功,若是數據已是1,則返回請求失敗。釋放鎖時將數據復位成0便可。經過調大上限,能夠實現多個客戶端同時持有鎖相似信號量的功能。在調用incr接口時須要設置超時時間,即鎖的超時時間,超時鎖被自動釋放。線程在使用完鎖以後進行decr進行鎖的釋放。
    可是基於incr的鎖沒法實現可重入性。

  • tair put/get/invalid,經過put是的version來校驗。
    嘗試獲取鎖的過程,由兩個步驟組成:先get到緩存的數據,若是能獲取到數據則返回獲取鎖失敗,若是不存在則調用put搶鎖,put時的version能夠除了0和1之外的全部數字(可是每次都須要是同樣),若是put成功則代表搶鎖成功,若是失敗代表搶鎖失敗。在put的時候須要設置超時時間,即鎖的超時時間,超時鎖主動被釋放。線程在使用完鎖以後使用invalid進行鎖的釋放。
    在put的時候,value能夠設置爲當前機器的ip和線程信息,在get的時候能夠比較value信息,若是當前機器的value和get到value是一致的,則認爲是同一個線程再次獲取鎖,從而實現可重入鎖。

參考:
https://www.jianshu.com/p/c1b9ec30b994

相關文章
相關標籤/搜索