緩存那些事

本文已發表於《程序員》雜誌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使用時間。在熱點數據場景下較適用,優先保證熱點數據的有效性。

除此以外,還有一些簡單策略好比:

  • 根據過時時間判斷,清理過時時間最長的元素;
  • 根據過時時間判斷,清理最近要過時的元素;
  • 隨機清理;
  • 根據關鍵字(或元素內容)長短清理等。

緩存介質

雖然從硬件介質上來看,無非就是內存和硬盤兩種,但從技術上,能夠分紅內存、硬盤文件、數據庫。

  • 內存:將緩存存儲於內存中是最快的選擇,無需額外的I/O開銷,可是內存的缺點是沒有持久化落地物理磁盤,一旦應用異常break down而從新啓動,數據很難或者沒法復原。
  • 硬盤:通常來講,不少緩存框架會結合使用內存和硬盤,在內存分配空間滿了或是在異常的狀況下,能夠被動或主動的將內存空間數據持久化到硬盤中,達到釋放空間或備份數據的目的。
  • 數據庫:前面有提到,增長緩存的策略的目的之一就是爲了減小數據庫的I/O壓力。如今使用數據庫作緩存介質是否是又回到了老問題上了?其實,數據庫也有不少種類型,像那些不支持SQL,只是簡單的key-value存儲結構的特殊數據庫(如BerkeleyDB和Redis),響應速度和吞吐量都遠遠高於咱們經常使用的關係型數據庫等。

緩存分類和應用場景

緩存有各種特徵,並且有不一樣介質的區別,那麼實際工程中咱們怎麼去對緩存分類呢?在目前的應用服務框架中,比較常見的,時根據緩存雨應用的藕合度,分爲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

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的左側部分描述。

總體數據流轉包括這樣幾類行爲:

  • 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的使用仍是相對簡單便捷的,提供了完整的各種API接口。須要注意的是,雖然Ehcache支持磁盤的持久化,可是因爲存在兩級緩存介質,在一級內存中的緩存,若是沒有主動的刷入磁盤持久化的話,在應用異常down機等情形下,依然會出現緩存數據丟失,爲此能夠根據須要將緩存刷到磁盤,將緩存條目刷到磁盤的操做能夠經過cache.flush()方法來執行,須要注意的是,對於對象的磁盤寫入,前提是要將對象進行序列化。

主要特性:

  • 快速,針對大型高併發系統場景,Ehcache的多線程機制有相應的優化改善。
  • 簡單,很小的jar包,簡單配置就可直接使用,單機場景下無需過多的其餘服務依賴。
  • 支持多種的緩存策略,靈活。
  • 緩存數據有兩級:內存和磁盤,與通常的本地內存緩存相比,有了磁盤的存儲空間,將能夠支持更大量的數據緩存需求。
  • 具備緩存和緩存管理器的偵聽接口,能更簡單方便的進行緩存實例的監控管理。
  • 支持多緩存管理器實例,以及一個實例的多個緩存區域。

注意:Ehcache的超時設置主要是針對整個cache實例設置總體的超時策略,而沒有較好的處理針對單獨的key的個性的超時設置(有策略設置,可是比較複雜,就不描述了),所以,在使用中要注意過時失效的緩存元素沒法被GC回收,時間越長緩存越多,內存佔用也就越大,內存泄露的機率也越大。

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操做: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生成器來建立緩存的方式,十分方便,而且各個緩存參數的配置設置,相似於函數式編程的寫法,可自行設置各種參數選型。它提供三種方式加載到緩存中。分別是:

  1. 在構建緩存的時候,使用build方法內部調用CacheLoader方法加載數據;
  2. callable 、callback方式加載數據;
  3. 使用粗暴直接的方式,直接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生成器管理,讓使用者有更多的自由度,可以根據不一樣場景設置合適的模式。

分佈式緩存

memcached緩存

memcached是應用較廣的開源分佈式緩存產品之一,它自己其實不提供分佈式解決方案。在服務端,memcached集羣環境實際就是一個個memcached服務器的堆積,環境搭建較爲簡單;cache的分佈式主要是在客戶端實現,經過客戶端的路由處理來達到分佈式解決方案的目的。客戶端作路由的原理很是簡單,應用服務器在每次存取某key的value時,經過某種算法把key映射到某臺memcached服務器nodeA上,所以這個key全部操做都在nodeA上,結構圖如圖六、圖7所示。



圖6 memcached客戶端路由圖

圖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內存管理須要注意的幾個方面:

  • 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模式的原子操做,能夠低成本的解決併發控制問題。

Redis緩存

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一共支持四種持久化方式,主要使用的兩種:

  1. 定時快照方式(snapshot):該持久化方式實際是在Redis內部一個定時器事件,每隔固定時間去檢查當前數據發生的改變次數與時間是否知足配置的持久化觸發的條件,若是知足則經過操做系統fork調用來建立出一個子進程,這個子進程默認會與父進程共享相同的地址空間,這時就能夠經過子進程來遍歷整個內存來進行存儲操做,而主進程則仍然能夠提供服務,當有寫入時由操做系統按照內存頁(page)爲單位來進行copy-on-write保證父子進程之間不會互相影響。它的缺點是快照只是表明一段時間內的內存映像,因此係統重啓會丟失上次快照與重啓之間全部的數據。
  2. 基於語句追加文件的方式(aof):aof方式實際相似MySQl的基於語句的binlog方式,即每條會使Redis內存數據發生改變的命令都會追加到一個log文件中,也就是說這個log文件就是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。



圖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在擴展和穩定高可用性能方面都是比較成熟的。可是,在數據一致性問題上,Redis沒有提供CAS操做命令來保障高併發場景下的數據一致性問題,不過它卻提供了事務的功能,Redis的Transactions提供的並非嚴格的ACID的事務(好比一串用EXEC提交執行的命令,在執行中服務器宕機,那麼會有一部分命令執行了,剩下的沒執行)。可是這個Transactions仍是提供了基本的命令打包執行的功能(在服務器不出問題的狀況下,能夠保證一連串的命令是順序在一塊兒執行的,中間有會有其它客戶端命令插進來執行)。Redis還提供了一個Watch功能,你能夠對一個key進行Watch,而後再執行Transactions,在這過程當中,若是這個Watched的值進行了修改,那麼這個Transactions會發現並拒絕執行。在失效策略上,Redis支持多大6種的數據淘汰策略:

  1. volatile-lru:從已設置過時時間的數據集(server.db[i].expires)中挑選最近最少使用的數據淘汰;
  2. volatile-ttl:從已設置過時時間的數據集(server.db[i].expires)中挑選將要過時的數據淘汰;
  3. volatile-random:從已設置過時時間的數據集(server.db[i].expires)中任意選擇數據淘汰 ;
  4. allkeys-lru:從數據集(server.db[i].dict)中挑選最近最少使用的數據淘汰;
  5. allkeys-random:從數據集(server.db[i].dict)中任意選擇數據淘汰;
  6. 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還有阻塞隊列的命令,可以讓一個程序在執行時被另外一個程序添加到隊列。

緩存實戰

實際工程中,對於緩存的應用能夠有多種的實戰方式,包括侵入式硬編碼,抽象服務化應用,以及輕量的註解式使用等。本文將主要介紹下註解式方式。

Spring註解緩存

Spring 3.1以後,引入了註解緩存技術,其本質上不是一個具體的緩存實現方案,而是一個對緩存使用的抽象,經過在既有代碼中添加少許自定義的各類annotation,即可以達到使用緩存對象和緩存方法的返回對象的效果。Spring的緩存技術具有至關的靈活性,不只可以使用SpEL(Spring Expression Language)來定義緩存的key和各類condition,還提供開箱即用的緩存臨時存儲方案,也支持和主流的專業緩存集成。其特色總結以下:

  • 少許的配置annotation註釋便可使得既有代碼支持緩存;
  • 支持開箱即用,不用安裝和部署額外的第三方組件便可使用緩存;
  • 支持Spring Express Language(SpEL),能使用對象的任何屬性或者方法來定義緩存的key和使用規則條件;
  • 支持自定義key和自定義緩存管理者,具備至關的靈活性和可擴展性。

和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的原理是基於動態生成的proxy代理機制來進行切面處理,關鍵點是對象的引用問題,若是對象的方法是類裏面的內部調用(this引用)而不是外部引用的場景下,會致使proxy失敗,那麼咱們所作的緩存切面處理也就失效了。所以,應避免已註解緩存的方法在類裏面的內部調用。
  • 使用的key約束,緩存的key應儘可能使用簡單的可區別的元素,如ID、名稱等,不能使用list等容器的值,或者使用總體model對象的值。非public方法沒法使用註解緩存實現。

總之,註釋驅動的Spring Cache可以極大的減小咱們編寫常見緩存的代碼量,經過少許的註釋標籤和配置文件,便可達到使代碼具有緩存的能力,且具有很好的靈活性和擴展性。可是咱們也應該看到,Spring Cache因爲基於Spring AOP技術,尤爲是動態的proxy技術,致使其不能很好的支持方法的內部調用或者非public方法的緩存設置,固然這些都是能夠解決的問題。

做者簡介

明輝,美團點評酒旅事業羣酒店住宿研發團隊B端商家業務平臺負責人,主導構建商家業務平臺系統,支撐美團點評酒店住宿業務的飛速發展需求。曾任職於聯想集團、百度。

 本文轉自:https://tech.meituan.com/cache_about.html

相關文章
相關標籤/搜索