乐趣区

关于缓存:万字长文聊缓存下-应用级缓存

深刻解析 SpringMVC 外围原理:从手写简易版 MVC 框架开始(SmartMvc) : https://github.com/silently9527/SmartMvc

IDEA 多线程文件下载插件: https://github.com/silently9527/FastDownloadIdeaPlugin

公众号:贝塔学 JAVA

摘要

在上一篇文章 万字长文聊缓存(上)中,咱们次要如何围绕着 Http 做缓存优化,在后端服务器的应用层同样有很多中央能够做缓存,进步服务的效率;本篇咱们就来持续聊聊利用级的缓存。

缓存的命中率

缓存的命中率是指从缓存中获取到数据的次数和总读取次数的比率,命中率越高证实缓存的成果越好。这是一个很重要的指标,应该通过监控这个指标来判断咱们的缓存是否设置的正当。

缓存的回收策略

基于工夫

  • 存活期:在设置缓存的同时设置该缓存能够存活多久,不管在存活期内被拜访了多少次,工夫到了都会过期
  • 闲暇期:是指缓存的数据多久没有被拜访就过期

基于空间

设置缓存的存储空间,比方:设置缓存的空间是 1G,当达到了 1G 之后就会依照肯定的策略将局部数据移除

基于缓存数量

设置缓存的最大条目数,当达到了设置的最大条目数之后依照肯定的策略将旧的数据移除

基于 Java 对象援用

  • 弱援用:当垃圾回收器开始回收内存的时候,如果发现了弱援用,它将立刻被回收。
  • 软援用:当垃圾回收器发现内存已有余的状况下会回收软援用的对象,从而腾出一下空间,避免产生内存溢出。软援用适宜用来做堆缓存

缓存的回收算法

  • FIFO 先进先出算法
  • LRU 最近起码应用算法
  • LFU 最不罕用算法

Java 缓存的类型

堆缓存

堆缓存是指把数据缓存在 JVM 的堆内存中,应用堆缓存的益处是没有序列化和反序列化的操作,是最快的缓存。如果缓存的数据量很大,为了防止造成 OOM 通常状况下应用的时软援用来存储缓存对象;堆缓存的毛病是缓存的空间无限,并且垃圾回收器暂停的工夫会变长。

Gauva Cache 实现堆缓存

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

通过 CacheBuilder 构建缓存对象

Gauva Cache 的次要配置和办法

  • 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 -> setValue(key).apply(key));
}

public Function<String, Object> setValue(String key){return t -> "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 -> setValue(key).apply(key));
    return cache.get(key);
}

public Function<String, Object> setValue(String key){return t -> "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 -> setAsyncValue().get());
    return cache.get(key);
}

public CompletableFuture<Object> setAsyncValue() {return CompletableFuture.supplyAsync(() -> "公众号:贝塔学 JAVA");
}
  1. 监听缓存被清理的事件
public void removeListener() {Cache<String, Object> cache = Caffeine.newBuilder()
            .removalListener((String key, Object value, RemovalCause cause) -> {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) -> "");
    cache.get("name", (t) -> "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 > offheap > 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 进行了封装,提供了多个注解来实现读写场景。官网参考文档:[](https://docs.spring.io/spring…

  • @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 -> 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() 来启用合并

写到最初 点关注,不迷路

文中或者会存在或多或少的有余、谬误之处,有倡议或者意见也十分欢送大家在评论交换。

最初,白嫖不好,创作不易 ,心愿敌人们能够 点赞评论关注 三连,因为这些就是我分享的全副能源起源????

源码地址:https://github.com/silently9527/CacheTutorial

公众号:贝塔学 JAVA

原文地址:https://silently9527.cn/archives/94

退出移动版