万字长文聊缓存(下)- 应用级缓存

 

摘要

在上一篇文章 万字长文聊缓存(上)中,咱们主要如何围绕着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()来启用合并

写到最后 点关注,不迷路

文中或许会存在或多或少的不足、错误之处,有建议或者意见也很是欢迎你们在评论交流。

相关文章
相关标签/搜索