緩存系統是提高系統性能和處理能力的利器,經常使用的緩存系統各自的特性和使用場景有所不一樣,這裏總結下經常使用緩存系統時須要關注的點以及解決方案,以及業務中緩存系統的選型等。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
緩存中的熱點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
緩存系統中的驚羣效應 是指大併發狀況下某個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();//釋放讀鎖
}
}
}
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;
緩存擊穿的場景有不少,如由緩存過時產生的驚羣,數據冷熱不均致使冷數據擊穿到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之間的一致性,增長了實現和維護成本。
併發請求會帶來不少問題,如以前討論的熱點key、驚羣的併發讀取,而併發寫入也是一個須要考慮的點。
案例:商品的庫存信息,大促期間有多個線程同時更新商品的庫存數量,如:線程A獲取庫存數爲10,作庫存-2操做,並將結果8寫入緩存;線程B在線程A寫入前獲取庫存數爲10,作庫存-1操做,將結果9寫入操做,這種狀況下,緩存中保存的庫存數量一定是有問題的。
解決方案:
* 分佈式鎖-悲觀鎖
在併發更新的狀況下線程A和線程B須要去競爭鎖,競爭到鎖的線程先去緩存中讀取數據如庫存數10,在作庫存-2操做,而後將結果寫入緩存,寫入成功以後釋放鎖。線程B再獲取到鎖,在作一樣的操做讀庫存減庫存,將結果寫入緩存,釋放鎖。
使用緩存系統時,一致性是一個比較難解決的問題,須要在業務評估的時候就要考慮起來。通常業務對一致性的要求能夠分爲三檔:強一致性、弱一致性、最終一致性。
若是業務對數據的一致性很是敏感,如電商的交易訂單信息,其中涉及到交易的狀態、付款信息等頻繁變動的場景,而許多須要反查交易的系統對交易訂單的狀態的準確性要求很是高,即使是短暫的不一致也不能忍受。這種場景下,交易系統對數據的要求是強一致的,強一致場景下使用緩存系統則會極大的提升系統的複雜性,因此不建議使用獨立的分佈式緩存系統。使用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消息或者暫時沒法實現的時候,那麼必定要注意使用封裝了緩存的數據操做接口來進行遍歷訂正。
使用分佈式緩存的目的是爲了替後端存儲擋下絕大部分的請求,可是在實際的業務場景中,數據的時候用頻率是不同的,有的數據請求高,有的數據請求低,這樣就形成數據的冷熱不均,並且這樣的冷熱數據每每也是跟實際的業務場景變化而變化,在電商場景中則更加明顯。
案例:家居大促、暑期電腦家電大促、秋冬服裝大促等。每次電商節,行業大促其側重點都有所不一樣,反應在應用系統的數據的緩存上,則是不一樣商品在緩存系統中的冷熱交替。如日常家居類商品訪問會不多,因此在緩存系統中因爲請求較少,一段時間後會被逐出或者過時掉,甚至在db中也是冷數據,在大促開始的時候則會因爲流量的涌入,致使緩存被擊穿,請求到達後端存儲,形成存儲系統壓力過大。
解決方案:
* 數據預熱
在大促前夕,根據大促的行業特色,活動商家分析出熱點商品,提早對這些商品進行讀取預熱。
緩存系統雖然性能很高,單機幾萬到幾十萬qps也沒有問題,可是畢竟是有處理極限,對請求仍是須要有基本的限流措施,而應用也須要時刻關注是否觸發了緩存系統的限流,若是觸發須要當即中止調用並進行review,不然會拖垮緩存系統或者影響其餘使用同個緩存系統的業務。
大併發下對緩存系統的請求qps通常都很是高,一個系統幾十萬甚至上百萬的請求也有可能的,序列化的性能以及序列化後的空間消耗則變得比較重要,因此須要選擇合適的序列化的方式。
案例:商品信息中包含了商品的名稱、商品圖片地址、商品類目、商品描述、商品視頻地址、商品屬性等,這些信息不多更新,可是會形成商品的size會很大,一個商品信息的DO在使用java原生序列化以後會有幾十K,若是一次批量獲取則有可能超過1M。
解決方案:
* 選擇合適的序列方式
從序列化的性能、序列化後的空間大小、序列方式的易用性等方面進行經常使用序列化方式對比,通常折中方案選擇json,若是對性能有更高的要求能夠選擇protoBuff。
使用緩存系統的時候必定要明確一個思想,緩存不是存儲,它不能用來代替持久化的存儲方案,如db、hbase。即使是redis已經宣稱實現了持續久化的方案RDB和AOF,緩存系統後端仍是須要有一套持久的存儲。
若是數據是不可丟失的,那麼在使用緩存系統的時候,必定須要考慮當緩存系統崩潰或者網絡抖動時,緩存中數據丟失和不一致的容災方案,還有緩存恢復以後數據重建方案。
案例:手淘包裹列表的redis方案,使用redis的zset來實現包裹按時間的排序,查詢時先查redis拿到排好序的包裹id列表,再用id列表回表查詢具體數據。這樣作的好處是複雜的排序操做由原先db移到redis,db只須要完成簡單的主鍵id查詢便可,提高查詢的性能。可是須要考慮的是若是redis不可用,那麼仍是須要到db中完成複雜的查詢,只是這個時候須要對查詢的接口進行限流,防止壓垮db。而redis恢復以後數據恢復方案有兩種,一是直接清空掉redis中全部數據,一段時間內由db查詢支撐並緩慢重建用戶在redis中的包裹數據,二是清空redis數據並遍歷db重建全部數據。
主要是統計緩存的命中率、錯誤數、錯誤類型等指標。
緩存命中率直接反應了緩存的效果,若是命中率太低(30%如下)則加緩存帶來的受益不大,這個時候付出的緩存容量、代碼複雜度都得不償失,因此須要及時review使用緩存的場景、key的設計、冷熱數據、代碼的使用,逐步調優提高命中率(70%以上)。
緩存的錯誤數、錯誤類型則用於統計和監控分佈式緩存應用的健康狀態,在緩存崩潰或者網絡抖動的時候,錯誤數或者錯誤持續時長達到閾值則須要切換到容災方案。
緩存系統的引入必然會對原有的代碼結構帶來必定的衝擊,特別是在複雜場景下每每不僅會使用一套緩存系統,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;
}
}
}
分佈式鎖是分佈式場景下一個典型的應用,其實現方式多種多樣,也有不少基於緩存系統的實現方式。
* 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是一致的,則認爲是同一個線程再次獲取鎖,從而實現可重入鎖。