萬字長文聊緩存(下)- 應用級緩存

 

摘要

在上一篇文章 萬字長文聊緩存(上)中,咱們主要如何圍繞着Http作緩存優化,在後端服務器的應用層一樣有不少地方能夠作緩存,提升服務的效率;本篇咱們就來繼續聊聊應用級的緩存。html

緩存的命中率

緩存的命中率是指從緩存中獲取到數據的次數和總讀取次數的比率,命中率越高證實緩存的效果越好。這是一個很重要的指標,應該經過監控這個指標來判斷咱們的緩存是否設置的合理。git

緩存的回收策略

基於時間

  • 存活期:在設置緩存的同時設置該緩存能夠存活多久,不論在存活期內被訪問了多少次,時間到了都會過時
  • 空閒期:是指緩存的數據多久沒有被訪問就過時

基於空間

設置緩存的存儲空間,好比:設置緩存的空間是 1G,當達到了1G以後就會按照必定的策略將部分數據移除github

基於緩存數量

設置緩存的最大條目數,當達到了設置的最大條目數以後按照必定的策略將舊的數據移除web

基於Java對象引用

  • 弱引用:當垃圾回收器開始回收內存的時候,若是發現了弱引用,它將當即被回收。
  • 軟引用:當垃圾回收器發現內存已不足的狀況下會回收軟引用的對象,從而騰出一下空間,防止發生內存溢出。軟引用適合用來作堆緩存

緩存的回收算法

  • FIFO 先進先出算法
  • LRU 最近最少使用算法
  • LFU 最不經常使用算法

Java緩存的類型

堆緩存

堆緩存是指把數據緩存在JVM的堆內存中,使用堆緩存的好處是沒有序列化和反序列化的操做,是最快的緩存。若是緩存的數據量很大,爲了不形成OOM一般狀況下使用的時軟引用來存儲緩存對象;堆緩存的缺點是緩存的空間有限,而且垃圾回收器暫停的時間會變長。redis

Gauva Cache實現堆緩存

Cache<string, string> cache = CacheBuilder.newBuilder()
                .build();

經過CacheBuilder構建緩存對象算法

Gauva Cache的主要配置和方法spring

  • put : 向緩存中設置key-value
  • V get(K key, Callable<!--? extends V--> loader) : 獲取一個緩存值,若是緩存中沒有,那麼就調用loader獲取一個而後放入到緩存
  • expireAfterWrite : 設置緩存的存活期,寫入數據後指定時間以後失效
  • expireAfterAccess : 設置緩存的空閒期,在給定的時間內沒有被訪問就會被回收
  • maximumSize : 設置緩存的最大條目數
  • weakKeys/weakValues : 設置弱引用緩存
  • softValues : 設置軟引用緩存
  • invalidate/invalidateAll: 主動失效指定key的緩存數據
  • recordStats : 啓動記錄統計信息,能夠查看到命中率
  • removalListener : 當緩存被刪除的時候會調用此監聽器,能夠用於查看爲何緩存會被刪除

Caffeine實現堆緩存

Caffeine是使用Java8對Guava緩存的重寫版本,高性能Java本地緩存組件,也是Spring推薦的堆緩存的實現,與spring的集成能夠查看文檔https://docs.spring.io/spring-framework/docs/current/reference/html/integration.html#cache-store-configuration-caffeine數據庫

因爲是對Guava緩存的重寫版本,因此不少的配置參數都是和Guava緩存一致:後端

  • initialCapacity: 初始的緩存空間大小
  • maximumSize: 緩存的最大條數
  • maximumWeight: 緩存的最大權重
  • expireAfterAccess: 最後一次寫入或訪問後通過固定時間過時
  • expireAfterWrite: 最後一次寫入後通過固定時間過時
  • expireAfter : 自定義過時策略
  • refreshAfterWrite: 建立緩存或者最近一次更新緩存後通過固定的時間間隔,刷新緩存
  • weakKeys: 打開key的弱引用
  • weakValues:打開value的弱引用
  • softValues:打開value的軟引用
  • recordStats:開啓統計功能

Caffeine的官方文檔:https://github.com/ben-manes/caffeine/wiki緩存

  1. pom.xml中添加依賴
<dependency>
    <groupid>com.github.ben-manes.caffeine</groupid>
    <artifactid>caffeine</artifactid>
    <version>2.8.4</version>
</dependency>
  1. Caffeine Cache提供了三種緩存填充策略:手動、同步加載和異步加載。
  • 手動加載:在每次get key的時候指定一個同步的函數,若是key不存在就調用這個函數生成一個值
public Object manual(String key) {
    Cache<string, object> cache = Caffeine.newBuilder()
            .expireAfterAccess(1, TimeUnit.SECONDS) //設置空閒期時長
            .maximumSize(10)
            .build();
    return cache.get(key, t -&gt; setValue(key).apply(key));
}

public Function<string, object> setValue(String key){
    return t -&gt; "https://silently9527.cn";
}
  • 同步加載:構造Cache時候,build方法傳入一個CacheLoader實現類。實現load方法,經過key加載value。
public Object sync(String key){
    LoadingCache<string, object> cache = Caffeine.newBuilder()
            .maximumSize(100)
            .expireAfterWrite(1, TimeUnit.MINUTES) //設置存活期時長
            .build(k -&gt; setValue(key).apply(key));
    return cache.get(key);
}

public Function<string, object> setValue(String key){
    return t -&gt; "https://silently9527.cn";
}
  • 異步加載:AsyncLoadingCache是繼承自LoadingCache類的,異步加載使用Executor去調用方法並返回一個CompletableFuture
public CompletableFuture async(String key) {
    AsyncLoadingCache<string, object> cache = Caffeine.newBuilder()
            .maximumSize(100)
            .expireAfterWrite(1, TimeUnit.MINUTES)
            .buildAsync(k -&gt; setAsyncValue().get());
    return cache.get(key);
}

public CompletableFuture<object> setAsyncValue() {
    return CompletableFuture.supplyAsync(() -&gt; "公衆號:貝塔學JAVA");
}
  1. 監聽緩存被清理的事件
public void removeListener() {
    Cache<string, object> cache = Caffeine.newBuilder()
            .removalListener((String key, Object value, RemovalCause cause) -&gt; {
                System.out.println("remove lisitener");
                System.out.println("remove Key:" + key);
                System.out.println("remove Value:" + value);
            })
            .build();
    cache.put("name", "silently9527");
    cache.invalidate("name");
}
  1. 統計
public void recordStats() {
    Cache<string, object> cache = Caffeine.newBuilder()
            .maximumSize(10000)
            .recordStats()
            .build();
    cache.put("公衆號", "貝塔學JAVA");
    cache.get("公衆號", (t) -&gt; "");
    cache.get("name", (t) -&gt; "silently9527");

    CacheStats stats = cache.stats();
    System.out.println(stats);
}

經過 Cache.stats() 獲取到CacheStatsCacheStats提供如下統計方法:

  • hitRate(): 返回緩存命中率
  • evictionCount(): 緩存回收數量
  • averageLoadPenalty(): 加載新值的平均時間

EhCache實現堆緩存

EhCache 是老牌Java開源緩存框架,早在2003年就已經出現了,發展到如今已經很是成熟穩定,在Java應用領域應用也很是普遍,並且和主流的Java框架好比Srping能夠很好集成。相比於 Guava Cache,EnCache 支持的功能更豐富,包括堆外緩存、磁盤緩存,固然使用起來要更重一些。使用 Ehcache 的Maven 依賴以下:

<dependency>
    <groupid>org.ehcache</groupid>
    <artifactid>ehcache</artifactid>
    <version>3.6.3</version>
</dependency>
CacheManager cacheManager = CacheManagerBuilder.newCacheManagerBuilder().build(true);

ResourcePoolsBuilder resource = ResourcePoolsBuilder.heap(10); //設置最大緩存條目數

CacheConfiguration<string, string> cacheConfig = CacheConfigurationBuilder
        .newCacheConfigurationBuilder(String.class, String.class, resource)
        .withExpiry(ExpiryPolicyBuilder.timeToIdleExpiration(Duration.ofMinutes(10)))
        .build();

Cache<string, string> cache = cacheManager.createCache("userInfo", cacheConfig);
  • ResourcePoolsBuilder.heap(10)設置緩存的最大條目數,這是簡寫方式,等價於ResourcePoolsBuilder.newResourcePoolsBuilder().heap(10, EntryUnit.ENTRIES);
  • ResourcePoolsBuilder.newResourcePoolsBuilder().heap(10, MemoryUnit.MB)設置緩存最大的空間10MB
  • withExpiry(ExpiryPolicyBuilder.timeToIdleExpiration(Duration.ofMinutes(10))) 設置緩存空閒時間
  • withExpiry(ExpiryPolicyBuilder.timeToLiveExpiration(Duration.ofMinutes(10))) 設置緩存存活時間
  • remove/removeAll主動失效緩存,與Guava Cache相似,調用方法後不會當即去清除回收,只有在get或者put的時候判斷緩存是否過時
  • withSizeOfMaxObjectSize(10,MemoryUnit.KB)限制單個緩存對象的大小,超過這兩個限制的對象則不被緩存

堆外緩存

堆外緩存即緩存數據在堆外內存中,空間大小隻受本機內存大小限制,不受GC管理,使用堆外緩存能夠減小GC暫停時間,可是堆外內存中的對象都須要序列化和反序列化,KEY和VALUE必須實現Serializable接口,所以速度會比堆內緩存慢。在Java中能夠經過 -XX:MaxDirectMemorySize 參數設置堆外內存的上限

CacheManager cacheManager = CacheManagerBuilder.newCacheManagerBuilder().build(true);
// 堆外內存不能按照存儲條目限制,只能按照內存大小進行限制,超過限制則回收緩存
ResourcePoolsBuilder resource = ResourcePoolsBuilder.newResourcePoolsBuilder().offheap(10, MemoryUnit.MB);

CacheConfiguration<string, string> cacheConfig = CacheConfigurationBuilder
        .newCacheConfigurationBuilder(String.class, String.class, resource)
        .withDispatcherConcurrency(4)
        .withExpiry(ExpiryPolicyBuilder.timeToLiveExpiration(Duration.ofMinutes(10)))
        .withSizeOfMaxObjectSize(10, MemoryUnit.KB)
        .build();

Cache<string, string> cache = cacheManager.createCache("userInfo2", cacheConfig);
cache.put("website", "https://silently9527.cn");
System.out.println(cache.get("website"));

磁盤緩存

把緩存數據存放到磁盤上,在JVM重啓時緩存的數據不會受到影響,而堆緩存和堆外緩存都會丟失;而且磁盤緩存有更大的存儲空間;可是緩存在磁盤上的數據也須要支持序列化,速度會被比內存更慢,在使用時推薦使用更快的磁盤帶來更大的吞吐率,好比使用閃存代替機械磁盤。

CacheManagerConfiguration<persistentcachemanager> persistentManagerConfig = CacheManagerBuilder
        .persistence(new File("/Users/huaan9527/Desktop", "ehcache-cache"));

PersistentCacheManager persistentCacheManager = CacheManagerBuilder.newCacheManagerBuilder()
        .with(persistentManagerConfig).build(true);

//disk 第三個參數設置爲 true 表示將數據持久化到磁盤上
ResourcePoolsBuilder resource = ResourcePoolsBuilder.newResourcePoolsBuilder().disk(100, MemoryUnit.MB, true);

CacheConfiguration<string, string> config = CacheConfigurationBuilder
        .newCacheConfigurationBuilder(String.class, String.class, resource).build();
Cache<string, string> cache = persistentCacheManager.createCache("userInfo",
        CacheConfigurationBuilder.newCacheConfigurationBuilder(config));

cache.put("公衆號", "貝塔學JAVA");
System.out.println(cache.get("公衆號"));
persistentCacheManager.close();

在JVM中止時,必定要記得調用persistentCacheManager.close(),保證內存中的數據可以dump到磁盤上。

這是典型 heap + offheap + disk 組合的結構圖,上層比下層速度快,下層比上層存儲空間大,在ehcache中,空間大小設置 heap &gt; offheap &gt; disk,不然會報錯; ehcache 會將最熱的數據保存在高一級的緩存。這種結構的代碼以下:

CacheManagerConfiguration<persistentcachemanager> persistentManagerConfig = CacheManagerBuilder
        .persistence(new File("/Users/huaan9527/Desktop", "ehcache-cache"));

PersistentCacheManager persistentCacheManager = CacheManagerBuilder.newCacheManagerBuilder()
        .with(persistentManagerConfig).build(true);

ResourcePoolsBuilder resource = ResourcePoolsBuilder.newResourcePoolsBuilder()
        .heap(10, MemoryUnit.MB)
        .offheap(100, MemoryUnit.MB)
        //第三個參數設置爲true,支持持久化
        .disk(500, MemoryUnit.MB, true);

CacheConfiguration<string, string> config = CacheConfigurationBuilder
        .newCacheConfigurationBuilder(String.class, String.class, resource).build();

Cache<string, string> cache = persistentCacheManager.createCache("userInfo",
        CacheConfigurationBuilder.newCacheConfigurationBuilder(config));

//寫入緩存
cache.put("name", "silently9527");
// 讀取緩存
System.out.println(cache.get("name"));

// 再程序關閉前,須要手動釋放資源
persistentCacheManager.close();

分佈式集中緩存

前面提到的堆內緩存和堆外緩存若是在多個JVM實例的狀況下會有兩個問題:1.單機容量畢竟有限;2.多臺JVM實例緩存的數據可能不一致;3.若是緩存數據同一時間都失效了,那麼請求都會打到數據庫上,數據庫壓力增大。這時候咱們就須要引入分佈式緩存來解決,如今使用最多的分佈式緩存是redis

當引入分佈式緩存以後就能夠把應用緩存的架構調整成上面的結構。

緩存使用模式的實踐

緩存使用的模式大概分爲兩類:Cache-Aside、Cache-As-SoR(SoR表示實際存儲數據的系統,也就是數據源)

Cache-Aside

業務代碼圍繞着緩存來寫,一般都是從緩存中來獲取數據,若是緩存沒有命中,則從數據庫中查找,查詢到以後就把數據放入到緩存;當數據被更新以後,也須要對應的去更新緩存中的數據。這種模式也是咱們一般使用最多的。

  • 讀場景
value = cache.get(key); //從緩存中讀取數據
if(value == null) {
    value = loadFromDatabase(key); //從數據庫中查詢
    cache.put(key, value); //放入到緩存中
}
  • 寫場景
wirteToDatabase(key, value); //寫入到數據庫
cache.put(key, value); //放入到緩存中 或者 能夠刪除掉緩存 cache.remove(key) ,再讀取的時候再查一次

Spring的Cache擴展就是使用的Cache-Aside模式,Spring爲了把業務代碼和緩存的讀取更新分離,對Cache-Aside模式使用AOP進行了封裝,提供了多個註解來實現讀寫場景。官方參考文檔:

  • @Cacheable : 一般是放在查詢方法上,實現的就是Cache-Aside讀的場景,先查緩存,若是不存在在查詢數據庫,最後把查詢出來的結果放入到緩存。
  • @CachePut : 一般用在保存更新方法上面,實現的就是Cache-Aside寫的場景,更新完成數據庫後把數據放入到緩存中。
  • @CacheEvict : 從緩存中刪除指定key的緩存

> 對於一些容許有一點點更新延遲基礎數據能夠考慮使用canal訂閱binlog日誌來完成緩存的增量更新。 > > Cache-Aside還有個問題,若是某個時刻熱點數據緩存失效,那麼會有不少請求同時打到後端數據庫上,數據庫的壓力會瞬間增大

Cache-As-SoR

Cache-As-SoR模式也就會把Cache看作是數據源,全部的操做都是針對緩存,Cache在委託給真正的SoR去實現讀或者寫。業務代碼中只會看到Cache的操做,這種模式又分爲了三種

Read Through

應用程序始終從緩存中請求數據,若是緩存中沒有數據,則它負責使用提供的數據加載程序從數據庫中檢索數據,檢索數據後,緩存會自行更新並將數據返回給調用的應用程序。Gauva Cache、Caffeine、EhCache都支持這種模式;

  1. Caffeine實現Read Through 因爲Gauva Cache和Caffeine實現相似,因此這裏只展現Caffeine的實現,如下代碼來自Caffeine官方文檔
LoadingCache<key, graph> cache = Caffeine.newBuilder()
    .maximumSize(10_000)
    .expireAfterWrite(10, TimeUnit.MINUTES)
    .build(key -&gt; createExpensiveGraph(key));

// Lookup and compute an entry if absent, or null if not computable
Graph graph = cache.get(key);
// Lookup and compute entries that are absent
Map<key, graph> graphs = cache.getAll(keys);

在build Cache的時候指定一個CacheLoader

  • [1] 在應用程序中直接調用cache.get(key)
  • [2] 首先查詢緩存,若是緩存存在就直接返回數據
  • [3] 若是不存在,就會委託給CacheLoader去數據源中查詢數據,以後在放入到緩存,返回給應用程序

> CacheLoader不要直接返回null,建議封裝成本身定義的Null對像,在放入到緩存中,能夠防止緩存擊穿

爲了防止由於某個熱點數據失效致使後端數據庫壓力增大的狀況,我能夠在CacheLoader中使用鎖限制只容許一個請求去查詢數據庫,其餘的請求都等待第一個請求查詢完成後從緩存中獲取,在上一篇 《萬字長文聊緩存(上)》中咱們聊到了Nginx也有相似的配置參數

value = loadFromCache(key);
if(value != null) {
    return value;
}
synchronized (lock) {
    value = loadFromCache(key);
    if(value != null) {
        return value;
    }
    return loadFromDatabase(key);
}
  1. EhCache實現Read Through
CacheManager cacheManager = CacheManagerBuilder.newCacheManagerBuilder().build(true);
ResourcePoolsBuilder resource = ResourcePoolsBuilder.newResourcePoolsBuilder().heap(10, MemoryUnit.MB); //設置最大緩存條目數
CacheConfiguration<string, string> cacheConfig = CacheConfigurationBuilder
        .newCacheConfigurationBuilder(String.class, String.class, resource)
        .withExpiry(ExpiryPolicyBuilder.timeToLiveExpiration(Duration.ofMinutes(10)))
        .withLoaderWriter(new CacheLoaderWriter<string, string>(){
            @Override
            public String load(String key) throws Exception {
                //load from database
                return "silently9527";
            }

            @Override
            public void write(String key, String value) throws Exception {

            }

            @Override
            public void delete(String key) throws Exception {

            }
        })
        .build();

Cache<string, string> cache = cacheManager.createCache("userInfo", cacheConfig);
System.out.println(cache.get("name"));

在EhCache中使用的是CacheLoaderWriter來從數據庫中加載數據;解決由於某個熱點數據失效致使後端數據庫壓力增大的問題和上面的方式同樣,也能夠在load中實現。

Write Through

和Read Through模式相似,當數據進行更新時,先去更新SoR,成功以後在更新緩存。

  1. Caffeine實現Write Through
Cache<string, string> cache = Caffeine.newBuilder()
        .maximumSize(100)
        .writer(new CacheWriter<string, string>() {
            @Override
            public void write(@NonNull String key, @NonNull String value) {
                //write data to database
                System.out.println(key);
                System.out.println(value);
            }

            @Override
            public void delete(@NonNull String key, @Nullable String value, @NonNull RemovalCause removalCause) {
                //delete from database
            }
        })
        .build();

cache.put("name", "silently9527");

Caffeine經過使用CacheWriter來實現Write Through,CacheWriter能夠同步的監聽到緩存的建立、變動和刪除操做,只有寫成功了纔會去更新緩存

  1. EhCache實現Write Through
CacheManager cacheManager = CacheManagerBuilder.newCacheManagerBuilder().build(true);
ResourcePoolsBuilder resource = ResourcePoolsBuilder.newResourcePoolsBuilder().heap(10, MemoryUnit.MB); //設置最大緩存條目數
CacheConfiguration<string, string> cacheConfig = CacheConfigurationBuilder
        .newCacheConfigurationBuilder(String.class, String.class, resource)
        .withExpiry(ExpiryPolicyBuilder.timeToLiveExpiration(Duration.ofMinutes(10)))
        .withLoaderWriter(new CacheLoaderWriter<string, string>(){
            @Override
            public String load(String key) throws Exception {
                return "silently9527";
            }

            @Override
            public void write(String key, String value) throws Exception {
                //write data to database
                System.out.println(key);
                System.out.println(value);
            }

            @Override
            public void delete(String key) throws Exception {
                //delete from database
            }
        })
        .build();

Cache<string, string> cache = cacheManager.createCache("userInfo", cacheConfig);
System.out.println(cache.get("name"));

cache.put("website","https://silently9527.cn");

EhCache仍是經過CacheLoaderWriter來實現的,當咱們調用cache.put("xxx","xxx")進行寫緩存的時候,EhCache首先會將寫的操做委託給CacheLoaderWriter,有CacheLoaderWriter.write去負責寫數據源

Write Behind

這種模式一般先將數據寫入緩存,再異步地寫入數據庫進行數據同步。這樣的設計既能夠減小對數據庫的直接訪問,下降壓力,同時對數據庫的屢次修改能夠合併操做,極大地提高了系統的承載能力。可是這種模式也存在風險,如當緩存機器出現宕機時,數據有丟失的可能。

  1. Caffeine要想實現Write Behind能夠在CacheLoaderWriter.write方法中把數據發送到MQ中,實現異步的消費,這樣能夠保證數據的安全,可是要想實現合併操做就須要擴展功能更強大的CacheLoaderWriter
  2. EhCache實現Write Behind
//1 定義線程池
PooledExecutionServiceConfiguration testWriteBehind = PooledExecutionServiceConfigurationBuilder
        .newPooledExecutionServiceConfigurationBuilder()
        .pool("testWriteBehind", 5, 10)
        .build();

CacheManager cacheManager = CacheManagerBuilder.newCacheManagerBuilder()
        .using(testWriteBehind)
        .build(true);
ResourcePoolsBuilder resource = ResourcePoolsBuilder.newResourcePoolsBuilder().heap(10, MemoryUnit.MB); //設置最大緩存條目數

//2 設置回寫模式配置
WriteBehindConfiguration testWriteBehindConfig = WriteBehindConfigurationBuilder
        .newUnBatchedWriteBehindConfiguration()
        .queueSize(10)
        .concurrencyLevel(2)
        .useThreadPool("testWriteBehind")
        .build();

CacheConfiguration<string, string> cacheConfig = CacheConfigurationBuilder
        .newCacheConfigurationBuilder(String.class, String.class, resource)
        .withLoaderWriter(new CacheLoaderWriter<string, string>() {
            @Override
            public String load(String key) throws Exception {
                return "silently9527";
            }

            @Override
            public void write(String key, String value) throws Exception {
                //write data to database
            }

            @Override
            public void delete(String key) throws Exception {
            }
        })
        .add(testWriteBehindConfig)
        .build();

Cache<string, string> cache = cacheManager.createCache("userInfo", cacheConfig);

首先使用PooledExecutionServiceConfigurationBuilder定義了線程池配置;而後使用WriteBehindConfigurationBuilder設置會寫模式配置,其中newUnBatchedWriteBehindConfiguration表示不進行批量寫操做,由於是異步寫,因此須要把寫操做先放入到隊列中,經過queueSize設置隊列大小,useThreadPool指定使用哪一個線程池; concurrencyLevel設置使用多少個併發線程和隊列進行Write Behind

EhCache實現批量寫的操做也很容易

  • 首先把newUnBatchedWriteBehindConfiguration()替換成newBatchedWriteBehindConfiguration(10, TimeUnit.SECONDS, 20),這裏設置的是數量達到20就進行批處理,若是10秒內沒有達到20個也會進行處理
  • 其次在CacheLoaderWriter中實現wirteAll 和 deleteAll進行批處理

> 若是須要把對相同的key的操做合併起來只記錄最後一次數據,能夠經過enableCoalescing()來啓用合併

寫到最後 點關注,不迷路

文中或許會存在或多或少的不足、錯誤之處,有建議或者意見也很是歡迎你們在評論交流。

相關文章
相關標籤/搜索