緩存那些事

yihaisufp 7年前發布 | 35K 次閱讀 Redis memcached

前言

一般而言,現在互聯網應用(網站或App)的整體流程,可以概括如圖1所示,用戶請求從界面(瀏覽器或App界面)到網絡轉發、應用服務再到存儲(數據庫或文件系統),然后返回到界面呈現內容。

隨著互聯網的普及,內容信息越來越復雜,用戶數和訪問量越來越大,我們的應用需要支撐更多的并發量,同時我們的應用服務器和數據庫服務器所做的計算也越來越多。但是往往我們的應用服務器資源是有限的,且技術變革是緩慢的,數據庫每秒能接受的請求次數也是有限的(或者文件的讀寫也是有限的),如何能夠有效利用有限的資源來提供盡可能大的吞吐量?一個有效的辦法就是引入緩存,打破標準流程,每個環節中請求可以從緩存中直接獲取目標數據并返回,從而減少計算量,有效提升響應速度,讓有限的資源服務更多的用戶。

如圖1所示,緩存的使用可以出現在1~4的各個環節中,每個環節的緩存方案與使用各有特點。

圖1 互聯網應用一般流程

緩存特征

緩存也是一個數據模型對象,那么必然有它的一些特征:

命中率

命中率=返回正確結果數/請求緩存次數,命中率問題是緩存中的一個非常重要的問題,它是衡量緩存有效性的重要指標。命中率越高,表明緩存的使用率越高。

最大元素(或最大空間)

緩存中可以存放的最大元素的數量,一旦緩存中元素數量超過這個值(或者緩存數據所占空間超過其最大支持空間),那么將會觸發緩存啟動清空策略根據不同的場景合理的設置最大元素值往往可以一定程度上提高緩存的命中率,從而更有效的時候緩存。

清空策略

如上描述,緩存的存儲空間有限制,當緩存空間被用滿時,如何保證在穩定服務的同時有效提升命中率?這就由緩存清空策略來處理,設計適合自身數據特征的清空策略能有效提升命中率。常見的一般策略有:

  • FIFO(first in first out)

    先進先出策略,最先進入緩存的數據在緩存空間不夠的情況下(超出最大元素限制)會被優先被清除掉,以騰出新的空間接受新的數據。策略算法主要比較緩存元素的創建時間。在數據實效性要求場景下可選擇該類策略,優先保障最新數據可用。

  • 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. 成員變量或局部變量實現

簡單代碼示例如下:

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 { // 緩存未命中  IO獲取數據,結果存入緩存
        Object valueObject = this.getInfoFromDB();
        localCacheStoreMap.put(valueObject.toString(), valueObject);

    }
}

} //示例 private List<Object> getInfoList(){ return new ArrayList<Object>(); } //示例數據庫IO獲取 private Object getInfoFromDB(){ return new Object(); }</code></pre>

以局部變量map結構緩存部分業務數據,減少頻繁的重復數據庫I/O操作。缺點僅限于類的自身作用域內,類間無法共享緩存。

b. 靜態變量實現

最常用的單例實現靜態資源緩存,代碼示例如下:

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("

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;
 }

}</code></pre>

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></code></pre>

整體上看,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); }</code></pre>

總體來看,Guava Cache基于ConcurrentHashMap的優秀設計借鑒,在高并發場景支持和線程安全上都有相應的改進策略,使用Reference引用命令,提升高并發下的數據……訪問速度并保持了GC的可回收,有效節省空間;同時,write鏈和access鏈的設計,能更靈活、高效的實現多種類型的緩存清理策略,包括基于容量的清理、基于時間的清理、基于引用的清理等;編程式的build生成器管理,讓使用者有更多的自由度,能夠根據不同場景設置合適的模式。

分布式緩存

memcached緩存

memcached是應用較廣的開源分布式緩存產品之一,它本身其實不提供分布式解決方案。在服務端,memcached集群環境實際就是一個個memcached服務器的堆積,環境搭建較為簡單;cache的分布式主要是在客戶端實現,通過客戶端的路由處理來達到分布式解決方案的目的。客戶端做路由的原理非常簡單,應用服務器在每次存取某key的value時,通過某種算法把key映射到某臺memcached服務器nodeA上,因此這個key所有操作都在nodeA上,結構圖如圖6、圖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更傾向于在服務端構建分布式存儲,如圖10、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發送變化時,為了保持信息的實時更新,需要手動刪除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方法無法使用注解緩存實現。

總之,注釋驅動的Spring Cache能夠極大的減少我們編寫常見緩存的代碼量,通過少量的注釋標簽和配置文件,即可達到使代碼具備緩存的能力,且具備很好的靈活性和擴展性。但是我們也應該看到,Spring Cache由于基于Spring AOP技術,尤其是動態的proxy技術,導致其不能很好的支持方法的內部調用或者非public方法的緩存設置,當然這些都是可以解決的問題。

作者簡介

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

 

來自:http://tech.meituan.com/cache_about.html

 

 本文由用戶 yihaisufp 自行上傳分享,僅供網友學習交流。所有權歸原作者,若您的權利被侵害,請聯系管理員。
 轉載本站原創文章,請注明出處,并保留原始鏈接、圖片水印。
 本站是一個以用戶分享為主的開源技術平臺,歡迎各類分享!