常用緩存系統使用經驗總結
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
常用緩存系統使用經驗總結