本文已發表於《程序員》雜誌2017年第3期,下面的版本又通過進一步的修訂。html
通常而言,如今互聯網應用(網站或App)的總體流程,能夠歸納如圖1所示,用戶請求從界面(瀏覽器或App界面)到網絡轉發、應用服務再到存儲(數據庫或文件系統),而後返回到界面呈現內容。java
隨着互聯網的普及,內容信息愈來愈複雜,用戶數和訪問量愈來愈大,咱們的應用須要支撐更多的併發量,同時咱們的應用服務器和數據庫服務器所作的計算也愈來愈多。可是每每咱們的應用服務器資源是有限的,且技術變革是緩慢的,數據庫每秒能接受的請求次數也是有限的(或者文件的讀寫也是有限的),如何可以有效利用有限的資源來提供儘量大的吞吐量?一個有效的辦法就是引入緩存,打破標準流程,每一個環節中請求能夠從緩存中直接獲取目標數據並返回,從而減小計算量,有效提高響應速度,讓有限的資源服務更多的用戶。node
如圖1所示,緩存的使用能夠出如今1~4的各個環節中,每一個環節的緩存方案與使用各有特色。程序員
圖1 互聯網應用通常流程redis
緩存也是一個數據模型對象,那麼必然有它的一些特徵:算法
命中率=返回正確結果數/請求緩存次數,命中率問題是緩存中的一個很是重要的問題,它是衡量緩存有效性的重要指標。命中率越高,代表緩存的使用率越高。spring
緩存中能夠存放的最大元素的數量,一旦緩存中元素數量超過這個值(或者緩存數據所佔空間超過其最大支持空間),那麼將會觸發緩存啓動清空策略根據不一樣的場景合理的設置最大元素值每每能夠必定程度上提升緩存的命中率,從而更有效的時候緩存。數據庫
如上描述,緩存的存儲空間有限制,當緩存空間被用滿時,如何保證在穩定服務的同時有效提高命中率?這就由緩存清空策略來處理,設計適合自身數據特徵的清空策略能有效提高命中率。常見的通常策略有:編程
FIFO(first in first out)api
先進先出策略,最早進入緩存的數據在緩存空間不夠的狀況下(超出最大元素限制)會被優先被清除掉,以騰出新的空間接受新的數據。策略算法主要比較緩存元素的建立時間。在數據實效性要求場景下可選擇該類策略,優先保障最新數據可用。
LFU(less frequently used)
最少使用策略,不管是否過時,根據元素的被使用次數判斷,清除使用次數較少的元素釋放空間。策略算法主要比較元素的hitCount(命中次數)。在保證高頻數據有效性場景下,可選擇這類策略。
LRU(least recently used)
最近最少使用策略,不管是否過時,根據元素最後一次被使用的時間戳,清除最遠使用時間戳的元素釋放空間。策略算法主要比較元素最近一次被get使用時間。在熱點數據場景下較適用,優先保證熱點數據的有效性。
除此以外,還有一些簡單策略好比:
雖然從硬件介質上來看,無非就是內存和硬盤兩種,但從技術上,能夠分紅內存、硬盤文件、數據庫。
緩存有各種特徵,並且有不一樣介質的區別,那麼實際工程中咱們怎麼去對緩存分類呢?在目前的應用服務框架中,比較常見的,時根據緩存雨應用的藕合度,分爲local cache(本地緩存)和remote cache(分佈式緩存):
本地緩存:指的是在應用中的緩存組件,其最大的優勢是應用和cache是在同一個進程內部,請求緩存很是快速,沒有過多的網絡開銷等,在單應用不須要集羣支持或者集羣狀況下各節點無需互相通知的場景下使用本地緩存較合適;同時,它的缺點也是應爲緩存跟應用程序耦合,多個應用程序沒法直接的共享緩存,各應用或集羣的各節點都須要維護本身的單獨緩存,對內存是一種浪費。
分佈式緩存:指的是與應用分離的緩存組件或服務,其最大的優勢是自身就是一個獨立的應用,與本地應用隔離,多個應用可直接的共享緩存。
目前各類類型的緩存都活躍在成千上萬的應用服務中,尚未一種緩存方案能夠解決一切的業務場景或數據類型,咱們須要根據自身的特殊場景和背景,選擇最適合的緩存方案。緩存的使用是程序員、架構師的必備技能,好的程序員能根據數據類型、業務場景來準確判斷使用何種類型的緩存,如何使用這種緩存,以最小的成本最快的效率達到最優的目的。
個別場景下,咱們只須要簡單的緩存數據的功能,而無需關注更多存取、清空策略等深刻的特性時,直接編程實現緩存則是最便捷和高效的。
a. 成員變量或局部變量實現
簡單代碼示例以下:
1 public void UseLocalCache(){ 2 //一個本地的緩存變量 3 Map<String, Object> localCacheStoreMap = new HashMap<String, Object>(); 4 5 List<Object> infosList = this.getInfoList(); 6 for(Object item:infosList){ 7 if(localCacheStoreMap.containsKey(item)){ //緩存命中 使用緩存數據 8 // todo 9 } else { // 緩存未命中 IO獲取數據,結果存入緩存 10 Object valueObject = this.getInfoFromDB(); 11 localCacheStoreMap.put(valueObject.toString(), valueObject); 12 13 } 14 } 15 } 16 //示例 17 private List<Object> getInfoList(){ 18 return new ArrayList<Object>(); 19 } 20 //示例數據庫IO獲取 21 private Object getInfoFromDB(){ 22 return new Object(); 23 }
以局部變量map結構緩存部分業務數據,減小頻繁的重複數據庫I/O操做。缺點僅限於類的自身做用域內,類間沒法共享緩存。
b. 靜態變量實現
最經常使用的單例實現靜態資源緩存,代碼示例以下:
1 public class CityUtils { 2 private static final HttpClient httpClient = ServerHolder.createClientWithPool(); 3 private static Map<Integer, String> cityIdNameMap = new HashMap<Integer, String>(); 4 private static Map<Integer, String> districtIdNameMap = new HashMap<Integer, String>(); 5 6 static { 7 HttpGet get = new HttpGet("http://gis-in.sankuai.com/api/location/city/all"); 8 BaseAuthorizationUtils.generateAuthAndDateHeader(get, 9 BaseAuthorizationUtils.CLIENT_TO_REQUEST_MDC, 10 BaseAuthorizationUtils.SECRET_TO_REQUEST_MDC); 11 try { 12 String resultStr = httpClient.execute(get, new BasicResponseHandler()); 13 JSONObject resultJo = new JSONObject(resultStr); 14 JSONArray dataJa = resultJo.getJSONArray("data"); 15 for (int i = 0; i < dataJa.length(); i++) { 16 JSONObject itemJo = dataJa.getJSONObject(i); 17 cityIdNameMap.put(itemJo.getInt("id"), itemJo.getString("name")); 18 } 19 } catch (Exception e) { 20 throw new RuntimeException("Init City List Error!", e); 21 } 22 } 23 static { 24 HttpGet get = new HttpGet("http://gis-in.sankuai.com/api/location/district/all"); 25 BaseAuthorizationUtils.generateAuthAndDateHeader(get, 26 BaseAuthorizationUtils.CLIENT_TO_REQUEST_MDC, 27 BaseAuthorizationUtils.SECRET_TO_REQUEST_MDC); 28 try { 29 String resultStr = httpClient.execute(get, new BasicResponseHandler()); 30 JSONObject resultJo = new JSONObject(resultStr); 31 JSONArray dataJa = resultJo.getJSONArray("data"); 32 for (int i = 0; i < dataJa.length(); i++) { 33 JSONObject itemJo = dataJa.getJSONObject(i); 34 districtIdNameMap.put(itemJo.getInt("id"), itemJo.getString("name")); 35 } 36 } catch (Exception e) { 37 throw new RuntimeException("Init District List Error!", e); 38 } 39 } 40 41 public static String getCityName(int cityId) { 42 String name = cityIdNameMap.get(cityId); 43 if (name == null) { 44 name = "未知"; 45 } 46 return name; 47 } 48 49 public static String getDistrictName(int districtId) { 50 String name = districtIdNameMap.get(districtId); 51 if (name == null) { 52 name = "未知"; 53 } 54 return name; 55 } 56 }
O2O業務中經常使用的城市基礎基本信息判斷,經過靜態變量一次獲取緩存內存中,減小頻繁的I/O讀取,靜態變量實現類間可共享,進程內可共享,緩存的實時性稍差。
爲了解決本地緩存數據的實時性問題,目前大量使用的是結合ZooKeeper的自動發現機制,實時變動本地靜態變量緩存:
美團點評內部的基礎配置組件MtConfig,採用的就是相似原理,使用靜態變量緩存,結合ZooKeeper的統一管理,作到自動動態更新緩存,如圖2所示。
圖2 Mtconfig實現圖
這類緩存實現,優勢是能直接在heap區內讀寫,最快也最方便;缺點一樣是受heap區域影響,緩存的數據量很是有限,同時緩存時間受GC影響。主要知足單機場景下的小數據量緩存需求,同時對緩存數據的變動無需太敏感感知,如上通常配置管理、基礎靜態數據等場景。
Ehcache是如今最流行的純Java開源緩存框架,配置簡單、結構清晰、功能強大,是一個很是輕量級的緩存實現,咱們經常使用的Hibernate裏面就集成了相關緩存功能。
圖3 Ehcache框架圖
從圖3中咱們能夠了解到,Ehcache的核心定義主要包括:
cache manager:緩存管理器,之前是隻容許單例的,不過如今也能夠多實例了。
cache:緩存管理器內能夠放置若干cache,存放數據的實質,全部cache都實現了Ehcache接口,這是一個真正使用的緩存實例;經過緩存管理器的模式,能夠在單個應用中輕鬆隔離多個緩存實例,獨立服務於不一樣業務場景需求,緩存數據物理隔離,同時須要時又可共享使用。
element:單條緩存數據的組成單位。
system of record(SOR):能夠取到真實數據的組件,能夠是真正的業務邏輯、外部接口調用、存放真實數據的數據庫等,緩存就是從SOR中讀取或者寫入到SOR中去的。
在上層能夠看到,整個Ehcache提供了對JSR、JMX等的標準支持,可以較好的兼容和移植,同時對各種對象有較完善的監控管理機制。它的緩存介質涵蓋堆內存(heap)、堆外內存(BigMemory商用版本支持)和磁盤,各介質可獨立設置屬性和策略。Ehcache最初是獨立的本地緩存框架組件,在後期的發展中,結合Terracotta服務陣列模型,能夠支持分佈式緩存集羣,主要有RMI、JGroups、JMS和Cache Server等傳播方式進行節點間通訊,如圖3的左側部分描述。
總體數據流轉包括這樣幾類行爲:
圖4反映了數據在各個層之間的流轉,同時也體現了各層數據的一個生命週期。
圖4 緩存數據流轉圖(L1:本地內存層;L2:Terracotta服務節點層)
Ehcache的配置使用以下:
<ehcache> <!-- 指定一個文件目錄,當Ehcache把數據寫到硬盤上時,將把數據寫到這個文件目錄下 --> <diskStore path="java.io.tmpdir"/> <!-- 設定緩存的默認數據過時策略 --> <defaultCache maxElementsInMemory="10000" eternal="false" overflowToDisk="true" timeToIdleSeconds="0" timeToLiveSeconds="0" diskPersistent="false" diskExpiryThreadIntervalSeconds="120"/> <!-- 設定具體的命名緩存的數據過時策略 cache元素的屬性: name:緩存名稱 maxElementsInMemory:內存中最大緩存對象數 maxElementsOnDisk:硬盤中最大緩存對象數,如果0表示無窮大 eternal:true表示對象永不過時,此時會忽略timeToIdleSeconds和timeToLiveSeconds屬性,默認爲false overflowToDisk:true表示當內存緩存的對象數目達到了maxElementsInMemory界限後,會把溢出的對象寫到硬盤緩存中。注意:若是緩存的對象要寫入到硬盤中的話,則該對象必須實現了Serializable接口才行。 diskSpoolBufferSizeMB:磁盤緩存區大小,默認爲30MB。每一個Cache都應該有本身的一個緩存區。 diskPersistent:是否緩存虛擬機重啓期數據 diskExpiryThreadIntervalSeconds:磁盤失效線程運行時間間隔,默認爲120秒 timeToIdleSeconds: 設定容許對象處於空閒狀態的最長時間,以秒爲單位。當對象自從最近一次被訪問後,若是處於空閒狀態的時間超過了timeToIdleSeconds屬性值,這個對象就會過時,EHCache將把它從緩存中清空。只有當eternal屬性爲false,該屬性纔有效。若是該屬性值爲0,則表示對象能夠無限期地處於空閒狀態 timeToLiveSeconds:設定對象容許存在於緩存中的最長時間,以秒爲單位。當對象自從被存放到緩存中後,若是處於緩存中的時間超過了 timeToLiveSeconds屬性值,這個對象就會過時,Ehcache將把它從緩存中清除。只有當eternal屬性爲false,該屬性纔有效。若是該屬性值爲0,則表示對象能夠無限期地存在於緩存中。timeToLiveSeconds必須大於timeToIdleSeconds屬性,纔有意義 memoryStoreEvictionPolicy:當達到maxElementsInMemory限制時,Ehcache將會根據指定的策略去清理內存。可選策略有:LRU(最近最少使用,默認策略)、FIFO(先進先出)、LFU(最少訪問次數)。 --> <cache name="CACHE1" maxElementsInMemory="1000" eternal="true" overflowToDisk="true"/> <cache name="CACHE2" maxElementsInMemory="1000" eternal="false" timeToIdleSeconds="200" timeToLiveSeconds="4000" overflowToDisk="true"/> </ehcache>
總體上看,Ehcache的使用仍是相對簡單便捷的,提供了完整的各種API接口。須要注意的是,雖然Ehcache支持磁盤的持久化,可是因爲存在兩級緩存介質,在一級內存中的緩存,若是沒有主動的刷入磁盤持久化的話,在應用異常down機等情形下,依然會出現緩存數據丟失,爲此能夠根據須要將緩存刷到磁盤,將緩存條目刷到磁盤的操做能夠經過cache.flush()方法來執行,須要注意的是,對於對象的磁盤寫入,前提是要將對象進行序列化。
主要特性:
注意:Ehcache的超時設置主要是針對整個cache實例設置總體的超時策略,而沒有較好的處理針對單獨的key的個性的超時設置(有策略設置,可是比較複雜,就不描述了),所以,在使用中要注意過時失效的緩存元素沒法被GC回收,時間越長緩存越多,內存佔用也就越大,內存泄露的機率也越大。
Guava Cache是Google開源的Java重用工具集庫Guava裏的一款緩存工具,其主要實現的緩存功能有:
Guava Cache的架構設計靈感來源於ConcurrentHashMap,咱們前面也提到過,簡單場景下能夠自行編碼經過hashmap來作少許數據的緩存,可是,若是結果可能隨時間改變或者是但願存儲的數據空間可控的話,本身實現這種數據結構仍是有必要的。
Guava Cache繼承了ConcurrentHashMap的思路,使用多個segments方式的細粒度鎖,在保證線程安全的同時,支持高併發場景需求。Cache相似於Map,它是存儲鍵值對的集合,不一樣的是它還須要處理evict、expire、dynamic load等算法邏輯,須要一些額外信息來實現這些操做。對此,根據面向對象思想,須要作方法與數據的關聯封裝。如圖5所示cache的內存數據模型,能夠看到,使用ReferenceEntry接口來封裝一個鍵值對,而用ValueReference來封裝Value值,之因此用Reference命令,是由於Cache要支持WeakReference Key和SoftReference、WeakReference value。
圖5 Guava Cache數據結構圖
ReferenceEntry是對一個鍵值對節點的抽象,它包含了key和值的ValueReference抽象類,Cache由多個Segment組成,而每一個Segment包含一個ReferenceEntry數組,每一個ReferenceEntry數組項都是一條ReferenceEntry鏈,且一個ReferenceEntry包含key、hash、valueReference、next字段。除了在ReferenceEntry數組項中組成的鏈,在一個Segment中,全部ReferenceEntry還組成access鏈(accessQueue)和write鏈(writeQueue)(後面會介紹鏈的做用)。ReferenceEntry能夠是強引用類型的key,也能夠WeakReference類型的key,爲了減小內存使用量,還能夠根據是否配置了expireAfterWrite、expireAfterAccess、maximumSize來決定是否須要write鏈和access鏈肯定要建立的具體Reference:StrongEntry、StrongWriteEntry、StrongAccessEntry、StrongWriteAccessEntry等。
對於ValueReference,由於Cache支持強引用的Value、SoftReference Value以及WeakReference Value,於是它對應三個實現類:StrongValueReference、SoftValueReference、WeakValueReference。爲了支持動態加載機制,它還有一個LoadingValueReference,在須要動態加載一個key的值時,先把該值封裝在LoadingValueReference中,以表達該key對應的值已經在加載了,若是其餘線程也要查詢該key對應的值,就能獲得該引用,而且等待改值加載完成,從而保證該值只被加載一次,在該值加載完成後,將LoadingValueReference替換成其餘ValueReference類型。ValueReference對象中會保留對ReferenceEntry的引用,這是由於在Value由於WeakReference、SoftReference被回收時,須要使用其key將對應的項從Segment的table中移除。
WriteQueue和AccessQueue :爲了實現最近最少使用算法,Guava Cache在Segment中添加了兩條鏈:write鏈(writeQueue)和access鏈(accessQueue),這兩條鏈都是一個雙向鏈表,經過ReferenceEntry中的previousInWriteQueue、nextInWriteQueue和previousInAccessQueue、nextInAccessQueue連接而成,可是以Queue的形式表達。WriteQueue和AccessQueue都是自定義了offer、add(直接調用offer)、remove、poll等操做的邏輯,對offer(add)操做,若是是新加的節點,則直接加入到該鏈的結尾,若是是已存在的節點,則將該節點連接的鏈尾;對remove操做,直接從該鏈中移除該節點;對poll操做,將頭節點的下一個節點移除,並返回。
瞭解了cache的總體數據結構後,再來看下針對緩存的相關操做就簡單多了:
Guava Cache提供Builder模式的CacheBuilder生成器來建立緩存的方式,十分方便,而且各個緩存參數的配置設置,相似於函數式編程的寫法,可自行設置各種參數選型。它提供三種方式加載到緩存中。分別是:
build生成器的兩種方式都實現了一種邏輯:從緩存中取key的值,若是該值已經緩存過了則返回緩存中的值,若是沒有緩存過能夠經過某個方法來獲取這個值,不一樣的地方在於cacheloader的定義比較寬泛,是針對整個cache定義的,能夠認爲是統一的根據key值load value的方法,而callable的方式較爲靈活,容許你在get的時候指定load方法。使用示例以下:
/** * CacheLoader */ public void loadingCache() { LoadingCache<String, String> graphs =CacheBuilder.newBuilder() .maximumSize(1000).build(new CacheLoader<String, String>() { @Override public String load(String key) throws Exception { System.out.println("key:"+key); if("key".equals(key)){ return "key return result"; }else{ return "get-if-absent-compute"; } } }); String resultVal = null; try { resultVal = graphs.get("key"); } catch (ExecutionException e) { e.printStackTrace(); } System.out.println(resultVal); } /** * * Callable */ public void callablex() throws ExecutionException { Cache<String, String> cache = CacheBuilder.newBuilder() .maximumSize(1000).build(); String result = cache.get("key", new Callable<String>() { public String call() { return "result"; } }); System.out.println(result); }
整體來看,Guava Cache基於ConcurrentHashMap的優秀設計借鑑,在高併發場景支持和線程安全上都有相應的改進策略,使用Reference引用命令,提高高併發下的數據……訪問速度並保持了GC的可回收,有效節省空間;同時,write鏈和access鏈的設計,能更靈活、高效的實現多種類型的緩存清理策略,包括基於容量的清理、基於時間的清理、基於引用的清理等;編程式的build生成器管理,讓使用者有更多的自由度,可以根據不一樣場景設置合適的模式。
memcached是應用較廣的開源分佈式緩存產品之一,它自己其實不提供分佈式解決方案。在服務端,memcached集羣環境實際就是一個個memcached服務器的堆積,環境搭建較爲簡單;cache的分佈式主要是在客戶端實現,經過客戶端的路由處理來達到分佈式解決方案的目的。客戶端作路由的原理很是簡單,應用服務器在每次存取某key的value時,經過某種算法把key映射到某臺memcached服務器nodeA上,所以這個key全部操做都在nodeA上,結構圖如圖六、圖7所示。
圖7 memcached一致性hash示例圖
memcached客戶端採用一致性hash算法做爲路由策略,如圖7,相對於通常hash(如簡單取模)的算法,一致性hash算法除了計算key的hash值外,還會計算每一個server對應的hash值,而後將這些hash值映射到一個有限的值域上(好比0~2^32)。經過尋找hash值大於hash(key)的最小server做爲存儲該key數據的目標server。若是找不到,則直接把具備最小hash值的server做爲目標server。同時,必定程度上,解決了擴容問題,增長或刪除單個節點,對於整個集羣來講,不會有大的影響。最近版本,增長了虛擬節點的設計,進一步提高了可用性。
memcached是一個高效的分佈式內存cache,瞭解memcached的內存管理機制,才能更好的掌握memcached,讓咱們能夠針對咱們數據特色進行調優,讓其更好的爲我所用。咱們知道memcached僅支持基礎的key-value鍵值對類型數據存儲。在memcached內存結構中有兩個很是重要的概念:slab和chunk。如圖8所示。
圖8 memcached內存結構圖
slab是一個內存塊,它是memcached一次申請內存的最小單位。在啓動memcached的時候通常會使用參數-m指定其可用內存,可是並非在啓動的那一刻全部的內存就所有分配出去了,只有在須要的時候纔會去申請,並且每次申請必定是一個slab。Slab的大小固定爲1M(1048576 Byte),一個slab由若干個大小相等的chunk組成。每一個chunk中都保存了一個item結構體、一對key和value。
雖然在同一個slab中chunk的大小相等的,可是在不一樣的slab中chunk的大小並不必定相等,在memcached中按照chunk的大小不一樣,能夠把slab分爲不少種類(class),默認狀況下memcached把slab分爲40類(class1~class40),在class 1中,chunk的大小爲80字節,因爲一個slab的大小是固定的1048576字節(1M),所以在class1中最多能夠有13107個chunk(也就是這個slab能存最多13107個小於80字節的key-value數據)。
memcached內存管理採起預分配、分組管理的方式,分組管理就是咱們上面提到的slab class,按照chunk的大小slab被分爲不少種類。內存預分配過程是怎樣的呢?向memcached添加一個item時候,memcached首先會根據item的大小,來選擇最合適的slab class:例如item的大小爲190字節,默認狀況下class 4的chunk大小爲160字節顯然不合適,class 5的chunk大小爲200字節,大於190字節,所以該item將放在class 5中(顯然這裏會有10字節的浪費是不可避免的),計算好所要放入的chunk以後,memcached會去檢查該類大小的chunk還有沒有空閒的,若是沒有,將會申請1M(1個slab)的空間並劃分爲該種類chunk。例如咱們第一次向memcached中放入一個190字節的item時,memcached會產生一個slab class 2(也叫一個page),並會用去一個chunk,剩餘5241個chunk供下次有適合大小item時使用,當咱們用完這全部的5242個chunk以後,下次再有一個在160~200字節之間的item添加進來時,memcached會再次產生一個class 5的slab(這樣就存在了2個pages)。
總結來看,memcached內存管理須要注意的幾個方面:
對於key-value信息,最好不要超過1m的大小;同時信息長度最好相對是比較均衡穩定的,這樣可以保障最大限度的使用內存;同時,memcached採用的LRU清理策略,合理甚至過時時間,提升命中率。
無特殊場景下,key-value能知足需求的前提下,使用memcached分佈式集羣是較好的選擇,搭建與操做使用都比較簡單;分佈式集羣在單點故障時,隻影響小部分數據異常,目前還能夠經過Magent緩存代理模式,作單點備份,提高高可用;整個緩存都是基於內存的,所以響應時間是很快,不須要額外的序列化、反序列化的程序,但同時因爲基於內存,數據沒有持久化,集羣故障重啓數據沒法恢復。高版本的memcached已經支持CAS模式的原子操做,能夠低成本的解決併發控制問題。
Redis是一個遠程內存數據庫(非關係型數據庫),性能強勁,具備複製特性以及解決問題而生的獨一無二的數據模型。它能夠存儲鍵值對與5種不一樣類型的值之間的映射,能夠將存儲在內存的鍵值對數據持久化到硬盤,可使用複製特性來擴展讀性能,還可使用客戶端分片來擴展寫性能。
圖9 Redis數據模型圖
如圖9,Redis內部使用一個redisObject對象來標識全部的key和value數據,redisObject最主要的信息如圖所示:type表明一個value對象具體是何種數據類型,encoding是不一樣數據類型在Redis內部的存儲方式,好比——type=string表明value存儲的是一個普通字符串,那麼對應的encoding能夠是raw或是int,若是是int則表明世界Redis內部是按數值類型存儲和表示這個字符串。
圖9左邊的raw列爲對象的編碼方式:字符串能夠被編碼爲raw(通常字符串)或Rint(爲了節約內存,Redis會將字符串表示的64位有符號整數編碼爲整數來進行儲存);列表能夠被編碼爲ziplist或linkedlist,ziplist是爲節約大小較小的列表空間而做的特殊表示;集合能夠被編碼爲intset或者hashtable,intset是隻儲存數字的小集合的特殊表示;hash表能夠編碼爲zipmap或者hashtable,zipmap是小hash表的特殊表示;有序集合能夠被編碼爲ziplist或者skiplist格式,ziplist用於表示小的有序集合,而skiplist則用於表示任何大小的有序集合。
從網絡I/O模型上看,Redis使用單線程的I/O複用模型,本身封裝了一個簡單的AeEvent事件處理框架,主要實現了epoll、kqueue和select。對於單純只有I/O操做來講,單線程能夠將速度優點發揮到最大,可是Redis也提供了一些簡單的計算功能,好比排序、聚合等,對於這些操做,單線程模型實際會嚴重影響總體吞吐量,CPU計算過程當中,整個I/O調度都是被阻塞住的,在這些特殊場景的使用中,須要額外的考慮。相較於memcached的預分配內存管理,Redis使用現場申請內存的方式來存儲數據,而且不多使用free-list等方式來優化內存分配,會在必定程度上存在內存碎片。Redis跟據存儲命令參數,會把帶過時時間的數據單獨存放在一塊兒,並把它們稱爲臨時數據,非臨時數據是永遠不會被剔除的,即使物理內存不夠,致使swap也不會剔除任何非臨時數據(但會嘗試剔除部分臨時數據)。
咱們描述Redis爲內存數據庫,做爲緩存服務,大量使用內存間的數據快速讀寫,支持高併發大吞吐;而做爲數據庫,則是指Redis對緩存的持久化支持。Redis因爲支持了很是豐富的內存數據庫結構類型,如何把這些複雜的內存組織方式持久化到磁盤上?Redis的持久化與傳統數據庫的方式差別較大,Redis一共支持四種持久化方式,主要使用的兩種:
aof的方式的主要缺點是追加log文件可能致使體積過大,當系統重啓恢復數據時若是是aof的方式則加載數據會很是慢,幾十G的數據可能須要幾小時才能加載完,固然這個耗時並非由於磁盤文件讀取速度慢,而是因爲讀取的全部命令都要在內存中執行一遍。另外因爲每條命令都要寫log,因此使用aof的方式,Redis的讀寫性能也會有所降低。
Redis的持久化使用了Buffer I/O,所謂Buffer I/O是指Redis對持久化文件的寫入和讀取操做都會使用物理內存的Page Cache,而大多數數據庫系統會使用Direct I/O來繞過這層Page Cache並自行維護一個數據的Cache。而當Redis的持久化文件過大(尤爲是快照文件),並對其進行讀寫時,磁盤文件中的數據都會被加載到物理內存中做爲操做系統對該文件的一層Cache,而這層Cache的數據與Redis內存中管理的數據實際是重複存儲的。雖然內核在物理內存緊張時會作Page Cache的剔除工做,但內核極可能認爲某塊Page Cache更重要,而讓你的進程開始Swap,這時你的系統就會開始出現不穩定或者崩潰了,所以在持久化配置後,針對內存使用須要實時監控觀察。
與memcached客戶端支持分佈式方案不一樣,Redis更傾向於在服務端構建分佈式存儲,如圖十、11。
圖11 Redis分佈式集羣圖2
Redis Cluster是一個實現了分佈式且容許單點故障的Redis高級版本,它沒有中心節點,具備線性可伸縮的功能。如圖11,其中節點與節點之間經過二進制協議進行通訊,節點與客戶端之間經過ascii協議進行通訊。在數據的放置策略上,Redis Cluster將整個key的數值域分紅4096個hash槽,每一個節點上能夠存儲一個或多個hash槽,也就是說當前Redis Cluster支持的最大節點數就是4096。Redis Cluster使用的分佈式算法也很簡單:crc16( key ) % HASH_SLOTS_NUMBER。總體設計可總結爲:
能夠看到,經過集羣+主從結合的設計,Redis在擴展和穩定高可用性能方面都是比較成熟的。可是,在數據一致性問題上,Redis沒有提供CAS操做命令來保障高併發場景下的數據一致性問題,不過它卻提供了事務的功能,Redis的Transactions提供的並非嚴格的ACID的事務(好比一串用EXEC提交執行的命令,在執行中服務器宕機,那麼會有一部分命令執行了,剩下的沒執行)。可是這個Transactions仍是提供了基本的命令打包執行的功能(在服務器不出問題的狀況下,能夠保證一連串的命令是順序在一塊兒執行的,中間有會有其它客戶端命令插進來執行)。Redis還提供了一個Watch功能,你能夠對一個key進行Watch,而後再執行Transactions,在這過程當中,若是這個Watched的值進行了修改,那麼這個Transactions會發現並拒絕執行。在失效策略上,Redis支持多大6種的數據淘汰策略:
我的總結了如下多種Web應用場景,在這些場景下能夠充分的利用Redis的特性,大大提升效率。
實際工程中,對於緩存的應用能夠有多種的實戰方式,包括侵入式硬編碼,抽象服務化應用,以及輕量的註解式使用等。本文將主要介紹下註解式方式。
Spring 3.1以後,引入了註解緩存技術,其本質上不是一個具體的緩存實現方案,而是一個對緩存使用的抽象,經過在既有代碼中添加少許自定義的各類annotation,即可以達到使用緩存對象和緩存方法的返回對象的效果。Spring的緩存技術具有至關的靈活性,不只可以使用SpEL(Spring Expression Language)來定義緩存的key和各類condition,還提供開箱即用的緩存臨時存儲方案,也支持和主流的專業緩存集成。其特色總結以下:
和Spring的事務管理相似,Spring Cache的關鍵原理就是Spring AOP,經過Spring AOP實現了在方法調用前、調用後獲取方法的入參和返回值,進而實現了緩存的邏輯。而Spring Cache利用了Spring AOP的動態代理技術,即當客戶端嘗試調用pojo的foo()方法的時候,給它的不是pojo自身的引用,而是一個動態生成的代理類。
圖12 Spring動態代理調用圖
如圖12所示,實際客戶端獲取的是一個代理的引用,在調用foo()方法的時候,會首先調用proxy的foo()方法,這個時候proxy能夠總體控制實際的pojo.foo()方法的入參和返回值,好比緩存結果,好比直接略過執行實際的foo()方法等,都是能夠輕鬆作到的。Spring Cache主要使用三個註釋標籤,即@Cacheable、@CachePut和@CacheEvict,主要針對方法上註解使用,部分場景也能夠直接類上註解使用,當在類上使用時,該類全部方法都將受影響。咱們總結一下其做用和配置方法,如表1所示。
表1
標籤類型 | 做用 | 主要配置參數說明 |
---|---|---|
@Cacheable | 主要針對方法配置,可以根據方法的請求參數對其結果進行緩存 | value:緩存的名稱,在 Spring 配置文件中定義,必須指定至少一個; key:緩存的 key,能夠爲空,若是指定要按照 SpEL 表達式編寫,若是不指定,則默認按照方法的全部參數進行組合; condition:緩存的條件,能夠爲空,使用 SpEL 編寫,返回 true 或者 false,只有爲 true 才進行緩存 |
@CachePut | 主要針對方法配置,可以根據方法的請求參數對其結果進行緩存,和 @Cacheable 不一樣的是,它每次都會觸發真實方法的調用 | value:緩存的名稱,在 spring 配置文件中定義,必須指定至少一個; key:緩存的 key,能夠爲空,若是指定要按照 SpEL 表達式編寫,若是不指定,則默認按照方法的全部參數進行組合; condition:緩存的條件,能夠爲空,使用 SpEL 編寫,返回 true 或者 false,只有爲 true 才進行緩存 |
@CacheEvict | 主要針對方法配置,可以根據必定的條件對緩存進行清空 | value:緩存的名稱,在 Spring 配置文件中定義,必須指定至少一個; key:緩存的 key,能夠爲空,若是指定要按照 SpEL 表達式編寫,若是不指定,則默認按照方法的全部參數進行組合; condition:緩存的條件,能夠爲空,使用 SpEL 編寫,返回 true 或者 false,只有爲 true 才進行緩存; allEntries:是否清空全部緩存內容,默認爲 false,若是指定爲 true,則方法調用後將當即清空全部緩存; beforeInvocation:是否在方法執行前就清空,默認爲 false,若是指定爲 true,則在方法尚未執行的時候就清空緩存,默認狀況下,若是方法執行拋出異常,則不會清空緩存 |
可擴展支持:Spring註解cache可以知足通常應用對緩存的需求,但隨着應用服務的複雜化,大併發高可用性能要求下,須要進行必定的擴展,這時對其自身集成的緩存方案可能不太適用,該怎麼辦?Spring預先有考慮到這點,那麼怎樣利用Spring提供的擴展點實現咱們本身的緩存,且在不改變原來已有代碼的狀況下進行擴展?是否在方法執行前就清空,默認爲false,若是指定爲true,則在方法尚未執行的時候就清空緩存,默認狀況下,若是方法執行拋出異常,則不會清空緩存。
這基本可以知足通常應用對緩存的需求,但現實老是很複雜,當你的用戶量上去或者性能跟不上,總須要進行擴展,這個時候你或許對其提供的內存緩存不滿意了,由於其不支持高可用性,也不具有持久化數據能力,這個時候,你就須要自定義你的緩存方案了,還好,Spring也想到了這一點。
咱們先不考慮如何持久化緩存,畢竟這種第三方的實現方案不少,咱們要考慮的是,怎麼利用Spring提供的擴展點實現咱們本身的緩存,且在不改原來已有代碼的狀況下進行擴展。這須要簡單的三步驟,首先須要提供一個CacheManager接口的實現(繼承至AbstractCacheManager),管理自身的cache實例;其次,實現本身的cache實例MyCache(繼承至Cache),在這裏面引入咱們須要的第三方cache或自定義cache;最後就是對配置項進行聲明,將MyCache實例注入CacheManager進行統一管理。
註解緩存的使用,能夠有效加強應用代碼的可讀性,同時統一管理緩存,提供較好的可擴展性,爲此,酒店商家端在Spring註解緩存基礎上,自定義了適合自身業務特性的註解緩存。
主要使用兩個標籤,即@HotelCacheable、@HotelCacheEvict,其做用和配置方法見表2。
表2
標籤類型 | 做用 | 主要配置參數說明 |
---|---|---|
@HotelCacheable | 主要針對方法配置,可以根據方法的請求參數對其結果進行緩存 | domain:做用域,針對集合場景,解決批量更新問題; domainKey:做用域對應的緩存key; key:緩存對象key 前綴; fieldKey:緩存對象key,與前綴合並生成對象key; condition:緩存獲取前置條件,支持spel語法; cacheCondition:緩存刷入前置條件,支持spel語法; expireTime:超時時間設置 |
@HotelCacheEvict | 主要針對方法配置,可以根據必定的條件對緩存進行清空 | 同上 |
增長做用域的概念,解決商家信息變動下,多重重要信息實時更新的問題。
圖13 域緩存處理圖
如圖13,按舊的方案,當cache0發送變化時,爲了保持信息的實時更新,須要手動刪除cache一、cache二、cache3等相關處的緩存數據。增長域緩存概念,cache0、cache一、cache二、cache3是以帳號ID爲基礎,相互存在影響約束的集合體,咱們做爲一個域集合,增長域緩存處理,當cache0發送變化時,總體的帳號ID domain域已發生更新,自動影響cache一、cache二、cache3等處的緩存數據。將相關聯邏輯緩存統一化,有效提高代碼可讀性,同時更好服務業務,帳號重點信息可以實時變動刷新,相關服務響應速度提高。
另外,增長了cacheCondition緩存刷入前置判斷,有效解決商家業務多重外部依賴場景下,業務降級有損服務下,業務數據一致性保證,不由於緩存的增長影響業務的準確性;自定義CacheManager緩存管理器,能夠有效兼容公共基礎組件Medis、Cellar相關服務,在對應用程序不作改動的狀況下,有效切換緩存方式;同時,統一的緩存服務AOP入口,結合接入Mtconfig統一配置管理,對應用內緩存作好降級準備,一鍵關閉緩存。幾點建議:
總之,註釋驅動的Spring Cache可以極大的減小咱們編寫常見緩存的代碼量,經過少許的註釋標籤和配置文件,便可達到使代碼具有緩存的能力,且具有很好的靈活性和擴展性。可是咱們也應該看到,Spring Cache因爲基於Spring AOP技術,尤爲是動態的proxy技術,致使其不能很好的支持方法的內部調用或者非public方法的緩存設置,固然這些都是能夠解決的問題。
明輝,美團點評酒旅事業羣酒店住宿研發團隊B端商家業務平臺負責人,主導構建商家業務平臺系統,支撐美團點評酒店住宿業務的飛速發展需求。曾任職於聯想集團、百度。
本文轉自:https://tech.meituan.com/cache_about.html