常用快取系統使用經驗總結

_吹雪_發表於2018-07-30

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

相關文章