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
