本地/分散式快取概論
現在網際網路應用(網站/App)的整體流程,可概括如圖

圖1 網際網路應用一般流程
使用者請求從介面(瀏覽器/App)到網路轉發、應用服務再到儲存(資料庫或檔案系統),然後返回到介面呈現內容。
如圖1所示,快取的使用可以出現在1~4的各個環節中,每個環節的快取方案與使用各有特點。
1 快取特徵
快取是一個數據模型物件,有它的一些特徵
1.1 命中率
命中率=返回正確結果數/請求快取次數
命中率問題是快取中的一個非常重要的問題,它是衡量快取有效性的重要指標。命中率越高,表明快取的使用率越高。
1.2 最大元素(或最大空間)
快取中可以存放的最大元素的數量,一旦快取中元素數量超過這個值(或者快取資料所佔空間超過其最大支援空間)
那麼將會觸發 快取啟動清空策略
,根據不同的場景合理的設定最大元素值往往可以一定程度上提高快取的命中率,從而更有效的使用快取
1.3 清空策略
快取的儲存空間有限制,當快取空間被用滿時,如何保證在穩定服務的同時有效提升命中率?
這就由快取清空策略來處理
常見的一般策略有:
- FIFO(first in first out)
最先進入快取的資料在快取空間不夠的情況下(超出最大元素限制)會被優先被清除掉,以騰出新的空間接受新的資料
策略演算法主要比較快取元素的建立時間。在資料實效性要求場景下
可選擇該類策略,優先保障最新資料可用 - LFU(less frequently used)
無論是否過期,根據元素的被使用次數判斷,清除使用次數較少的元素釋放空間
策略演算法主要比較元素的hitCount(命中次數)。在保證高頻資料有效性場景下
,可選擇這類策略 - LRU(least recently used)
無論是否過期,根據元素最後一次被使用的時間戳,清除最遠使用時間戳的元素釋放空間
策略演算法主要比較元素最近一次被get使用時間。在熱點資料場景下較適用
,優先保證熱點資料的有效性
除此之外,還有一些簡單策略比如:
- 根據過期時間判斷,清理過期時間最長的元素
- 根據過期時間判斷,清理最近要過期的元素
- 隨機清理
- 根據關鍵字(或元素內容)長短清理等
2 快取介質
從硬體介質上來看,記憶體和硬碟
從技術上,可以分成記憶體、硬碟檔案、資料庫
- 記憶體: 將快取儲存於記憶體中是最快的選擇,無需額外的I/O開銷,但是記憶體的缺點是沒有持久化落地物理磁碟,一旦應用異常break down而重新啟動,資料很難或者無法復原
- 硬碟: 一般來說,很多快取框架會結合使用記憶體和硬碟,在記憶體分配空間滿了或是在異常的情況下,可以被動或主動的將記憶體空間資料持久化到硬碟中,達到釋放空間或備份資料的目的。
- 資料庫: 前面有提到,增加快取的策略的目的之一就是為了減少資料庫的I/O壓力。現在使用資料庫做快取介質是不是又回到了老問題上了?其實,資料庫也有很多種型別,像那些不支援SQL,只是簡單的key-value儲存結構的特殊資料庫(如BerkeleyDB和Redis),響應速度和吞吐量都遠遠高於我們常用的關係型資料庫等。
3 快取分類和應用場景
根據快取與應用的藕合度,分為local cache(本地快取)和remote cache(分散式快取)
- 本地快取 :指的是在應用中的快取元件,其最大的優點是應用和cache是在同一個程序內部,請求快取非常快速,沒有過多的網路開銷等,在單應用不需要叢集支援或者叢集情況下各節點無需互相通知的場景下使用本地快取較合適;同時,它的缺點也是應為快取跟應用程式耦合,多個應用程式無法直接的共享快取,各應用或叢集的各節點都需要維護自己的單獨快取,對記憶體是一種浪費。
- 分散式快取 :指的是與應用分離的快取元件或服務,其最大的優點是自身就是一個獨立的應用,與本地應用隔離,多個應用可直接的共享快取
3.1 本地快取
3.1.1 程式設計直接實現快取
個別場景下,我們只需要簡單的快取資料的功能,而無需關注更多存取、清空策略等深入的特性時,直接程式設計實現快取則是最便捷和高效的
- 成員變數或區域性變數實現
public void UseLocalCache(){ //一個本地的快取變數 Map<String, Object> localCacheStoreMap = new HashMap<String, Object>(); List<Object> infosList = this.getInfoList(); for(Object item:infosList){ if(localCacheStoreMap.containsKey(item)){ //快取命中 使用快取資料 // todo } else { // 快取未命中I/O獲取資料,結果存入快取 Object valueObject = this.getInfoFromDB(); localCacheStoreMap.put(valueObject.toString(), valueObject); } } } //示例 private List<Object> getInfoList(){ return new ArrayList<Object>(); } //示例資料庫I/O獲取 private Object getInfoFromDB(){ return new Object(); }
以區域性變數map結構快取部分業務資料,減少頻繁的重複資料庫I/O操作。缺點僅限於類的自身作用域內,類間無法共享快取。
-
靜態變數實現
最常用的單例實現靜態資源快取
public class CityUtils { private static final HttpClient httpClient = ServerHolder.createClientWithPool(); private static Map<Integer, String> cityIdNameMap = new HashMap<Integer, String>(); private static Map<Integer, String> districtIdNameMap = new HashMap<Integer, String>(); static { HttpGet get = new HttpGet("http://gis-in.sankuai.com/api/location/city/all"); BaseAuthorizationUtils.generateAuthAndDateHeader(get, BaseAuthorizationUtils.CLIENT_TO_REQUEST_MDC, BaseAuthorizationUtils.SECRET_TO_REQUEST_MDC); try { String resultStr = httpClient.execute(get, new BasicResponseHandler()); JSONObject resultJo = new JSONObject(resultStr); JSONArray dataJa = resultJo.getJSONArray("data"); for (int i = 0; i < dataJa.length(); i++) { JSONObject itemJo = dataJa.getJSONObject(i); cityIdNameMap.put(itemJo.getInt("id"), itemJo.getString("name")); } } catch (Exception e) { throw new RuntimeException("Init City List Error!", e); } } static { HttpGet get = new HttpGet("http://gis-in.sankuai.com/api/location/district/all"); BaseAuthorizationUtils.generateAuthAndDateHeader(get, BaseAuthorizationUtils.CLIENT_TO_REQUEST_MDC, BaseAuthorizationUtils.SECRET_TO_REQUEST_MDC); try { String resultStr = httpClient.execute(get, new BasicResponseHandler()); JSONObject resultJo = new JSONObject(resultStr); JSONArray dataJa = resultJo.getJSONArray("data"); for (int i = 0; i < dataJa.length(); i++) { JSONObject itemJo = dataJa.getJSONObject(i); districtIdNameMap.put(itemJo.getInt("id"), itemJo.getString("name")); } } catch (Exception e) { throw new RuntimeException("Init District List Error!", e); } } public static String getCityName(int cityId) { String name = cityIdNameMap.get(cityId); if (name == null) { name = "未知"; } return name; } public static String getDistrictName(int districtId) { String name = districtIdNameMap.get(districtId); if (name == null) { name = "未知"; } return name; } }
O2O業務中常用的城市基礎基本資訊判斷,通過 靜態變數一次獲取快取記憶體中,減少頻繁的I/O讀取
靜態變數實現類間可共享,程序內可共享,快取的實時性稍差
為了解決本地快取資料的實時性問題,目前大量使用的是結合ZooKeeper的自動發現機制,實時變更本地靜態變數快取:
美團的基礎配置元件MtConfig,採用的就是類似原理,使用靜態變數快取,結合ZooKeeper的統一管理,做到自動動態更新快取

圖2 Mtconfig實現圖
這類快取,優點是能直接在heap區內讀寫,最快也最方便
缺點同樣是受heap區域影響,快取的資料量非常有限,同時快取時間受GC影響
主要滿足單機場景下的小資料量快取需求,同時對快取資料的變更無需太敏感,如一般配置管理、基礎靜態資料等場景
3.1.2 Ehcache
Ehcache是現在最流行的純Java開源快取框架,配置簡單、結構清晰、功能強大,是一個非常輕量級的快取實現,我們常用的Hibernate裡面就集成了相關快取功能

圖3 Ehcache框架圖
從中我們可以瞭解到,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的左側部分描述。
整體資料流轉包括這樣幾類行為
- Flush:快取條目向低層次移動。
- Fault:從低層拷貝一個物件到高層。在獲取快取的過程中,某一層發現自己的該快取條目已經失效,就觸發了Fault行為。
- Eviction:把快取條目除去。
- Expiration:失效狀態。
- Pinning:強制快取條目保持在某一層。
圖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支援磁碟的持久化,但是由於存在兩級快取介質
在一級記憶體中的快取,如果沒有主動的刷入磁碟持久化的話,在應用異常down機等情形下,依然會出現快取資料丟失
為此可以根據需要將快取刷到磁碟,將快取條目刷到磁碟的操作可以通過cache.flush()方法來執行,需要注意的是,對於物件的磁碟寫入,前提是要將物件進行序列化。
主要特性:
- 快速,針對大型高併發系統場景,Ehcache的多執行緒機制有相應的優化改善
- 簡單,很小的jar包,簡單配置就可直接使用,單機場景下無需過多的其他服務依賴
- 支援多種的快取策略,靈活
- 快取資料有兩級:記憶體和磁碟,與一般的本地記憶體快取相比,有了磁碟的儲存空間,將可以支援更大量的資料快取需求
- 具有快取和快取管理器的監聽介面,能更簡單方便的進行快取例項的監控管理
- 支援多快取管理器例項,以及一個例項的多個快取區域
Ehcache的超時設定主要是針對整個cache例項設定整體的超時策略,而沒有較好的處理針對單獨的key的個性的超時設定
因此,在使用中要注意過期失效的快取元素無法被GC回收,時間越長快取越多,記憶體佔用也就越大,記憶體洩露的概率也越大
3.1.3 Guava Cache
Guava Cache是Google開源的Java重用工具集庫Guava裡的一款快取工具,實現的快取功能有:
- 自動將entry節點載入進快取結構中
- 當快取的資料超過設定的最大值時,使用LRU演算法移除
- 具備根據entry節點上次被訪問或者寫入時間計算它的過期機制
- 快取的key被封裝在WeakReference引用內
- 快取的Value被封裝在WeakReference或SoftReference引用內
- 統計快取使用過程中命中率、異常率、未命中率等統計資料
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的整體結構後,再來看下針對快取的相關操作就簡單多了:
- Segment中的evict清除策略操作,是在
每一次呼叫操作的開始和結束時觸發清理工作
這樣比一般的快取另起執行緒監控清理相比,可以減少開銷
,但如果長時間沒有呼叫方法的話,會導致不能及時的清理釋放記憶體空間的問題
evict主要處理四個Queue:1. keyReferenceQueue;2. valueReferenceQueue;3. writeQueue;4. accessQueue
前兩個queue是因為WeakReference、SoftReference被垃圾回收時加入的,清理時只需要遍歷整個queue,將對應的項從LocalCache中移除即可,這裡keyReferenceQueue存放ReferenceEntry,而valueReferenceQueue存放的是ValueReference,要從Cache中移除需要有key,因而ValueReference需要有對ReferenceEntry的引用,這個前面也提到過了
而對後面兩個Queue,只需要檢查是否配置了相應的expire時間,然後從頭開始查詢已經expire的Entry,將它們移除即可 - Segment中的put操作:put操作相對比較簡單,首先它需要獲得鎖,然後嘗試做一些清理工作,接下來的邏輯類似ConcurrentHashMap中的rehash,查詢位置並注入資料。需要說明的是當找到一個已存在的Entry時,需要先判斷當前的ValueRefernece中的值事實上已經被回收了,因為它們可以是WeakReference、SoftReference型別,如果已經被回收了,則將新值寫入。並且在每次更新時註冊當前操作引起的移除事件,指定相應的原因:COLLECTED、REPLACED等,這些註冊的事件在退出的時候統一呼叫Cache註冊的RemovalListener,由於事件處理可能會有很長時間,因而這裡將事件處理的邏輯在退出鎖以後才做。最後,在更新已存在的Entry結束後都嘗試著將那些已經expire的Entry移除。另外put操作中還需要更新writeQueue和accessQueue的語義正確性。
- Segment帶CacheLoader的get操作
V get(K key, int hash, CacheLoader<? super K, V> loader) throws ExecutionException { checkNotNull(key); checkNotNull(loader); try { if (count != 0) { // read-volatile // don't call getLiveEntry, which would ignore loading values ReferenceEntry<K, V> e = getEntry(key, hash); if (e != null) { long now = map.ticker.read(); V value = getLiveValue(e, now); if (value != null) { recordRead(e, now); statsCounter.recordHits(1); return scheduleRefresh(e, key, hash, value, now, loader); } ValueReference<K, V> valueReference = e.getValueReference(); if (valueReference.isLoading()) { return waitForLoadingValue(e, key, valueReference); } } } // at this point e is either null or expired; return lockedGetOrLoad(key, hash, loader); } catch (ExecutionException ee) { Throwable cause = ee.getCause(); if (cause instanceof Error) { throw new ExecutionError((Error) cause); } else if (cause instanceof RuntimeException) { throw new UncheckedExecutionException(cause); } throw ee; } finally { postReadCleanup(); } }
1. 先查詢table中是否已存在沒有被回收、也沒有expire的entry,如果找到,並在CacheBuilder中配置了refreshAfterWrite,並且當前時間間隔已經操作這個事件,則重新載入值,否則,直接返回原有的值
2. 如果查詢到的ValueReference是LoadingValueReference,則等待該LoadingValueReference載入結束,並返回載入的值

3. 如果沒有找到entry,或者找到的entry的值為null,則加鎖後,繼續在table中查詢已存在key對應的entry,如果找到並且對應的entry.isLoading()為true,則表示有另一個執行緒正在載入,因而等待那個執行緒載入完成,如果找到一個非null值,返回該值,否則建立一個LoadingValueReference

並呼叫loadSync載入相應的值

在載入完成後,將新載入的值更新到table中,即大部分情況下替換原來的LoadingValueReference
Guava Cache提供Builder模式的CacheBuilder生成器來建立快取的方式,十分方便,並且各個快取引數的配置設定,類似於函數語言程式設計的寫法,可自行設定各類引數選型。
它提供三種方式載入到快取中
- 在構建快取的時候,使用build方法內部呼叫CacheLoader方法載入資料;
- callable 、callback方式載入資料;
- 使用粗暴直接的方式,直接Cache.put 載入資料,但自動載入是首選的,因為它可以更容易的推斷所有快取內容的一致性。
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生成器管理,讓使用者有更多的自由度,能夠根據不同場景設定合適的模式。
3.2 分散式快取
3.2.1 memcached快取
memcached是應用較廣的開源分散式快取產品之一,它本身其實不提供分散式解決方案。
在服務端,memcached叢集環境實際就是一個個memcached伺服器的堆積,環境搭建較為簡單
cache的分散式主要是在客戶端實現,通過客戶端的路由處理來達到分散式解決方案
客戶端做路由的原理非常簡單:
應用伺服器在每次存取某key的value時,通過某種演算法把key對映到某臺memcached伺服器nodeA上,因此這個key所有操作都在nodeA上

圖6 memcached客戶端路由圖

圖7 memcached一致性hash示例圖
一致性hash演算法作為路由策略
相對於一般hash演算法,一致性hash除了計算key的hash值外,還會計算每個server對應的hash值,然後將這些hash值對映到一個有限的值域上(比如0~2^32)。
- 通過尋找hash值大於hash(key)的最小server作為儲存該key資料的目標server
- 如果找不到,則直接把具有最小hash值的server作為目標server
同時,一定程度上,解決了擴容問題,增加或刪除單個節點,對於整個叢集來說,不會有大的影響
最近版本,增加了虛擬節點的設計,進一步提升了可用性。
memcached僅支援基礎的key-value鍵值對型別資料儲存
在memcached記憶體結構中有兩個非常重要的概念:slab和chunk

圖8 memcached記憶體結構圖
memcached一次申請記憶體的最小單位
在啟動memcached的時候一般會使用引數-m指定其可用記憶體,但並非在啟動的那一刻所有的記憶體就全部分配,只有在需要的時候才會去申請,而且每次申請一定是一個slab
slab的大小固定為1M,一個slab由若干個大小相等的chunk組成
每個chunk中都儲存了一個item結構體、一對key/value
雖然在同一個slab中chunk的大小相等的, 但是在不同的slab中chunk的大小並不一定相等
在memcached中按照chunk的大小不同,可以把slab分為很多種類(class),預設情況下memcached把slab分為40類(class1~class40),在class 1中,chunk的大小為80位元組,由於一個slab的大小是固定的1M,因此在class1中最多可以有13107個chunk(也就是這個slab能存最多13107個小於80位元組的key-value資料)
memcached 記憶體管理採取預分配、分組管理
- 分組管理就是我們上面提到的slab class,按照chunk的大小slab被分為很多種類
- 記憶體預分配過程是怎樣的呢?
向memcached新增一個item時候,memcached首先會根據item的大小,來選擇最合適的slab class
計算好所要放入的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記憶體管理需要注意
- chunk是在page裡面劃分的,page固定為1m,所以chunk最大不能超過1m
- chunk實際佔用記憶體要加48B,因為chunk資料結構本身需要佔用48B
- 如果使用者資料大於1m,則memcached會將其切割,放到多個chunk內
- 已分配出去的page不能回收
對於key/value資訊,最好不要超過1m的大小
同時資訊長度最好相對是比較均衡穩定的,這樣能夠保障最大限度的使用記憶體
同時,memcached採用的LRU清理策略,合理甚至過期時間,提高命中率
無特殊場景下,key-value能滿足需求的前提下,使用memcached分散式叢集是較好的選擇,搭建與操作使用都比較簡單
分散式叢集在單點故障時,隻影響小部分資料異常,目前還可以通過Magent快取代理模式,做單點備份,提升高可用
整個快取都是基於記憶體的,因此響應時間是很快,不需要額外的序列化、反序列化的程式,但同時由於基於記憶體,資料沒有持久化,叢集故障重啟資料無法恢復
高版本的memcached已經支援CAS模式的原子操作,可以低成本的解決併發控制問題
3.2.2 Redis快取
Redis是一個遠端記憶體資料庫(非關係型資料庫),效能強勁,具有複製特性以及解決問題而生的獨一無二的資料模型

圖9 Redis資料模型圖
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一共支援四種持久化方式,主要使用的兩種:
- 定時快照方式(snapshot): 該持久化方式實際是在Redis內部一個定時器事件,每隔固定時間去檢查當前資料發生的改變次數與時間是否滿足配置的持久化觸發的條件,如果滿足則通過作業系統fork呼叫來創建出一個子程序,這個子程序預設會與父程序共享相同的地址空間,這時就可以通過子程序來遍歷整個記憶體來進行儲存操作,而主程序則仍然可以提供服務,當有寫入時由作業系統按照記憶體頁(page)為單位來進行copy-on-write保證父子程序之間不會互相影響。它的缺點是快照只是代表一段時間內的記憶體映像,所以系統重啟會丟失上次快照與重啟之間所有的資料。
- 基於語句追加檔案的方式(aof): aof方式實際類似MySQl的基於語句的binlog方式,即每條會使Redis記憶體資料發生改變的命令都會追加到一個log檔案中,也就是說這個log檔案就是Redis的持久化資料。
aof的方式的主要缺點是追加log檔案可能導致體積過大,當系統重啟恢復資料時如果是aof的方式則載入資料會非常慢,幾十G的資料可能需要幾小時才能載入完,當然這個耗時並不是因為磁碟檔案讀取速度慢,而是由於讀取的所有命令都要在記憶體中執行一遍。另外由於每條命令都要寫log,所以使用aof的方式,Redis的讀寫效能也會有所下降。
Redis的持久化使用了Buffer I/O,對持久化檔案的寫入和讀取操作都會使用實體記憶體的Page Cache,而大多數資料庫系統會使用Direct I/O來繞過這層Page Cache並自行維護一個數據的Cache
而當Redis的持久化檔案過大,並對其進行讀寫時,磁碟檔案中的資料都會被載入到實體記憶體中作為作業系統對該檔案的一層Cache,而這層Cache的資料與Redis記憶體中管理的資料實際是重複儲存的
雖然核心在實體記憶體緊張時會做Page Cache的剔除工作,但核心很可能認為某塊Page Cache更重要,而讓你的程序開始Swap,這時你的系統就會開始出現不穩定或者崩潰了,因此在持久化配置後,針對記憶體使用需要實時監控觀察
與memcached客戶端支援分散式方案不同,Redis更傾向於在服務端構建分散式儲存

圖10 Redis分散式叢集圖1

圖11 Redis分散式叢集圖2
Redis Cluster是一個實現了分散式且允許單點故障的Redis高階版本,它沒有中心節點,具有線性可伸縮的功能。如圖11,其中節點與節點之間通過二進位制協議進行通訊,節點與客戶端之間通過ascii協議進行通訊
在資料的放置策略上,Redis Cluster將整個key的數值域分成4096個hash槽,每個節點上可以儲存一個或多個hash槽,也就是說當前Redis Cluster支援的最大節點數就是4096
Redis Cluster使用的分散式演算法也很簡單:crc16( key ) % HASH_SLOTS_NUMBER
整體設計可總結為:
- 資料hash分佈在不同的Redis節點例項上
- M/S的切換採用Sentinel
- 寫:只會寫master Instance,從sentinel獲取當前的master Instance
- 讀:從Redis Node中基於權重選取一個Redis Instance讀取,失敗/超時則輪詢其他Instance;Redis本身就很好的支援讀寫分離,在單程序的I/O場景下,可以有效的避免主庫的阻塞風險
- 通過RPC服務訪問,RPC server端封裝了Redis客戶端,客戶端基於Jedis開發
Redis沒有提供CAS操作命令來保障高併發場景下的資料一致性問題,不過它卻提供了事務的功能
Redis的Transactions提供的並不是嚴格的ACID的事務(比如一串用EXEC提交執行的命令,在執行中伺服器宕機,那麼會有一部分命令執行了,剩下的沒執行)。
但是這個Transactions還是提供了基本的命令打包執行的功能(在伺服器不出問題的情況下,可以保證一連串的命令是順序在一起執行的,中間有會有其它客戶端命令插進來執行)
Redis還提供了一個Watch功能,你可以對一個key進行Watch,然後再執行Transactions,在這過程中,如果這個Watched的值進行了修改,那麼這個Transactions會發現並拒絕執行
在失效策略上,Redis支援多達6種的資料淘汰策略
- volatile-lru:從已設定過期時間的資料集(server.db[i].expires)中挑選最近最少使用的資料淘汰
- volatile-ttl:從已設定過期時間的資料集(server.db[i].expires)中挑選將要過期的資料淘汰
- volatile-random:從已設定過期時間的資料集(server.db[i].expires)中任意選擇資料淘汰
- allkeys-lru:從資料集(server.db[i].dict)中挑選最近最少使用的資料淘汰;
- allkeys-random:從資料集(server.db[i].dict)中任意選擇資料淘汰;
- no-enviction(驅逐):禁止驅逐資料。
以下多種Web應用場景,可以充分的利用Redis的特性,大大提高效率
-
-
在主頁中顯示最新的專案列表
Redis使用的是常駐記憶體的快取,速度非常快
- LPUSH用來插入一個內容ID,作為關鍵字儲存在列表頭部
- LTRIM用來限制列表中的專案數最多為5000
如果使用者需要的檢索的資料量超越這個快取容量,這時才需要把請求傳送到資料庫
-
-
-
刪除和過濾
如果一篇文章被刪除,可以使用LREM從快取中徹底清除掉
-
-
-
排行榜及相關問題
排行榜(leader board)按照得分進行排序
- ZADD命令可以直接實現這個功能
- ZREVRANGE命令可以用來按照得分來獲取前100名的使用者
- ZRANK可以用來獲取使用者排名,非常直接而且操作容易
-
-
-
按照使用者投票和時間排序
排行榜,得分會隨著時間變化。
LPUSH和LTRIM命令結合運用,把文章新增到一個列表中
一項後臺任務用來獲取列表,並重新計算列表的排序,ZADD命令用來按照新的順序填充生成列表。列表可以實現非常快速的檢索,即使是負載很重的站點。
-
-
-
過期專案處理
使用Unix時間作為關鍵字,用來保持列表能夠按時間排序。對current_time和time_to_live進行檢索,完成查詢過期專案的艱鉅任務。另一項後臺任務使用ZRANGE…WITHSCORES進行查詢,刪除過期的條目。
-
-
-
計數
進行各種資料統計的用途是非常廣泛的,比如想知道什麼時候封鎖一個IP地址
INCRBY命令讓這些變得很容易,通過原子遞增保持計數
GETSET用來重置計數器
過期屬性用來確認一個關鍵字什麼時候應該刪除
-
-
-
特定時間內的特定專案
這是特定訪問者的問題,可以通過給每次頁面瀏覽使用SADD命令來解決
SADD不會將已經存在的成員新增到一個集合。
-
-
-
Pub/Sub
在更新中保持使用者對資料的對映是系統中的一個普遍任務。Redis的pub/sub功能使用了SUBSCRIBE、UNSUBSCRIBE和PUBLISH命令,讓這個變得更加容易。
-
-
-
佇列
在當前的程式設計中佇列隨處可見。除了push和pop型別的命令之外,Redis還有阻塞佇列的命令,能夠讓一個程式在執行時被另一個程式新增到佇列。
-
4 快取實戰
實際工程中,對於快取的應用可以有多種的實戰方式,包括侵入式硬編碼,抽象服務化應用,以及輕量的註解式使用等。本文將主要介紹註解式方式。
4.1 Spring註解快取
Spring 3.1之後,引入了註解快取技術,其本質上不是一個具體的快取實現方案,而是一個對快取使用的抽象,通過在既有程式碼中新增少量自定義的各種annotation,即能夠達到使用快取物件和快取方法的返回物件的效果。Spring的快取技術具備相當的靈活性,不僅能夠使用SpEL來定義快取的key和各種condition,還提供開箱即用的快取臨時儲存方案,也支援和主流的專業快取整合。
其特點總結如下:
- 少量的配置annotation註釋即可使得既有程式碼支援快取;
- 支援開箱即用,不用安裝和部署額外的第三方元件即可使用快取;
- 支援Spring Express Language(SpEL),能使用物件的任何屬性或者方法來定義快取的key和使用規則條件;
- 支援自定義key和自定義快取管理者,具有相當的靈活性和可擴充套件性。
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進行統一管理。
4.1.1酒店商家端自定義註解快取
註解快取的使用,可以有效增強應用程式碼的可讀性,同時統一管理快取,提供較好的可擴充套件性,為此,酒店商家端在Spring註解快取基礎上,自定義了適合自身業務特性的註解快取。
主要使用兩個標籤,即@HotelCacheable、@HotelCacheEvict,其作用和配置方法見表2。
表2
標籤型別 | 作用 | 主要配置引數說明 |
---|---|---|
@HotelCacheable | 主要針對方法配置,能夠根據方法的請求引數對其結果進行快取 | domain: 作用域,針對集合場景,解決批量更新問題; domainKey: 作用域對應的快取key; key: 快取物件key 字首; fieldKey: 快取物件key,與前綴合並生成物件key; condition: 快取獲取前置條件,支援spel語法; cacheCondition: 快取刷入前置條件,支援spel語法; expireTime: 超時時間設定 |
@HotelCacheEvict | 主要針對方法配置,能夠根據一定的條件對快取進行清空 | 同上 |
增加作用域的概念,解決商家資訊變更下,多重重要資訊實時更新的問題。

圖13 域快取處理圖
如圖13,按舊的方案,當cache0傳送變化時,為了保持資訊的實時更新,需要手動刪除cache1、cache2、cache3等相關處的快取資料。
增加域快取概念
,cache0、cache1、cache2、cache3是以賬號ID為基礎,相互存在影響約束的集合體,我們作為一個域集合,增加域快取處理,當cache0傳送變化時,整體的賬號ID domain域已發生更新,自動影響cache1、cache2、cache3等處的快取資料。將相關聯邏輯快取統一化,有效提升程式碼可讀性,同時更好服務業務,賬號重點資訊能夠實時變更重新整理,相關服務響應速度提升。
另外,增加了cacheCondition快取刷入前置判斷,有效解決商家業務多重外部依賴場景下,業務降級有損服務下,業務資料一致性保證,不因為快取的增加影響業務的準確性;自定義CacheManager快取管理器,可以有效相容公共基礎元件Medis、Cellar相關服務,在對應用程式不做改動的情況下,有效切換快取方式;同時,統一的快取服務AOP入口,結合接入Mtconfig統一配置管理,對應用內快取做好降級準備,一鍵關閉快取。幾點建議:
- 上面介紹過Spring Cache的原理是基於動態生成的proxy代理機制來進行切面處理,關鍵點是物件的引用問題,如果物件的方法是類裡面的內部呼叫(this引用)而不是外部引用的場景下,會導致proxy失敗,那麼我們所做的快取切面處理也就失效了。因此,應避免已註解快取的方法在類裡面的內部呼叫。
- 使用的key約束,快取的key應儘量使用簡單的可區別的元素,如ID、名稱等,不能使用list等容器的值,或者使用整體model物件的值。非public方法無法使用註解快取實現。