Cache API及默认提供的实现
Spring提供的核心Cache接口:
package org.springframework.cache; public interface Cache { String getName(); //缓存的名字 Object getNativeCache(); //得到底层使用的缓存,如Ehcache ValueWrapper get(Object key); //根据key得到一个ValueWrapper,然后调用其get方法获取值 <T> T get(Object key, Class<T> type);//根据key,和value的类型直接获取value void put(Object key, Object value);//往缓存放数据 void evict(Object key);//从缓存中移除key对应的缓存 void clear(); //清空缓存 interface ValueWrapper { //缓存值的Wrapper Object get(); //得到真实的value } }
提供了缓存操作的读取/写入/移除方法;
默认提供了如下实现:
ConcurrentMapCache:使用java.util.concurrent.ConcurrentHashMap实现的Cache;
GuavaCache:对Guava com.google.common.cache.Cache进行的Wrapper,需要Google Guava 12.0或更高版本,@since spring 4;
EhCacheCache:使用Ehcache实现
JCacheCache:对javax.cache.Cache进行的wrapper,@since spring 3.2;spring4将此类更新到JCache 0.11版本;
另外,因为我们在应用中并不是使用一个Cache,而是多个,因此Spring还提供了CacheManager抽象,用于缓存的管理:
package org.springframework.cache; import java.util.Collection; public interface CacheManager { Cache getCache(String name); //根据Cache名字获取Cache Collection<String> getCacheNames(); //得到所有Cache的名字 }
默认提供的实现:
ConcurrentMapCacheManager/ConcurrentMapCacheFactoryBean:管理ConcurrentMapCache;
GuavaCacheManager;
EhCacheCacheManager/EhCacheManagerFactoryBean;
JCacheCacheManager/JCacheManagerFactoryBean;
另外还提供了CompositeCacheManager用于组合CacheManager,即可以从多个CacheManager中轮询得到相应的Cache,如
<bean id="cacheManager" class="org.springframework.cache.support.CompositeCacheManager"> <property name="cacheManagers"> <list> <ref bean="ehcacheManager"/> <ref bean="jcacheManager"/> </list> </property> <property name="fallbackToNoOpCache" value="true"/> </bean>
当我们调用cacheManager.getCache(cacheName) 时,会先从第一个cacheManager中查找有没有cacheName的cache,如果没有接着查找第二个,如果最后找不到,因为fallbackToNoOpCache=true,那么将返回一个NOP的Cache否则返回null。
除了GuavaCacheManager之外,其他Cache都支持Spring事务的,即如果事务回滚了,Cache的数据也会移除掉。
Spring不进行Cache的缓存策略的维护,这些都是由底层Cache自己实现,Spring只是提供了一个Wrapper,提供一套对外一致的API。
demo
依赖包安装
<!-- redis cache related.....start --> <dependency> <groupId>org.springframework.data</groupId> <artifactId>spring-data-redis</artifactId> <version>1.6.0.RELEASE</version> </dependency> <dependency> <groupId>redis.clients</groupId> <artifactId>jedis</artifactId> <version>2.7.3</version> </dependency> <!-- redis cache related.....end -->
定义实体类、服务类和相关配置文件
Account.java
package cacheOfAnno; public class Account { private int id; private String name; public Account(String name) { this.name = name; } public int getId() { return id; } public void setId(int id) { this.id = id; } public String getName() { return name; } public void setName(String name) { this.name = name; } }
AccountService.java
package cacheOfAnno; import org.springframework.cache.annotation.CacheEvict; import org.springframework.cache.annotation.Cacheable; public class AccountService { @Cacheable(value="accountCache")// 使用了一个缓存名叫 accountCache public Account getAccountByName(String userName) { // 方法内部实现不考虑缓存逻辑,直接实现业务 System.out.println("real query account."+userName); return getFromDB(userName); } private Account getFromDB(String acctName) { System.out.println("real querying db..."+acctName); return new Account(acctName); } }
注意,此类的 getAccountByName 方法上有一个注释 annotation,即 @Cacheable(value=”accountCache”),这个注释的意思是,当调用这个方法的时候,会从一个名叫 accountCache 的缓存中查询,如果没有,则执行实际的方法(即查询数据库),并将执行的结果存入缓存中,否则返回缓存中的对象。这里的缓存中的 key 就是参数 userName,value 就是 Account 对象。“accountCache”缓存是在 spring*.xml 中定义的名称。
好,因为加入了 spring,所以我们还需要一个 spring 的配置文件来支持基于注释的缓存。
Spring-cache-anno.xml
<?xml version="1.0" encoding="UTF-8"?> <beans xmlns="http://www.springframework.org/schema/beans" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:p="http://www.springframework.org/schema/p" xmlns:cache="http://www.springframework.org/schema/cache" xsi:schemaLocation="http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans-4.2.xsd http://www.springframework.org/schema/cache http://www.springframework.org/schema/cache/spring-cache-4.2.xsd"> <!-- 启用缓存注解功能,这个是必须的,否则注解不会生效,另外,该注解一定要声明在spring主配置文件中才会生效 --> <cache:annotation-driven cache-manager="cacheManager" key-generator="workingKeyGenerator"/> <!-- redis 相关配置 --> <bean id="poolConfig" class="redis.clients.jedis.JedisPoolConfig"> <!--<property name="maxIdle" value="${redis.maxIdle}" />--> <!--<property name="maxWaitMillis" value="${redis.maxWait}" />--> <!--<property name="testOnBorrow" value="${redis.testOnBorrow}" />--> </bean> <bean id="JedisConnectionFactory" class="org.springframework.data.redis.connection.jedis.JedisConnectionFactory" p:host-name="${redis.ip}" p:port="${redis.port}" p:pool-config-ref="poolConfig"/> <bean id="redisTemplate" class="org.springframework.data.redis.core.RedisTemplate"> <property name="connectionFactory" ref="JedisConnectionFactory" /> </bean> <!-- spring自己的缓存管理器,这里定义了缓存位置名称 ,即注解中的value --> <bean id="cacheManager" class="org.springframework.cache.support.SimpleCacheManager"> <property name="caches"> <set> <bean class="org.springframework.cache.concurrent.ConcurrentMapCacheFactoryBean" p:name="default" /> <bean class="org.springframework.data.redis.cache.RedisCache"> <constructor-arg index="0" name="name" value="accountCache"/> <constructor-arg index="1"><null/></constructor-arg> <constructor-arg index="2" name="redisOperations" ref="redisTemplate"/> <constructor-arg index="3" name="expiration" value="100"/> </bean> </set> </property> </bean> <!--使用自定义key generator--> <bean id="workingKeyGenerator" class="com.cms.tzyy.common.cache.WorkingKeyGenerator" /> <!--默认使用的key generator--> <!--<bean id="workingKeyGenerator" class="org.springframework.cache.interceptor.SimpleKeyGenerator" />--> </beans>
注意这个 spring 配置文件有一个关键的支持缓存的配置项:<cache:annotation-driven />,
这个配置项缺省使用了一个名字叫 cacheManager 的缓存管理器,这个缓存管理器有一个 spring 的缺省实现,即 org.springframework.cache.support.SimpleCacheManager,这个缓存管理器实现了我们刚刚自定义的缓存管理器的逻辑,它需要配置一个属性 caches,即此缓存管理器管理的缓存集合,除了缺省的名字叫 default 的缓存(使用了缺省的内存存储方案 ConcurrentMapCacheFactoryBean,它是基于 java.util.concurrent.ConcurrentHashMap 的一个内存缓存实现方案),我们还自定义了一个名字叫 accountCache 的缓存。
OK,现在我们具备了测试条件,测试代码如下:
Main.java
package cacheOfAnno; import org.springframework.context.ApplicationContext; import org.springframework.context.support.ClassPathXmlApplicationContext; public class Main { public static void main(String[] args) { ApplicationContext context = new ClassPathXmlApplicationContext( "spring-cache-anno.xml");// 加载 spring 配置文件 AccountService s = (AccountService) context.getBean("accountServiceBean"); // 第一次查询,应该走数据库 System.out.print("first query..."); s.getAccountByName("somebody"); // 第二次查询,应该不查数据库,直接返回缓存的值 System.out.print("second query..."); s.getAccountByName("somebody"); System.out.println(); } }
上面的测试代码主要进行了两次查询,第一次应该会查询数据库,第二次应该返回缓存,不再查数据库,我们执行一下,看看结果。
执行结果
first query...real query account.somebody// 第一次查询
real querying db...somebody// 对数据库进行了查询
second query...// 第二次查询,没有打印数据库查询日志,直接返回了缓存中的结果
可以看出我们设置的基于注释的缓存起作用了,而在 AccountService.java 的代码中,我们没有看到任何的缓存逻辑代码,只有一行注释:@Cacheable(value="accountCache"),就实现了基本的缓存方案。
Cache注解
启用Cache注解
XML风格的:
<cache:annotation-driven cache-manager="cacheManager" proxy-target-class="true"/>
另外还可以指定一个 key-generator,即默认的key生成策略,后边讨论;
注解风格的:
@Configuration @ComponentScan(basePackages = "com.sishuok.spring.service") @EnableCaching(proxyTargetClass = true) public class AppConfig implements CachingConfigurer { @Bean @Override public CacheManager cacheManager() { try { net.sf.ehcache.CacheManager ehcacheCacheManager = new net.sf.ehcache.CacheManager(new ClassPathResource("ehcache.xml").getInputStream()); EhCacheCacheManager cacheCacheManager = new EhCacheCacheManager(ehcacheCacheManager); return cacheCacheManager; } catch (IOException e) { throw new RuntimeException(e); } } @Bean @Override public KeyGenerator keyGenerator() { return new SimpleKeyGenerator(); } }
1、使用@EnableCaching启用Cache注解支持;
2、实现CachingConfigurer,然后注入需要的cacheManager和keyGenerator;从spring4开始默认的keyGenerator是SimpleKeyGenerator;
@CachePut
应用到写数据的方法上,如新增/修改方法,调用方法时会自动把相应的数据放入缓存:
@CachePut(value = "user", key = "#user.id") public User save(User user) { users.add(user); return user; }
即调用该方法时,会把user.id作为key,返回值作为value放入缓存;
@CachePut注解:
public @interface CachePut { String[] value(); //缓存的名字,可以把数据写到多个缓存 String key() default ""; //缓存key,如果不指定将使用默认的KeyGenerator生成,后边介绍 String condition() default ""; //满足缓存条件的数据才会放入缓存,condition在调用方法之前和之后都会判断 String unless() default ""; //用于否决缓存更新的,不像condition,该表达只在方法执行之后判断,此时可以拿到返回值result进行判断了 }
@CacheEvict
即应用到移除数据的方法上,如删除方法,调用方法时会从缓存中移除相应的数据:
@CacheEvict(value = "user", key = "#user.id") //移除指定key的数据 public User delete(User user) { users.remove(user); return user; } @CacheEvict(value = "user", allEntries = true) //移除所有数据 public void deleteAll() { users.clear(); }
@CacheEvict注解:
public @interface CacheEvict { String[] value(); //请参考@CachePut String key() default ""; //请参考@CachePut String condition() default ""; //请参考@CachePut boolean allEntries() default false; //是否移除所有数据 boolean beforeInvocation() default false;//是调用方法之前移除/还是调用之后移除
@Cacheable
应用到读取数据的方法上,即可缓存的方法,如查找方法:先从缓存中读取,如果没有再调用方法获取数据,然后把数据添加到缓存中:
@Cacheable注解:
运行流程
- 首先执行@CacheEvict(如果beforeInvocation=true且condition 通过),如果allEntries=true,则清空所有
- 接着收集@Cacheable(如果condition 通过,且key对应的数据不在缓存),放入cachePutRequests(也就是说如果cachePutRequests为空,则数据在缓存中)
- 如果cachePutRequests为空且没有@CachePut操作,那么将查找@Cacheable的缓存,否则result=缓存数据(也就是说只要当没有cache put请求时才会查找缓存)
- 如果没有找到缓存,那么调用实际的API,把结果放入result
- 如果有@CachePut操作(如果condition 通过),那么放入cachePutRequests
- 执行cachePutRequests,将数据写入缓存(unless为空或者unless解析结果为false);
- 执行@CacheEvict(如果beforeInvocation=false 且 condition 通过),如果allEntries=true,则清空所有
流程中需要注意的就是2/3/4步:
如果有@CachePut操作,即使有@Cacheable也不会从缓存中读取;问题很明显,如果要混合多个注解使用,不能组合使用@CachePut和@Cacheable;官方说应该避免这样使用(解释是如果带条件的注解相互排除的场景);不过个人感觉还是不要考虑这个好,让用户来决定如何使用,否则一会介绍的场景不能满足。
提供的SpEL上下文数据
Spring Cache提供了一些供我们使用的SpEL上下文数据,下表直接摘自Spring官方文档:
名字 | 位置 | 描述 | 示例 |
methodName |
root对象 |
当前被调用的方法名 |
|
method |
root对象 |
当前被调用的方法 |
|
target |
root对象 |
当前被调用的目标对象 |
|
targetClass |
root对象 |
当前被调用的目标对象类 |
|
args |
root对象 |
当前被调用的方法的参数列表 |
|
caches |
root对象 |
当前方法调用使用的缓存列表(如@Cacheable(value={"cache1", "cache2"})),则有两个cache |
|
argument name |
执行上下文 |
当前被调用的方法的参数,如findById(Long id),我们可以通过#id拿到参数 |
#user.id |
result |
执行上下文 |
方法执行后的返回值(仅当方法执行之后的判断有效,如‘unless’,'cache evict'的beforeInvocation=false) |
|
通过这些数据我们可能实现比较复杂的缓存逻辑了,后边再来介绍。
Key生成器
如果在Cache注解上没有指定key的话@CachePut(value = "user"),会使用KeyGenerator进行生成一个key:
默认提供了DefaultKeyGenerator生成器(Spring 4之后使用SimpleKeyGenerator):
即如果只有一个参数,就使用参数作为key,否则使用SimpleKey作为key。
我们也可以自定义自己的key生成器(参考:https://marschall.github.io/2017/10/01/better-spring-cache-key-generator.html),然后通过xml风格的<cache:annotation-driven key-generator=""/>或注解风格的CachingConfigurer中指定keyGenerator。
条件缓存
根据运行流程,如下@Cacheable将在执行方法之前( #result还拿不到返回值)判断condition,如果返回true,则查缓存;
根据运行流程,如下@CachePut将在执行完方法后(#result就能拿到返回值了)判断condition,如果返回true,则放入缓存;
根据运行流程,如下@CachePut将在执行完方法后(#result就能拿到返回值了)判断unless,如果返回false,则放入缓存;(即跟condition相反)
@Caching
有时候我们可能组合多个Cache注解使用;比如用户新增成功后,我们要添加id-->user;username--->user;email--->user的缓存;此时就需要@Caching组合多个注解标签了。
如用户新增成功后,添加id-->user;username--->user;email--->user到缓存;
@Caching定义如下:
自定义缓存注解
比如之前的那个@Caching组合,会让方法上的注解显得整个代码比较乱,此时可以使用自定义注解把这些注解组合到一个注解中,如:
这样我们在方法上使用如下代码即可,整个代码显得比较干净。
示例
新增/修改数据时往缓存中写
@Caching( cacheable = { @Cacheable(value = "user", key = "#email") } ) public User findByEmail(final String email)
基本原理
和 spring 的事务管理类似,spring cache 的关键原理就是 spring AOP,通过 spring AOP,其实现了在方法调用前、调用后获取方法的入参和返回值,进而实现了缓存的逻辑。我们来看一下下面这个图:
原始方法调用图
上图显示,当客户端“Calling code”调用一个普通类 Plain Object 的 foo() 方法的时候,是直接作用在 pojo 类自身对象上的,客户端拥有的是被调用者的直接的引用。
而 Spring cache 利用了 Spring AOP 的动态代理技术,即当客户端尝试调用 pojo 的 foo()方法的时候,给他的不是 pojo 自身的引用,而是一个动态生成的代理类
动态代理调用图
如上图所示,这个时候,实际客户端拥有的是一个代理的引用,那么在调用 foo() 方法的时候,会首先调用 proxy 的 foo() 方法,这个时候 proxy 可以整体控制实际的 pojo.foo() 方法的入参和返回值,比如缓存结果,比如直接略过执行实际的 foo() 方法等,都是可以轻松做到的。
注意和限制
CacheManager 必须设置缓存过期时间,否则缓存对象将永不过期,这样做的原因是避免一些野数据“永久保存”。此外,设置缓存过期时间也有助于资源利用最大化,因为缓存里保留的永远是热点数据。
缓存适用于读多写少的场合,查询时缓存命中率很低、写操作很频繁等场景不适宜用缓存。
基于 proxy 的 spring aop 带来的内部调用问题
上面介绍过 spring cache 的原理,即它是基于动态生成的 proxy 代理机制来对方法的调用进行切面,这里关键点是对象的引用问题,如果对象的方法是内部调用(即 this 引用)而不是外部引用,则会导致 proxy 失效,那么我们的切面就失效,也就是说上面定义的各种注释包括 @Cacheable、@CachePut 和 @CacheEvict 都会失效,我们来演示一下。
清单 28. AccountService.java
public Account getAccountByName2(String userName) { return this.getAccountByName(userName); } @Cacheable(value="accountCache")// 使用了一个缓存名叫 accountCache public Account getAccountByName(String userName) { // 方法内部实现不考虑缓存逻辑,直接实现业务 return getFromDB(userName);
上面我们定义了一个新的方法 getAccountByName2,其自身调用了 getAccountByName 方法,这个时候,发生的是内部调用(this),所以没有走 proxy,导致 spring cache 失效
清单 29. Main.java
public static void main(String[] args) { ApplicationContext context = new ClassPathXmlApplicationContext( "spring-cache-anno.xml");// 加载 spring 配置文件 AccountService s = (AccountService) context.getBean("accountServiceBean"); s.getAccountByName2("someone"); s.getAccountByName2("someone"); s.getAccountByName2("someone"); }
清单 30. 运行结果
real querying db...someone
real querying db...someone
real querying db...someone
可见,结果是每次都查询数据库,缓存没起作用。要避免这个问题,就是要避免对缓存方法的内部调用,或者避免使用基于 proxy 的 AOP 模式,可以使用基于 aspectJ 的 AOP 模式来解决这个问题。
@CacheEvict 的可靠性问题
我们看到,@CacheEvict 注释有一个属性 beforeInvocation,缺省为 false,即缺省情况下,都是在实际的方法执行完成后,才对缓存进行清空操作。期间如果执行方法出现异常,则会导致缓存不会被清空。我们演示一下
清单 31. AccountService.java
@CacheEvict(value="accountCache",allEntries=true)// 清空 accountCache 缓存 public void reload() { throw new RuntimeException(); }
注意上面的代码,我们在 reload 的时候抛出了运行期异常,这会导致清空缓存失败。
清单 32. Main.java
public static void main(String[] args) { ApplicationContext context = new ClassPathXmlApplicationContext( "spring-cache-anno.xml");// 加载 spring 配置文件 AccountService s = (AccountService) context.getBean("accountServiceBean"); s.getAccountByName("someone"); s.getAccountByName("someone"); try { s.reload(); } catch (Exception e) { } s.getAccountByName("someone"); }
上面的测试代码先查询了两次,然后 reload,然后再查询一次,结果应该是只有第一次查询走了数据库,其他两次查询都从缓存,第三次也走缓存因为 reload 失败了。
清单 33. 运行结果
real querying db...someone
和预期一样。那么我们如何避免这个问题呢?我们可以用 @CacheEvict 注释提供的 beforeInvocation 属性,将其设置为 true,这样,在方法执行前我们的缓存就被清空了。可以确保缓存被清空。
清单 34. AccountService.java
@CacheEvict(value="accountCache",allEntries=true,beforeInvocation=true) // 清空 accountCache 缓存 public void reload() { throw new RuntimeException(); }
注意上面的代码,我们在 @CacheEvict 注释中加了 beforeInvocation 属性,确保缓存被清空。
执行相同的测试代码
清单 35. 运行结果
real querying db...someone
real querying db...someone
这样,第一次和第三次都从数据库取数据了,缓存清空有效。
非 public 方法问题
和内部调用问题类似,非 public 方法如果想实现基于注释的缓存,必须采用基于 AspectJ 的 AOP 机制。
问题及解决方案
一、比如findByUsername时,不应该只放username-->user,应该连同id--->user和email--->user一起放入;这样下次如果按照id查找直接从缓存中就命中了;这需要根据之前的运行流程改造CacheAspectSupport:
然后就可以通过如下代码完成想要的功能:
二、缓存注解会让代码看上去比较乱;应该使用自定义注解把缓存注解提取出去;
三、往缓存放数据/移除数据是有条件的,而且条件可能很复杂,考虑使用SpEL表达式:
四、其实对于:id--->user;username---->user;email--->user;更好的方式可能是:id--->user;username--->id;email--->id;保证user只存一份;如:
五、使用Spring3.1注解 缓存 模糊匹配Evict的问题
缓存都是key-value风格的,模糊匹配本来就不应该是Cache要做的;而是通过自己的缓存代码实现;
六、spring cache的缺陷:例如有一个缓存存放 list<User>,现在你执行了一个 update(user)的方法,你一定不希望清除整个缓存而想替换掉update的元素
这个在现有的抽象上没有很好的方案,可以考虑通过condition在之前的Helper方法中解决;当然不是很优雅。
也就是说Spring Cache注解还不是很完美,我认为可以这样设计:
@Cacheable(cacheName = "缓存名称",key="缓存key/SpEL", value="缓存值/SpEL/不填默认返回值", beforeCondition="方法执行之前的条件/SpEL", afterCondition="方法执行后的条件/SpEL", afterCache="缓存之后执行的逻辑/SpEL")
value也是一个SpEL,这样可以定制要缓存的数据;afterCache定制自己的缓存成功后的其他逻辑。
当然Spring Cache注解对于大多数场景够用了,如果场景复杂还是考虑使用AOP吧;如果自己实现请考虑使用Spring Cache API进行缓存抽象。
参考资料
http://jinnianshilongnian.iteye.com/blog/2001040
https://www.ibm.com/developerworks/cn/opensource/os-cn-spring-cache/
http://blog.csdn.net/defonds/article/details/48716161
https://marschall.github.io/2017/10/01/better-spring-cache-key-generator.html