JAVA中使用最广泛的本地缓存?Ehcache的自信从何而来 —— 感受来自Ehcache的强大实力

10,250 阅读14分钟

本文为稀土掘金技术社区首发签约文章,14天内禁止转载,14天后未获授权禁止转载,侵权必究!


大家好,又见面了。

作为《深入理解缓存原理与实战设计》系列专栏,前面几篇文章中我们详细的介绍与探讨了Guava CacheCaffeine的实现、特性与使用方式。提到JAVA本地缓存框架,还有一个同样无法被忽视的强大存在 —— Ehcache!它最初是由Greg Luck于2003年开始开发,截止目前,Ehcache已经演进到了3.10.0版本,各方面的能力已经构建的非常完善。Ehcache官网上也毫不谦虚的描述自己是“Java's most widely-used cache”,即JAVA中使用最广泛的缓存,足见Ehcache的强大与自信。

此外,Ehcache还是被Hibernate选中并默认集成的缓存框架,它究竟有什么魅力可以让著名的Hibernate对其青眼有加?它与Caffeine又有啥区别呢?我们实际的业务项目里又该如何取舍呢?带着这些疑问,接下来就来认识下Ehcache,一睹Ehcache那些闪闪发光的优秀特性吧!

Ehcache的闪光特性

支持多级缓存

之前文章中我们介绍过的Guava Cache或者是Caffeine,都是纯内存缓存,使用上会受到内存大小的制约,而Ehcache则打破了这一约束。Ehcache2.x时代就已经支持了基于内存磁盘的二级缓存能力,而演进到Ehcache3.x版本时进一步扩展了此部分能力,增加了对于堆外缓存的支持。此外,结合Ehcache原生支持的集群能力,又可以打破单机的限制,完全解决容量这一制约因素。

综合而言,Ehcache支持的缓存形式就有了如下四种:

  • 堆内缓存(heap)

所谓的堆内(heap)缓存,就是我们常规意义上说的内存缓存,严格意义上来说,是指被JVM托管占用的部分内存。内存缓存最大的优势就是具有超快的读写速度,但是不足点就在于容量有限、且无法持久化

在创建缓存的时候可以指定使用堆内缓存,也可以一并指定堆内缓存允许的最大字节数

// 指定使用堆内缓存,并限制最大容量为100M
ResourcePoolsBuilder.newResourcePoolsBuilder().heap(100, MemoryUnit.MB);

除了按照总字节大小限制,还可以按照记录数进行约束:

// 指定使用堆内缓存,并限制最大容量为100个Entity记录
ResourcePoolsBuilder.newResourcePoolsBuilder().heap(100, EntryUnit.ENTRIES);
  • 堆外缓存(off-heap)

堆外(off-heap)缓存,同样是存储在内存中。其实就是在内存中开辟一块区域,将其当做磁盘进行使用。由于内存的读写速度特别快,所以将数据存储在这个区域,读写上可以获得比本地磁盘读取更优的表现。这里的“堆外”,主要是相对与JVM的堆内存而言的,因为这个区域不在JVM的堆内存中,所以叫堆外缓存。这块的关系如下图示意:

看到这里,不知道大家是否有这么个疑问:既然都是内存中存储,那为何多此一举非要将其划分为堆外缓存呢?直接将这部分的空间类驾到堆内缓存上,不是一样的效果吗?

我们知道JVM会基于GC机制自动的对内存中不再使用的对象进行垃圾回收,而GC的时候对系统性能的影响是非常大的。堆内缓存的数据越多,GC的压力就会越大,对系统性能的影响也会越明显。所以为了降低大量缓存对象的GC回收动作的影响,便出现了off-heap处理方式。在JVM堆外的内存中开辟一块空间,可以像使用本地磁盘一样去使用这块内存区域,这样就既享受了内存的高速读写能力,又避免频繁GC带来的烦恼。

可以在创建缓存的时候,通过offheap方法来指定使用堆外缓存并设定堆外缓存的容量大小,这样当heap缓存容量满之后,其余的数据便会存储到堆外缓存中。

ResourcePoolsBuilder.newResourcePoolsBuilder()
        .heap(100, MemoryUnit.KB) // 堆内缓存100K
        .offheap(10, MemoryUnit.MB); // 堆外缓存10M

堆外缓存的时候,offheap的大小设定需要注意两个原则:

  1. offheap需要大于heap的容量大小(前提是heap大小设定的是字节数而非Entity数)
  2. offheap大小必须1M以上

如果设定的时候不满足上述条件,会报错:

Caused by: java.lang.IllegalArgumentException: The value of maxBytesLocalOffHeap is less than the minimum allowed value of 1M. Reconfigure maxBytesLocalOffHeap in ehcache.xml or programmatically.
	at org.ehcache.impl.internal.store.offheap.HeuristicConfiguration.<init>(HeuristicConfiguration.java:55)
	at org.ehcache.impl.internal.store.offheap.OffHeapStore.createBackingMap(OffHeapStore.java:102)
	at org.ehcache.impl.internal.store.offheap.OffHeapStore.access$500(OffHeapStore.java:69)

总结下堆内缓存与堆外缓存的区别与各自优缺点

  1. 堆内缓存是由JVM管理的,在JVM中可以直接去以引用的形式去读取,所以读写的速度会特别高。而且JVM会负责其内容的回收与清理,使用起来比较“省心”。
  2. 堆外缓存是在内存中划定了一块独立的存储区域,然后可以将这部分内存当做“磁盘”进行使用。需要使用方自行维护数据的清理,读写前需要序列化反序列化操作,但可以省去GC的影响。
  • 磁盘缓存(disk)

当我们需要缓存的数据量特别大、内存容量无法满足需求的时候,可以使用disk磁盘存储来作为补充。相比于内存,磁盘的读写速度显然要慢一些、但是胜在其价格便宜,容量可以足够大。

我们可以在缓存创建的时候,指定使用磁盘缓存,作为堆内缓存或者堆外缓存的补充。

ResourcePoolsBuilder.newResourcePoolsBuilder()
        .heap(10, MemoryUnit.MB) 
        .offheap(1, MemoryUnit.MB)
        .disk(10, MemoryUnit.GB); // 指定使用10G磁盘缓存空间

需要注意这里磁盘的容量设定一定要大于前面的heap以及offHeap的大小,否则会报错:

Exception in thread "main" java.lang.IllegalArgumentException: Tiering Inversion: 'Pool {100 MB offheap}' is not smaller than 'Pool {20 MB disk}'
	at org.ehcache.impl.config.ResourcePoolsImpl.validateResourcePools(ResourcePoolsImpl.java:137)
	at org.ehcache.config.builders.ResourcePoolsBuilder.<init>(ResourcePoolsBuilder.java:53)
  • 集群缓存(Cluster)

作为单机缓存,数据都是存在各个进程内的,在分布式组网系统中,如果缓存数据发生变更,就会出现各个进程节点中缓存数据不一致的问题。为了解决这一问题,Ehcache支持通过集群的方式,将多个分布式节点组网成一个整体,保证相互节点之间的数据同步。

需要注意的是,除了堆内缓存属于JVM堆内部,可以直接通过引用的方式进行访问,其余几种类型都属于JVM外部的数据交互,所以对这部分数据的读写时,需要先进行序列化反序列化,因此要求缓存的数据对象一定要支持序列化与反序列化。

不同的缓存类型具有不同的运算处理速度,堆内缓存的速度最快,堆外缓存次之,集群缓存的速度最慢。为了兼具处理性能与缓存容量,可以采用多种缓存形式组合使用的方式,构建多级缓存来实现。组合上述几种不同缓存类型然后构建多级缓存的时候,也需要遵循几个约束:

  1. 多级缓存中必须有堆内缓存,必须按照堆内缓存 < 堆外缓存 < 磁盘缓存 < 集群缓存的顺序进行组合;
  2. 多级缓存中的容量设定必须遵循堆内缓存 < 堆外缓存 < 磁盘缓存 < 集群缓存的原则;
  3. 多级缓存中不允许磁盘缓存集群缓存同时出现;

按照上述原则,可以组合出所有合法的多级缓存类型:

堆内缓存 + 堆外缓存 堆内缓存 + 堆外缓存 + 磁盘缓存 堆内缓存 + 堆外缓存 + 集群缓存 堆内缓存 + 磁盘缓存 堆内缓存 + 集群缓存

支持缓存持久化

常规的基于内存的缓存都有一个通病就是无法持久化,每次重新启动的时候,缓存数据都会丢失,需要重新去构建。而Ehcache则支持使用磁盘来对缓存内容进行持久化保存。

如果需要开启持久化保存能力,我们首先需要在创建缓存的时候先指定下持久化结果存储的磁盘根目录,然后需要指定组合使用磁盘存储的容量,并选择开启持久化数据的能力。

public static void main(String[] args) {
    CacheManager cacheManager = CacheManagerBuilder.newCacheManagerBuilder()
            .withCache("myCache", CacheConfigurationBuilder.newCacheConfigurationBuilder(Integer.class,
                    String.class,
                    ResourcePoolsBuilder.newResourcePoolsBuilder()
                            .heap(1, MemoryUnit.MB)
                            .disk(10, MemoryUnit.GB, true)) // 指定需要持久化到磁盘
                    .build())
            .with(CacheManagerBuilder.persistence("d:\\myCache\\")) // 指定持久化磁盘路径
            .build(true);
    Cache<Integer, String> myCache = cacheManager.getCache("myCache", Integer.class, String.class);
    myCache.put(1, "value1");
    myCache.put(2, "value2");
    System.out.println(myCache.get(2));
    cacheManager.close();
}

执行之后,指定的目录里面会留有对应的持久化文件记录:

这样在进程重新启动的时候,会自动从持久化文件中读取内容并加载到缓存中,可以直接使用。比如我们将代码修改下,缓存创建完成后不执行put操作,而是直接去读取数据。比如还是上面的这段代码,将put操作注释掉,重新启动执行,依旧可以获取到缓存值。

支持变身分布式缓存

在本专栏开立后的第一篇文章《聊一聊作为高并发系统基石之一的缓存,会用很简单,用好才是技术活》中,我们介绍了下在集群多节点场景下本地缓存经常会出现的一个缓存漂移问题。比如一个互动论坛系统里面,其中一个节点处理了修改请求并同步更新了自己的本地缓存,但是其余节点没有感知到这个变更操作,导致相互之间内存数据不一致,这个时候查询请求就会出现一会正常一会异常的情况。

对于分布式系统,或者是集群场景下,并非是本地缓存的主战场。为了保证集群内数据的一致性,很多场景往往就直接选择Redis集中式缓存。但是集中式缓存也弊端,比如有些数据并不怎么更新、但是每个节点对其依赖度却非常高,如果频繁地去Redis请求交互,又会导致大量的性能损耗在网络IO交互处理上。

针对这种情况,Ehcache给出了一个相对完美的答案:本地 + 集群化策略。即在本地缓存的基础上,将集群内各本地节点组成一个相互连接的网,然后基于某种机制,将一个节点上发生的变更同步给其余节点进行同步更新自身缓存数据,这样就可以实现各个节点的缓存数据一致。

Ehcache提供了多种不同的解决方案,可以将其由本地缓存变身为“分布式缓存”:

  • RMI组播方式

  • JMS消息方式

  • Cache Server模式

  • JGroup方式

  • Terracotta方式

在下一篇文章中,将专门针对上面的几种方式进行展开介绍。

更灵活和细粒度的过期时间设定

前面我们介绍过的本地缓存框架Caffeine与Guava Cache,它们支持设定过期时间,但是仅允许为设定缓存容器级别统一的过期时间,容器内的所有元素都遵循同一个过期时间。

Ehcache不仅支持缓存容器对象级别统一的过期时间设定,还会支持为容器中每一条缓存记录设定独立过期时间,允许不同记录有不同的过期时间。这在某些场景下还是非常友好的,可以指定部分热点数据一个相对较长的过期时间,避免热点数据因为过期导致的缓存击穿

同时支持JCache与SpringCache规范

Ehcache作为一个标准化构建的通用缓存框架,同时支持了JAVA目前业界最为主流的两大缓存标准,即官方的JSR107标准以及使用非常广泛的Spring Cache标准,这样使得业务中可以基于标准化的缓存接口去调用,避免了Ehcache深度耦合到业务逻辑中去。

作为当前绝对主流的Spring框架,Ehcache可以做到无缝集成,便于项目中使用。在下面的章节中会专门介绍如何与Spring进行集成,此处先不赘述。

Hibernate的默认缓存策略

Hibernate是一个著名的开源ORM框架实现,提供了对JDBC的轻量级封装实现,可以在代码中以面向对象的方式去操作数据库数据,此前著名的SSH框架中的H,指的便是Hibernate框架。Hibernate支持一二级缓存,其中一级缓存是session级别的缓存,默认开启。而Hibernate的二级缓存,默认使用的便是Ehcache来实现的。能够被大名鼎鼎的Hibernate选中作为默认的缓存实现,也可以证明Ehcache不俗的实力。

Ehcache、Caffeine、Redis如何选择

之前的文章中介绍过Caffeine的相关特性与用法,两者虽然同属JVM级别的本地缓存框架,但是两者在目标细分领域,还是各有侧重的。而作为具备分布式能力的本地缓存,Ehcache与天生的分布式集中式缓存之间似乎也存在一些功能上的重合度,那么EhcacheCaffeineRedis三者之间应该如何选择呢?先看下三者的定位:

  • Caffeine
  1. 更加轻量级,使用更加简单,可以理解为一个增强版的HashMap
  2. 足够纯粹,适用于仅需要本地缓存数据的常规场景,可以获取到绝佳的命中率与并发访问性能。
  • Redis
  1. 纯粹的集中缓存,为集群化、分布式多节点场景而生,可以保证缓存的一致性;
  2. 业务需要通过网络进行交互,相比与本地缓存而言性能上会有损耗
  • Ehcache
  1. 支持多级缓存扩展能力。通过内存+磁盘等多种存储机制,解决缓存容量问题,适合本地缓存中对容量有特别要求的场景;
  2. 支持缓存数据持久化操作。允许将内存中的缓存数据持久化到磁盘上,进程启动的时候从磁盘加载到内存中;
  3. 支持多节点集群化组网。可以将分布式场景下的各个节点组成集群,实现缓存数据一致,解决缓存漂移问题。

相比而言,Caffeine专注于提供纯粹且简单的本地基础缓存能力、Redis则聚焦统一缓存的数据一致性方面,而Ehcache的功能则是更为的中庸,介于两者之间,既具有本地缓存无可比拟的性能优势,又兼具分布式缓存的多节点数据一致性与容量扩展能力。项目里面进行选型的时候,可以结合上面的差异点,评估下自己的实际诉求,决定如何选择。

简单来说,把握如下原则即可:

  • 如果只是本地简单、少量缓存数据使用的,选择Caffeine

  • 如果本地缓存数据量较大、内存不足需要使用磁盘缓存的,选择EhCache

  • 如果是大型分布式多节点系统,业务对缓存使用较为重度,且各个节点需要依赖并频繁操作同一个缓存,选择Redis

小结回顾

好啦,关于Ehcache的一些问题关键特性,就介绍到这里了。不知道小伙伴们是否开始对Ehcache更加的感兴趣了呢?后面我们将一起来具体看下如何在项目中进行集成与使用Ehcache,充分去发掘与体验其强大之处。而关于Ehcache你是否有自己的一些想法与见解呢?欢迎评论区一起交流下,期待和各位小伙伴们一起切磋、共同成长。

📣 补充说明1

本文属于《深入理解缓存原理与实战设计》系列专栏的内容之一。该专栏围绕缓存这个宏大命题进行展开阐述,全方位、系统性地深度剖析各种缓存实现策略与原理、以及缓存的各种用法、各种问题应对策略,并一起探讨下缓存设计的哲学。

如果有兴趣,也欢迎关注此专栏。

📣 补充说明2

  • 关于本文中涉及的演示代码的完整示例,我已经整理并提交到github中,如果您有需要,可以自取:github.com/veezean/Jav…

我是悟道,聊技术、又不仅仅聊技术~

如果觉得有用,请点赞 + 关注让我感受到您的支持。也可以关注下我的公众号【架构悟道】,获取更及时的更新。

期待与你一起探讨,一起成长为更好的自己。