应用级缓存

Posted 菜鸟JAVA后端

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了应用级缓存相关的知识,希望对你有一定的参考价值。

一、什么是缓存、缓存的作用

缓存,个人理解是为了解决应用性能瓶颈的一种技术,目的是让网站的访问速度更快。一般的工作机制是,先从访问速度更快的缓存中读取数据,如果缓存命中,则返回数据,反之从访问速度次之的设备中读取数据,然后再刷新缓存。下图为redis作为缓存的应用原理图:

那些经常被访问的数据,热点数据,I/O瓶颈数据,计算昂贵的数据、符合5分钟法则(简单理解I/O5 分钟法则:如果一个数据的访问周期在5分钟内,则放在内存中,否则放在磁盘上)等数据需要被缓存。比如:访问速度 CPU(计算速度)> 内存 >磁盘,因为CPU的计算所需的数据从内存中获取,而CPU的计算速度远大于内存的数据访问速度,为了提高CPU的利用率,加入了访问速度更快的高速缓存,将内存预加载到缓存中,CPU从高速缓存读取数据。

二、缓存的基本概念介绍

2.1、缓存命中率

缓存的命中率是指缓存中读取数据的次数与总访问次数的比率,这也是评判一个缓存设计好坏的一个重要标准。

2.2、缓存的回收策略

  • 基于空间:设置缓存存储空间的大小,如设置为100MB,当达到缓存存储空间上线时,按照一定策略移除数据。

  • 基于容量:缓存设置最大值,当缓存的条目超过最大值时,按照一定的策略移除数据。

  • 基于时间:TTL为存活期,指从创建开始到过期时间(时间到即删除,不管是否有过访问)    TTI:空闲期,两次访问之间的间隔时间,大于TTL时间则会被移除。

  • 基于java对象应用:java中存在四类对象引用(强、软、弱、虚),

    软应用:如果一个对象是软引用,那么当JVM堆内存不足时,垃圾回收器可以回收此类对象。

    弱应用:当垃圾回收回收垃圾时,不管内存够不够都要进行回收。

2.2、缓存的回收策略

  • FIFO:先进先出算法,即先放入缓存的先被移除。

  • LRU:最近最少使用(最长使用的算法 如guava cache,Ehcache默认使用该算法)

  • LFU:最少使用,淘汰在一段时间内,最少使用的数据。

三、java中缓存类型

对于java开发者而言,一般接触到的缓存分为以下几种:

  • 客户端缓存:对于BS架构的互联网应用来说客户端缓存主要分为页面缓存和浏览器缓存两种。

  • 网络中缓存:网络中的缓存主要是指代理服务器对客户端请求数据的缓存,主要分为WEB代理缓存和边缘缓存(CDN边缘缓存)

    服务端的缓存(本文的重点):对于服务器而言,按照架构分为:堆缓存、堆外缓存、磁盘缓存、分布式缓存以及数据库缓存。下面将一一进行介绍并附实现方法。

3.1、堆缓存:

学习java的人都知道,根据jvm内存模型,绝大部分java对象存储在java堆中。使用堆内存的好处是不需要经过序列化与反序列化,速度可以达到最优,但大家都知道堆空间是很宝贵的内存空间,当缓存的数据量很大的时候需要暂停GC进行进行垃圾回收,且堆缓存的容量受限于堆空间的大小,所以堆缓存一般通过软/弱引用来存储缓存对象(前面提软/弱引用当内存空间不足时进行垃圾回收)。常见的堆缓存,guava cache、Ehcache3、mapDB、以及目前最流行的Java堆内缓存框架Caffeine

3.2、堆外缓存:

即缓存数据存储在堆外内存,减少GC暂停的时间,且缓存容量只受限于本机机器内存大小,但需要进行序列化和反序列化处理,因此比堆内存慢的多。可以使用 Ehcache3,MapDB 实现。

3.3、磁盘缓存:

即缓存数据存储在磁盘上,相对于堆缓存和堆外缓存而言数据不会丢失。可以使用 Ehcache3,MapDB 实现。

3.4、分布式缓存:

上述缓存是进程内的缓存和磁盘缓存,只适用于单机版和单jvm缓存,在分布式环境下并不适应。可以采用当前比较流行redis作为分布式缓存。

四、缓存的实现demo
上述缓存的实现都非常简单,由于篇幅的原因只展示当前比较流行的解决方案。
4.1、Guava cache实现堆内缓存

guava cache是google guava中的一个内存缓存模块,用于将数据缓存到JVM内存中.实际项目开发中经常将一些比较公共或者常用的数据缓存起来方便快速访问.

内存缓存最常见的就是基于 HashMap 实现的缓存,为了解决并发问题也可能也会用到 ConcurrentHashMap(分段加锁) 等并发集合,但是内存缓存需要考虑很多问题,包括并发问题、缓存过期机制、缓存移除机制、缓存命中统计率等.
import com.google.common.cache.CacheBuilder;import com.google.common.cache.CacheLoader;import com.google.common.cache.LoadingCache;
import java.text.SimpleDateFormat;import java.util.Date;import java.util.Random;import java.util.concurrent.TimeUnit;
public class DemoGuavaCache { public static void main(String[] args) throws Exception { LoadingCache<Integer, String> cache = CacheBuilder.newBuilder() //设置并发级别为8,并发级别是指可以同时写缓存的线程数 .concurrencyLevel(8) //设置缓存容器的初始容量为10 .initialCapacity(10) //设置缓存最大容量为100,超过100之后就会按照LRU最近虽少使用算法来移除缓存项 .maximumSize(100) //是否需要统计缓存情况,该操作消耗一定的性能,生产环境应该去除 .recordStats() //设置写缓存后n秒钟过期 .expireAfterWrite(17, TimeUnit.SECONDS) //设置读写缓存后n秒钟过期,实际很少用到,类似于expireAfterWrite //.expireAfterAccess(17, TimeUnit.SECONDS) //只阻塞当前数据加载线程,其他线程返回旧值 //.refreshAfterWrite(13, TimeUnit.SECONDS) //设置缓存的移除通知 .removalListener(notification -> { System.out.println(notification.getKey() + " " + notification.getValue() + " 被移除,原因:" + notification.getCause()); }) //build方法中可以指定CacheLoader,在缓存不存在时通过CacheLoader的实现自动加载缓存          .build(new DemoCacheLoader()); //模拟线程并发 new Thread(() -> { //非线程安全的时间格式化工具 SimpleDateFormat simpleDateFormat = new SimpleDateFormat("HH:mm:ss"); try { for (int i = 0; i < 15; i++) { String value = cache.get(1); System.out.println(Thread.currentThread().getName() + " " + simpleDateFormat.format(new Date()) + " " + value); TimeUnit.SECONDS.sleep(3); } } catch (Exception ignored) { } }).start();
new Thread(() -> { SimpleDateFormat simpleDateFormat = new SimpleDateFormat("HH:mm:ss"); try { for (int i = 0; i < 10; i++) { String value = cache.get(1); System.out.println(Thread.currentThread().getName() + " " + simpleDateFormat.format(new Date()) + " " + value); TimeUnit.SECONDS.sleep(5); } } catch (Exception ignored) { } }).start(); //缓存状态查看 System.out.println(cache.stats().toString()); }
/** * 随机缓存加载,实际使用时应实现业务的缓存加载逻辑,例如从数据库获取数据 */ public static class DemoCacheLoader extends CacheLoader<Integer, String> { @Override public String load(Integer key) throws Exception { System.out.println(Thread.currentThread().getName() + " 加载数据开始"); TimeUnit.SECONDS.sleep(8); Random random = new Random(); System.out.println(Thread.currentThread().getName() + " 加载数据结束"); return "value:" + random.nextInt(10000); } }}
4.2、Caffeine进程内缓存

jvm也可以使用map数据结构实现进程内存储数据,如在多线程情况也可以使用ConcurrentHashMap来存储数据且能保证线程安全,为什么还需要其他的工具或者框架来作为本地缓存?map在一定应用场景可以实现快速存取数据,但大家都知道jvm内存是非常宝贵的资源,如果并发量与数据量很大的情况下,可能会出现oom等异常。而解决上述问题的方式可以使用本地缓存,给缓存数据设置淘汰策略,即在指定时间删除哪些对象。

Caffeine:因使用了 Window TinyLfu 回收策略,提供了一个近乎最佳的命中率。Caffeine api与Guava Cache一样的,下面为Caffeine的简单API

public static void main(String[] args) { Cache<String, String> cache = Caffeine.newBuilder() .expireAfterWrite(1, TimeUnit.SECONDS) .expireAfterAccess(1,TimeUnit.SECONDS) .maximumSize(10) .build(); cache.put("hello","hello");    }
Caffeine的三种填充策略:
  • 手动填充:通过put手动填充

public static void main(String[] args) { Cache<String, Data> cache = Caffeine.newBuilder() // 设置过期时间 .expireAfterWrite(1, TimeUnit.MINUTES) // 缓存数量            .maximumSize(100).build(); // 往缓存中设置  cache.put("A", new Data("A"));        Assert.assertEquals(cache.getIfPresent("A").getData(), "A"); // 取值 若存在返回 不存在返回null Data b = cache.getIfPresent("B");        Assert.assertNull(b); // 取值 若不存在 则按后面计算并返回        b = cache.get("B", k -> new Data(k));        Assert.assertEquals(cache.getIfPresent("B").getData(), "B"); // 手动移除key        cache.invalidate("A"); }
  • 同步加载:这种加载缓存的方式使用了与用于初始化值的 Function 的手动策略类似的 get 方法。让我们看看如何使用它。

 public static void main(String[] args) { LoadingCache<String, Data> cache = Caffeine.newBuilder() .maximumSize(100) .expireAfterWrite(1, TimeUnit.MINUTES)                .build(k -> new Data(k)); //这个获取的仍然为空        Assert.assertNull(cache.getIfPresent("A")); // get() 检索值 此处会根据初始化时指定的方法初始化value Data a = cache.get("a"); Assert.assertNotNull(a);        Assert.assertEquals(a.getData(),"a");}           
  • 异步加载数据

  public static void main(String[] args) { AsyncLoadingCache<String, Data> cache = Caffeine.newBuilder().maximumSize(100).expireAfterWrite(1, TimeUnit.MINUTES).buildAsync(k -> new Data(k)); cache.get("A").thenAccept(data -> { Assert.assertNotNull(data); Assert.assertEquals(data.getData(), "a"); });}
2、Caffeine的三种回收策略:

    Caffeine 有三个值回收策略:基于大小,基于时间和基于引用:

   (1)、基于大小:缓存大小超过配置的大小限制时会发生回收
       
(2)、基于时间:这种回收策略是基于条目的到期时间,有三种类型:

访问后到期 — 从上次读或写发生后,条目即过期。写入后到期 — 从上次写入发生之后,条目即过期自定义策略 — 到期时间由 Expiry 实现独自计算

   (3)、基于引用:

LoadingCache<String, DataObject> cache = Caffeine.newBuilder() .expireAfterWrite(10, TimeUnit.SECONDS) .weakKeys() .weakValues() .build(k -> DataObject.get("Data for " + k)); cache = Caffeine.newBuilder() .expireAfterWrite(10, TimeUnit.SECONDS) .softValues() .build(k -> DataObject.get("Data for " + k));

我们可以将缓存配置启用基于缓存键值的垃圾回收。为此,我们将 key 和 value 配置为 弱引用,并且可以仅配置软引用以进行垃圾回收。

当对象的没有任何强引用时,使用 WeakRefence 可以启用对象的垃圾收回收。SoftReference 允许对象根据 JVM 的全局最近最少使用(Least-Recently-Used)的策略进行垃圾回收。

五、总结

简单的理解,缓存就是将数据从读取较慢的介质上读取出来放到读取较快的介质上,如磁盘-->内存。平时我们会将数据存储到磁盘上,如:数据库。如果每次都从数据库里去读取,会因为磁盘本身的IO影响读取速度,所以就有了像redis这种的内存缓存。可以将数据读取出来放到内存里,这样当需要获取数据时,就能够直接从内存中拿到数据返回,能够很大程度的提高速度。但是一般redis是单独部署成集群,所以会有网络IO上的消耗,虽然与redis集群的链接已经有连接池这种工具,但是数据传输上也还是会有一定消耗。所以就有了应用内缓存,如:caffeine。当应用内缓存有符合条件的数据时,就可以直接使用,而不用通过网络到redis中去获取,这样就形成了两级缓存。应用内缓存叫做一级缓存,远程缓存(如redis)叫做二级缓存。
后面将进行二级缓存的学习和使用。



            

            


以上是关于应用级缓存的主要内容,如果未能解决你的问题,请参考以下文章

Swift新async/await并发中利用Task防止指定代码片段执行的数据竞争(Data Race)问题

h5-应用级缓存

Rail片段缓存如何使您的应用受益,即阻止数据库调用?

应用级缓存

如果数据库已经提供缓存,为啥还要使用应用程序级缓存?

应用级缓存