1. 程式人生 > >常用緩存系統使用經驗總結

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

per 用戶id 釋放 數據結構 map 隨機選擇 導致 和數 ember

0. 前言

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

本文內容主要包括以下:

  • 緩存使用中需要註意的點:熱點、驚群、擊穿、並發、一致性、預熱、限流、序列化、壓縮、容災、統計、監控。
  • spring cache、分布式鎖。

1、常用緩存系統

在平常的業務開發過程中,一般會使用集團自己開發的tair分布式緩存系統,tair有三種存儲引擎:mdb、ldb、rdb,從名字上就可以看出,分別對應memcache、leveldb、redis。 在一些特定場景,還會使用到localcache,常見的會用到guava cache。

  • mdb(memcache)
  • ldb(leveldb)
  • rdb(redis)
  • localcache(guava cache)

2、緩存使用中需要註意的點

2.1 熱點

緩存中的熱點key是指短時間大量訪問同一個key,一般是高讀低寫。短時間頻繁訪問同一個key,請求會打到同一臺緩存機器上,形成單點,無法發揮分布式緩存集群的能力。

案例:商品信息,更新很少,但是讀取量很大,一般會以商品id為key,value為商品的基本信息。在大促期間有些熱門商品會被頻繁訪問(小米新品首發、秒殺場景),形成熱點商品。

解決方案:

  • 使用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不適合。

  • 對熱點key散列
    某些業務場景下需要進行計數,比如對某個頁面的pv進行統計,這種高寫低讀的場景可以對這key進行散列,比如講key散列成key1、key2、key3....keyn,計數時隨機選擇一個key,統計總數是讀出所有的key再進行合並統計,這種場景雖然會放大讀操作,但是由於讀的訪問本身就不高的場景下,不會對集群產生太大的影響。

  • 緩存服務端熱點識別

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

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

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