web后端-SpringCahce
参考资料
- 精通Spring 4.x 企业应用开发实战
缺失待补
缓存概述
实战经验
为什么要使用缓存?
其实在现实生活中也可以找到很多“缓存”的影子。比如,京东的物流为什么那么快,甚至可以实现当日送达,就因为其在全国各地都有分仓库,在发货的时候,会找离客户最近的仓库,如果该仓库有货物,则安排就近送货。用过 Maven的朋友应该知道,在找依赖构件的时候,先从本机仓库找,如果没有再从本地服务器仓库找,最后才到远程服务器仓库找。
所以可将缓存定义为一种存储机制,它将数据保存在某个地方,并以一种更快的方式提供服务。较为常见的一种情况是在应用中使用缓存机制,以避免方法的多次执行。缓存的策略有很多种,在应用系统中可根据实际情况选择,通常会把一些静态数据或者变化频率不高的数据放到缓存中,如配置参数、字典表等。而有些场景可能要寻求替代方案,比如,想提升全文检索的速度,在复杂的场景下建议使用搜索引擎,如 Solr或ElasticSearch。
通常在 Web 应用开发中,不同层级对应的缓存要求和缓存策略全然不同。如图15-1所示列举了系统不同层级对应的缓存技术选型。
缓存命中率
- 即从缓存中读取数据的次数与总读取次数的比率。一般来说,命中率越高越好。
- 命中率 =从缓存中读取的次数/(总读取次数[从缓存中读取的次数+从慢速设备上读取的次数])
- Miss率= 没有从缓存中读取的次数/(总读取次数[从缓存中读取的次数+从慢速设备上读取的次数])
过期策略
- 即如果缓存满了,从缓存中移除数据的策略。常见的有LFU、LRU、FIFO。
- FIFO (First In First Out):先进先出策略,即先放入缓存的数据先被移除。LRU (Least Recently Used):最久未使用策略,即使用时间距离现在最久的那个数据被移除。
- LFU(Least Frequently Used):最近最少使用策略,即一定时间段内便用次数(频率)最少的那个数据被移除。
- TTL (Time To Live):存活期,即从缓存中创建时间点开始直至到期的一个时间段(不管在这个时间段内有没有访问都将过期)。
- TTI (Time To Idle):空闲期,即一个数据多久没被访问就从缓存中移除的时间。
至此,我们基本了解了缓存的一些基本知识。在Java中,一般会对调用方法进行缓存控制。比如调用“findUserById(String id)",应该在调用这个方法之前先从缓存中查找有没有符合查询条件的数据,如果没有,则执行该方法从数据库中查找该用户,然后添加到缓存中,下次调用时将会从缓存中获取该数据。
从Spring 3.1开始,提供了类似于@Transactional事务注解的缓存注解,且提供了Cache层的抽象。通过使用AOP对方法进行织入。为了启用AOP缓存功能,需要使用缓存注解对类中的相关方法进行标记,以便Spring 为其生成具备缓存功能的代理类。需要注意的是,Spring Cache仅提供了一种抽象而未提供具体实现。在此之前,我们一般会自己使用AOP来做一定程度的封装实现,使用Spring Cache带来的好处如下:
- 支持开箱即用(Out-Of-The-Box),并提供基本的Cache抽象,方便切换各种底层Cache。
- 类似于Spring 提供的数据库事务管理,通过Cache注解即可实现缓存逻辑透明化,让开发者关注业务逻辑。
- 当事务回滚时,缓存也会自动回滚。支持比较复杂的缓存逻辑。
- 提供缓存编程的一致性抽象,方便代码维护。
需要注意的是,Spring Cache并不针对多进程的应用环境进行专门的处理,也就是说,当应用程序处于分布式或者集群环境下时,需要针对具体的缓存进行相应的配置。如 EhCache可以通过RMI、JGroups 及 EhCache Server等方式来配置其多播集群环境。另外,在Spring Cache抽象的操作中没有锁的概念,当多线程并发操作(更新或者删除)同一个缓存项时,将可能得到过期的数据。有些缓存实现提供了锁的功能,如果需要考虑如上场景,则可以详细了解具体缓存的一些相关特性,如 EhCache就提供了针对缓存元素key的 Read(读)、Write(写)锁。
使用 Spring Cache
假设一个场景:在日常所见的项目中,用户查询是一个非常频繁的动作,从性能优化的角度,自然会想到对一个用户的查询方法做缓存,以避免频繁的数据库访问操作,提高页面的响应速度。通常的做法是以用户的userId作为key,以返回的用户信息对象作为value值存储。而当以相同的userld查询用户时,程序将直接从缓存中获取结果并返回;否则更新缓存。
一个例子
首先定义一个User实体类,该类具备基本的userId、age 及 userName属性,且具备getter和 setter方法,如代码清单15-1所示。
Java对象的缓存和序列化是息息相关的,一般情况下,需要被缓存的实体类需要实现Serializable,只有实现了Serializable 按口的尖,JVM才能对其对象及进行虚拟化。对于Redis、EhCache等缓存套件来说,被缓存的对象应该是可序列化的,否则在网络传输、硬盘存储时都会抛出序列化异常。当然,由于这里直接采用Map以内存的方式存储缓存对象,所以 User不实现Serializable接口也不会有问题,但是实体类始终实现Serializable接口是一个好的编程习惯。实现 Serializable接口的实体类,一般要求声明一个serialVersionUID成员变量,以表明该实体类的版本。如果实体类的结构发生变化,则可以修改serialVersionUID值以支持反序列化工作。关于对象序列化和反序列化的更多知识,读者可自行查找相关资料学习,在此不再展开。
接下来定义一个缓存管理器,这个管理器负责实现缓存逻辑,支持对象的增加、修改和删除,并且支持值对象的泛型,具体实现如代码清单15-2所示。
现在有了实体类(User)和一个缓存管理器(CacheManager),还需要一个挺兴用户查询的服务类,此服务类使用缓存管理器来支持用户查询,如代码清单15-3所示。
现在有了实体类(User)和一个缓存管理器(CacheManager),还需要一个提供用户查询的服务类,此服务类使用缓存管理器来支持用户查询,如代码清单15-3所示。
现在开始写一个测试类,用于测试刚才的方法,如代码清单15-4所示。
按照分析,执行结果应该是:首先从数据库加载,然后直接返回缓存中的结果;重置缓存后,应该首先从数据库加载,然后返回缓存中的结果。实际的执行结果如下:
缺点
可以看出,自定义缓存可以正常工作,但这种实现方式并不优雅:缓存代码和业务代码高度耦合,业务代码中穿插着大量的缓存控制逻辑,并且代码显式依赖缓存的具体实现。此外,本实现方案并不支持按条件缓存,比如只有符合某种条件的用户账号才需要缓存。如果要新增这一功能,代码将进一步复杂化。
业务代码中穿插着大量的缓存控制逻辑,并且代码显式依赖缓存的具体实现这个可以从一下代码看出,改进放方法就是进行封装,参考待补。
改进版
下面使用Spring Cache来实现上面这个例子,基于刚才自定义缓存方案的实体类User,改造 UserService和 UserMain,如代码清单15-5所示。因为Spring已经提供了默认的缓存管理器,所以这个例子不用实现自定义的缓存管理器。
getUserByld()方法标注了一个注解:@Cacheable(cacheNames ="users"),当调用这个方法的时候,会先从users缓存中查询匹配的缓存对象,如果存在则直接返回;如果不存在,则执行方法体内的逻辑(查询数据库),并将返回值放入缓存中。对应缓存的 key为userId的值, value就是userld所对应的User对象,缓存名称需要在applicationContext.xml中定义。
现在还需要一个Spring配置文件来支持基于注解的缓存,如代码清单15-6所示。
Spring通过<cache:annotation-driven />即可启用基于注解的缓存驱动,这个配置项默认使用了一个定义为cacheManager 的缓存管理器。SimpleCacheManager是这个缓存管理器的默认实现,它通过对其属性caches 的配置来实现刚刚自定义的缓存管理器逻辑。从上面的代码中可以看到,除了默认的default缓存外,还自定义了一个名为users的缓存,使用了默认的内存存储方案ConcurrentMapCacheFactoryBean,它是一个基于java.util.concurrent.ConcurrentHashMap的内存缓存实现方案。
接下来便可以重写UserMain,以观察使用Spring Cache运行的效果,如代码清单15-7所示。
掌握 Spring Cache抽象
缓存注解
Spring Cache提供了5种可在方法级别或类级别上使用的缓存注解。这些注解定义了哪些方法的返回值会被缓存或者从缓存中移除。注意,只有使用public定义的方法才可以被缓存,而private方法、protected方法或者使用default修饰符的方法都不能被缓存。当在一个类上使用注解时,该类中每个公共方法的返回值都将被缓存到指定的缓存项中或者从中移除。本节将详细介绍这5种缓存注解的用法及所支持的属性。
@Cacheable是最主要的注解,它指定了被注解方法的返回值是可被缓存的。其:作原理是Spring首先在缓存中查找数据,如果没有则执行方法并缓存结果,然后返回据。缓存名是必须提供的,可以使用引号、Value或者cacheNames属性来定义名称。面的定义展示了users缓存的声明及其注解的使用:
@Cacheable
@Cacheable是最主要的注解,它指定了被注解方法的返回值是可被缓存的。其:作原理是Spring首先在缓存中查找数据,如果没有则执行方法并缓存结果,然后返回据。缓存名是必须提供的,可以使用引号、Value或者cacheNames属性来定义名称。面的定义展示了users缓存的声明及其注解的使用:
此外,还可以以列表的形式提供多个缓存,在该列表中使用逗号分隔缓存名称,并用花括号括起来。以下代码展示了在@Cacheable注解中定义的两个缓存cache1和 cache2:
@cacheable (cacheNames -{ "cache1", "cache2"})
下面所示的代码片段展示了如何在方法上应用@Caheable注解: getUser(String id)方法以id值为键值将用户缓存到users缓存段中。此外,还可以提供自定义键,以便获取存储在某一缓存中的数据。
键生成策略
缓存的本质就是键/值对集合。在默认情况下,缓存抽象使用方法签名及参数值作头一个键值,并将该键与方法调用的结果组成键/值对。如果在Cache注解上没有指定key.则Spring会使用KeyGenerator来生成一个key。
Sping默认提供了SimpleKeyGenerator生成器。Spring 4.0废弃了3.x的 DefaultKeyGenerator而用 SimpleKeyGenerator取代,原因是 DefaultKeyGenerator在有多个入参时只是简单地把所有入参放在一起使用hashCode()方法生成 key值,这样很容易造成key冲突。SimpleKeyGenerator使用一个复合键 SimpleKey来解决这个问题。接下来通过Spring源码来看看Spring 生成key 的规则。
通过源码来学习Spring 往往简单明了,从上面的代码中可以发现其生成规则如下:
- 如果方法没有入参,则使用SimpleKey.EMPTY作为key.
- 如果只有一个入参,则使用该入参作为key。
- 如果有多个入参,则返回包含所有入参的一个 SimpleKey
此外,还可以在声明中指定键值。@Cacheable注解提供了实现该功能的key属性,通过该属性,可以使用 SpEL指定自定义键,如下:
如上所示,入参中有一个boolean值用于区分用户是否已经注销,而这个boolean值并不想作为key的一部分,那么可以根据key属性指定使用userCode作为缓存键。
当然,如果key 生成策略涉及一些比较复杂的算法,而这个key生成策略又是通用的,则可以通过实现 org.springframework.cache.interceptor.KeyGenerator接口来定义个性化的key生成器。如自定义了一个MyKeyGenerator类并且实现了KeyGenerator接口以实现自定义的key 生成器,那么便可如下使用:
带条件的缓存
使用@Cacheable 注解的condition属性可按条件进行缓存,condition属性使用了SpEL表达式动态评估方法入参是否满足缓存条件。关于SpEL表达式的相关内容,斥文会进行介绍。
对于使用了@Cacheable注解声明的getUser()方法,我们启用了一个缓存判断条件;仅对年龄小于35周岁的用户启用缓存。
在上述代码中,#user引用方法的同名入参变量,接着通过.age 访问user 入参对象的age属性值。在调用方法前,将对注解中声明的条件进行评估,满足条件才缓存。
与condition属性相反,可使用unless属性排除某些不希望缓存的对象。下面的示例拒绝了对年龄大于或等于35周岁的用户进行缓存。
参数详解
@CachePut
@CachePut注解与@Cacheable注解效果几乎一样,它首先执行方法,然后将返回值放入缓存。当希望使用方法返回值来更新缓存时,便可以选择这种方法,如下:
参数详解
需要注意的是,在同一个方法内不能同时使用@CachePut 和@Cacheable注解,因为它们拥有不同的特性。当@Cacheable注解跳过方法直接获取缓存时,@CachePut注解会强制执行方法以更新缓存,这会导致意想不到的情况发生,如当注解都带入了条件属性,就会使得它们彼此排斥。还需要注意的是,@CachePut注解的condition属性设置的缓存条件也不应该依赖于方法返回的结果(如 condition="#result"),因为缓存条件是在方法执行前预先验证的。
@CacheEvict
@CacheEvict注解是@Cachable注解的反向操作,它负责从给定的缓存中移除一个值。大多数缓存框架都提供了缓存数据的有效期,使用该注解可以显式地从缓存中删除失效的缓存数据。该注解通常用于更新或者删除用户的操作。下面的方法定义从数据库中删除一个用户,而@CacheEvict 注解也完成了相同的工作,从users 缓存中删除了被缓存的用户。
与@Cacheable注解一样,@CacheEvict注解也提供了key和 condition属性,通过这些属性可以使用SpEL表达式指定自定义的键和条件。
此外,@CacheEvict注解还具有两个与@Cacheable注解不同的属性: allEntries属性定义了是否移除缓存的所有条目,其默认行为是不移除这些条目; beforeInvocation属性定义了在调用方法之前还是在调用方法之后完成移除操作。与@Cacheable注解不同的是,在默认情况下,@CacheEvict注解在方法调用之后运行。
1) allEntries属性
allEntries是布尔类型的,用来表示是否需要清除缓存中的所有元素。默认值为false,表示不需要。当指定allEntries为 true时,Spring Cache将忽略指定的key,清除缓存中的所有内容。具体使用方法如下:
2)beforelnvocation属性
需要知道,清除操作默认是在对应方法执行成功后触发的,即方法如果因为抛出异常而未能成功返回时则不会触发清除操作。使用beforeInvocation属性可以改变触发清除操作的时间。当指定该属性值为true时,Spring 会在调用该方法之前清除缓存中的指定元素。具体使用方法如下:
需要注意的是,在相同的方法上使用@Caheable和@CacheEvict注解并使用它们指向相同的缓存没有任何意义,因为这相当于数据被缓存之后又被立即移除了,所以需要避免在同一方法上同时使用这两个注解。
参数详解
@Caching
@Caching是一个组注解,可以为一个方法定义提供基于@Cacheable、@CacheEvict或者@CachePut注解的数组。为了方便说明@Caching注解的使用方法,示例定义了User.Member(会员)和Visitor(游客)3个实体类,它们彼此之间有一个简单的层次结构:User是一个抽象类,而 Member和 Visitor类扩展了该类。
在代码清单15-8中,UserService类是一个 Spring服务Bean,包含了getUser()方法。同时声明了两个@Cacheable注解,并使其指向两个不同的缓存项: members和 visitors。然后根据两个@Cacheable 注解定义中的条件对方法的参数进行检查,并将对象存储在members 或 visitors缓存中。
@CacheConfig
在Spring 4.0之前并没有类级别的全局缓存注解。前面我们所了解的注解都是基于方法的,如果在同一个类中需要缓存的方法注解属性都相似,则需要一个个地重复增加Spring 4.0增加了@CacheConfig类级别的注解来解决这个问题。一个典型的使用示例如代码清单15-9所示。
可以看到,在@CacheConfig注解中定义了类级别的缓存users和自定义key生成器,那么在 findA()和 findB()方法中不再需要重复指定,而是默认使用类级别的定义。
缓存管理器
CacheManager是SPI (Service Provider Interface,服务提供程序接口),提供了访问缓存名称和缓存对象的方法,同时也提供了管理缓存、操作缓存和移除缓存的方法。本节将列举 Spring Cache框架所提供的不同的缓存管理器实现。
SimpleCacheManager
通过使用SimpleCacheManager可以配置缓存列表,并利用这些缓存进行相关的操作。下面的代码片段是针对该缓存管理器的一个配置示例。对应缓存的定义,我们使用了ConcurrentMapCaheFactoryBean类来对ConcurrentMapCache进行实例化,该实例使用了JDK的ConcurrentMap 实现。
NoOpCacheManager
NoOpCacheManager主要用于测试目的,但实际上它并不缓存任何数据。下面的代码给出了该缓存管理器的配置定义,我们没有为该管理器提供缓存列表,因为它仅仅作为测试目的。
<bean id="cacheManager" class="org.springframework.cache.support.NoOpCache Manager>
ConcurrentMapCacheManager
ConcurrentMapCacheManager使用了JDK的ConcurrentMap。它提供了与前面介绍的SimpleCacheManager类似的功能,但并不需要像前面那样定义缓存。该缓存定义如下:
<bean id="cacheManager"class="org.springframework.cache.concurrent.ConcurrentMapCacheManager">
CompositeCacheManager
CompositeCacheManager能够定义多个缓存管理器。当在应用程序上下文中声明<cache:annotation-driven>标记时,它只提供一个缓存管理器,这往往并不能满足用户的需求。而CompositeCacheManager 定义将多个缓存管理器定义组合在一起,从而扩展了该功能。此外,CompositeCacheManager还提供了一种机制,通过使用fallbackToNoOpCache属性回到NoOpCacheManager。
下面所示的定义是一个 CompositeCacheManager定义,将一个简单的缓存管理器与HazelCast缓存管理器捆绑在一起。简单的缓存管理器定义了members缓存,而HazelCast缓存管理器则为visitors定义了缓存管理器。配置HazelCast缓存管理器的详细过程将在本章稍后介绍。下面的示例展示了可在不同的缓存中存储不同类型的对象,而不同的缓存则由不同的缓存管理器进行管理。
使用SpEL表达式
在 Spring Cache注解属性中(如 key、condition和 unless),Spring 的缓存抽象使用了SpEL表达式,从而提供了属性值的动态生成及足够的灵活性。下面代码所示的方法根据用户编码进行了缓存。对于key属性,使用了表达式来自定义键的生成。
在下面的代码片段中,还应用了一个条件,以便对那些年龄小于35周岁的用户进行缓存。
参数详解
基于XML的 Cache声明
如果不想使用注解或者由于其他原因而无法获得项目的源码(如二次开发时经常没有源码的访问权限),也可以用XML 的方式配置Spring Cache。其配置方式和transaction管理器的advice类似,如下例:
如上配置文件为UserService开启了缓存。使用cache:advice定义包装了两个需要使用缓存的方法,其中 findUser()定义了Cacheable,而loadUsers()定义了CacheEvict,并且定义了公共的缓存users.
aop:config定义了cacheAdvice的切入点(关于AOP的定义,具体请参考第8章)。XML声明式配置支持所有注解的方法,因此二者之间可以很容易地替换,当然也可以在项目中同时使用这两种方式。
以编程方式初始化缓存
在实际项目中,有时可能需要在使用之前就完成缓存的初始化。最典型的示例是当启动并且运行应用程序时将数据加载到缓存中。实现该方法很简单,首先访问缓存管理器,然后将数据手工加载到不同的缓存中。
代码清单15-10展示了在Sping Bean的@PostConstrut注解方法中初始化缓存users。此外,该Bean还包含了getUser()方法,并且已被注解用于缓存操作。
接下来创建UserService类作为Spring服务Bean,并且自动注入 CacheManager,然后在@PostConstruct中通过键值把所有数据放置到缓存中,该键值将用于检索。
下面通过基于Java类的配置方式准备好Spring容器配置信息,如代码清单15-11所示。
用UserMain测试以上缓存实现的效果,如代码清单15-12所示。
自定义缓存注解
之前介绍的@Caching 组合也许会让方法上的注解显得比较杂乱,Spring 提供了自定义注解,可把这些注解组合到一个注解类中,从而解决这个问题,如下代码所示:
配置Cache存储
在企业级应用中往往会有更复杂的功能和性能需求,所以在日常开发过程中,大部分情况下会使用第三方的缓存实现,而不是SimpleCacheManager的简单实现。在企业级Java领域,Spring缓存提供了与不同缓存框架的集成支持。
EhCache
EhCache是广泛使用的Java开源缓存框架之一,本节将介绍如何通过Spring Cache来集成EhCache,以便在项目中便捷地使用。
在项目中增加 EhCache的依赖。我们选择的是2.8.3版本,该版本最值得关注的新属性包括Off-Heap存储和JSR-107兼容。
在配置文件中声明了cacheManager Bean。通过使用 EhCache定义cacheManager是非常简单和直接的。它包含了另外一个名为ehcache 的 Bean,并使用配置文件ehcache.xml对其进行配置。下面给出一个最简单的ehcache.xml配置文件,其中仅包含了users缓存的定义。
Ehcache还提供了TTL或者Eviction Policy之类的功能,并且可以对这些功能进行配置。而相关配置应该直接通过缓存提供程序完成,因为缓存抽象没有为这些功能提供任何配置(这些功能不可能被不同的提供程序所支持,如JDK的 ConcurrentMap)。如果想进一步配置,请参考EhCache的相关文档。
实战经验
非 public方法问题
和内部调用问题类似,非 public方法如果想实现基于注解的缓存,则必须采用基于AspectJ的AOP机制。这里限于篇幅不再详述,可参考第8章。
基于Proxy 的 Spring AOP带来的内部调用问题
前面介绍了Spring Cache的原理,即它是基于动态生成子类的代理机制来对方法的调用进行切面的,这里的关键点是对象的引用问题。如果对象的方法是内部调用(this引用)而不是外部引用,则会导致代理失效,那么切面就失效,也就是说上面定义的各种注解,包括@Cacheable、@CachePut和@CacheEvict都会失效。来看下面的示例:
上面定义了一个新的方法getUserByName2(),其自身调用了getUserByName()方法,这时发生的是内部调用(this),所以没有使用代理类,导致Spring Cache失效。要避免这个问题,就要避免对缓存方法的内部调用,或者避免使用基于代理机制的 AOP模式,也可以使用基于AspectJ的AOP模式来解决这个问题。如果想使用基于代理机制的AOP模式,要想避免此问题,则可以参考7.5.4节介绍的方法。
@CacheEvict的可靠性问题
我们看到,@CacheEvict注解有一个属性 beforeInvocation,默认为false,即默认情况下都是在实际的方法执行完成后才对缓存进行清空操作。期间如果执行方法出现异常,则会导致缓存清空而不被执行。
运行期开发
有的时候,在代码迁移、调试或者部署的时候,恰好没有可运行的缓存服务容器,比如 MemCache还不具备条件、GemFire还没有安装好等,如果这时候想调试代码,基本上非常困难。这里有一个办法,在不具备缓存条件的时候,在不更改代码的前提下,禁用缓存。
方法就是修改 spring*.xml 配置文件,设置一个找不到缓存就不执行任何操作的标志位,如下: