写点什么

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

用户头像
Silently9527
关注
发布于: 2021 年 01 月 05 日

深入解析 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-framework/docs/current/reference/html/integration.html#cache-annotations)


  • @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 computableGraph graph = cache.get(key);// Lookup and compute entries that are absentMap<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


用户头像

Silently9527

关注

公众号:贝塔学JAVA 2018.05.09 加入

Simple Programmer, Make the complex simple

评论

发布
暂无评论
万字长文聊缓存(下)- 应用级缓存