Java緩存管理方案 AutoLoadCache
AutoLoadCache 是使用 Spring AOP 、 Annotation以及Spring EL表達式 來進行管理緩存的解決方案,同時基于AOP實現自動加載機制來達到數據“常駐內存”的目的。
現在使用的緩存技術很多,比如Redis、 Memcache 、 EhCache等,甚至還有使用ConcurrentHashMap 或 HashTable 來實現緩存。但在緩存的使用上,每個人都有自己的實現方式,大部分是直接與業務代碼綁定,隨著業務的變化,要更換緩存方案時,非常麻煩。接下來我們就使用AOP + Annotation 來解決這個問題,同時使用自動加載機制 來實現數據“常駐內存”。
Spring AOP這幾年非常熱門,使用也越來越多,但個人建議AOP只用于處理一些輔助的功能(比如:接下來我們要說的緩存),而不能把業務邏輯使用AOP中實現,尤其是在需要“事務”的環境中。
如下圖所示:
AOP攔截到請求后:
根據請求參數生成Key,后面我們會對生成Key的規則,進一步說明;
如果是AutoLoad的,則請求相關參數,封裝到AutoLoadTO中,并放到AutoLoadHandler中。
根據Key去緩存服務器中取數據,如果取到數據,則返回數據,如果沒有取到數據,則執行DAO中的方法,獲取數據,同時將數據放到緩存中。如果是 AutoLoad的,則把最后加載時間,更新到AutoLoadTO中,最后返回數據;如是AutoLoad的請求,每次請求時,都會更新 AutoLoadTO中的 最后請求時間。
為了減少并發,增加等待機制:如果多個用戶同時取一個數據,那么先讓第一個用戶去DAO取數據,其它用戶則等待其返回后,去緩存中獲取,嘗試一定次數后,如果還沒獲取到,再去DAO中取數據。
AutoLoadHandler(自動加載處理器)主要做的事情:當緩存即將過期時,去執行DAO的方法,獲取數據,并將數據放到緩存中。為了防止 自動加載隊列過大,設置了容量限制;同時會將超過一定時間沒有用戶請求的也會從自動加載隊列中移除,把服務器資源釋放出來,給真正需要的請求。
使用自加載的目的:
避免在請求高峰時,因為緩存失效,而造成數據庫壓力無法承受;
把一些耗時業務得以實現。
把一些使用非常頻繁的數據,使用自動加載,因為這樣的數據緩存失效時,最容易造成服務器的壓力過大。
分布式自動加載
如果將應用部署在多臺服務器上,理論上可以認為自動加載隊列是由這幾臺服務器共同完成自動加載任務。比如應用部署在A,B兩臺服務器上,A服務器自 動加載了數據D,(因為兩臺服務器的自動加載隊列是獨立的,所以加載的順序也是一樣的),接著有用戶從B服務器請求數據D,這時會把數據D的最后加載時間 更新給B服務器,這樣B服務器就不會重復加載數據D。
使用方法
1. Maven
<dependency> <groupId>com.github.qiujiayu</groupId> <artifactId>autoload-cache</artifactId> <version>2.2</version> </dependency>
2. Spring AOP配置
從0.4版本開始增加了Redis及Memcache的PointCut 的實現,直接在Spring 中用aop:config就可以使用。
Redis 配置:
<!-- Jedis 連接池配置 --> <bean id="jedisPoolConfig" class="redis.clients.jedis.JedisPoolConfig"> <property name="maxTotal" value="2000" /> <property name="maxIdle" value="100" /> <property name="minIdle" value="50" /> <property name="maxWaitMillis" value="2000" /> <property name="testOnBorrow" value="false" /> <property name="testOnReturn" value="false" /> <property name="testWhileIdle" value="false" /> </bean> <bean id="shardedJedisPool" class="redis.clients.jedis.ShardedJedisPool"> <constructor-arg ref="jedisPoolConfig" /> <constructor-arg> <list> <bean class="redis.clients.jedis.JedisShardInfo"> <constructor-arg value="${redis1.host}" /> <constructor-arg type="int" value="${redis1.port}" /> <constructor-arg value="instance:01" /> </bean> <bean class="redis.clients.jedis.JedisShardInfo"> <constructor-arg value="${redis2.host}" /> <constructor-arg type="int" value="${redis2.port}" /> <constructor-arg value="instance:02" /> </bean> <bean class="redis.clients.jedis.JedisShardInfo"> <constructor-arg value="${redis3.host}" /> <constructor-arg type="int" value="${redis3.port}" /> <constructor-arg value="instance:03" /> </bean> </list> </constructor-arg> </bean> <bean id="autoLoadConfig" class="com.jarvis.cache.to.AutoLoadConfig"> <property name="threadCnt" value="10" /> <property name="maxElement" value="20000" /> <property name="printSlowLog" value="true" /> <property name="slowLoadTime" value="500" /> <property name="sortType" value="1" /> <property name="checkFromCacheBeforeLoad" value="true" /> </bean> <bean id="hessianSerializer" class="com.jarvis.cache.serializer.HessianSerializer" /> <bean id="cachePointCut" class="com.jarvis.cache.redis.ShardedCachePointCut" destroy-method="destroy"> <constructor-arg ref="autoLoadConfig" /> <property name="serializer" ref="hessianSerializer" /> <property name="shardedJedisPool" ref="shardedJedisPool" /> <property name="namespace" value="test_hessian" /> </bean>
Memcache 配置:
<bean id="memcachedClient" class="net.spy.memcached.spring.MemcachedClientFactoryBean"> <property name="servers" value="192.138.11.165:11211,192.138.11.166:11211" /> <property name="protocol" value="BINARY" /> <property name="transcoder"> <bean class="net.spy.memcached.transcoders.SerializingTranscoder"> <property name="compressionThreshold" value="1024" /> </bean> </property> <property name="opTimeout" value="2000" /> <property name="timeoutExceptionThreshold" value="1998" /> <property name="hashAlg"> <value type="net.spy.memcached.DefaultHashAlgorithm">KETAMA_HASH</value> </property> <property name="locatorType" value="CONSISTENT" /> <property name="failureMode" value="Redistribute" /> <property name="useNagleAlgorithm" value="false" /> </bean> <bean id="hessianSerializer" class="com.jarvis.cache.serializer.HessianSerializer" /> <bean id="cachePointCut" class="com.jarvis.cache.memcache.CachePointCut" destroy-method="destroy"> <constructor-arg ref="autoLoadConfig" /> <property name="serializer" ref="hessianSerializer" /> <property name="memcachedClient", ref="memcachedClient" /> <property name="namespace" value="test" /> </bean>
AOP 配置:
<aop:config> <aop:aspect ref="cachePointCut"> <aop:pointcut id="daoCachePointcut" expression="execution(public !void com.jarvis.cache_example.common.dao..*.*(..)) && @annotation(cache)" /> <aop:around pointcut-ref="daoCachePointcut" method="proceed" /> </aop:aspect> <aop:aspect ref="cachePointCut" order="1000"><!-- order 參數控制 aop通知的優先級,值越小,優先級越高 ,在事務提交后刪除緩存 --> <aop:pointcut id="deleteCachePointcut" expression="execution(* com.jarvis.cache_example.common.dao..*.*(..)) && @annotation(cacheDelete)" /> <aop:after-returning pointcut-ref="deleteCachePointcut" method="deleteCache" returning="retVal"/> </aop:aspect> </aop:config>
通過Spring配置,能更好地支持,不同的數據使用不同的緩存服務器的情況。
3. 將需要使用緩存操作的方法前增加 @Cache和 @CacheDelete注解(Redis為例子)
package com.jarvis.example.dao; import ... ... public class UserDAO { /** * 添加用戶的同時,把數據放到緩存中 * @param userName * @return */ @Cache(expire=600, key="'user'+#retVal.id", opType=CacheOpType.WRITE) public UserTO addUser(String userName) { UserTO user=new UserTO(); user.setName(userName); Random rand=new Random(); // 數據庫返回ID Integer id=rand.nextInt(100000); user.setId(id); System.out.println("add User:" + id); return user; } /** * * @param id * @return */ @Cache(expire=600, autoload=true, key="'user'+#args[0]", condition="#args[0]>0") public UserTO getUserById(Integer id) { UserTO user=new UserTO(); user.setId(id); user.setName("name" + id); System.out.println("getUserById from dao"); return user; } /** * * @param user */ @CacheDelete({@CacheDeleteKey(value="'user'+#args[0].id", keyType=CacheKeyType.DEFINED)}) public void updateUserName(UserTO user) { System.out.println("update user name:" + user.getName()); // save to db } // 注意:因為沒有用 SpEL表達式,所以不需要用單引號 @CacheDelete({@CacheDeleteKey(value="user*", keyType=CacheKeyType.DEFINED)}) public void clearUserCache() { System.out.println("clearUserCache"); } // ------------------------以下是使用默認生成Key的方法-------------------- @Cache(expire=600, autoload=true, condition="#args[0]>0") public UserTO getUserById2(Integer id) { UserTO user=new UserTO(); user.setId(id); user.setName("name" + id); System.out.println("getUserById from dao"); return user; } @CacheDelete({@CacheDeleteKey(cls=UserDAO.class, method="getUserById2", argsEl={"#args[0].id"}, keyType=CacheKeyType.DEFAULT)}) public void updateUserName2(UserTO user) { System.out.println("update user name:" + user.getName()); // save to db } @CacheDelete({@CacheDeleteKey(deleteByPrefixKey=true, cls=UserDAO.class, method="getUserById2", keyType=CacheKeyType.DEFAULT)}) public void clearUserCache2() { System.out.println("clearUserCache"); // save to db } }
緩存Key的生成
-
使用Spring EL 表達式自定義緩存Key:CacheUtil.getDefinedCacheKey(String keySpEL, Object[] arguments),我們稱之為自定義緩存Key:
例如:
@Cache(expire=600, key="'goods.getGoodsById'+#args[0]") public GoodsTO getGoodsById(Long id){...}
注意:Spring EL表達式支持調整類的static 變量和方法,比如:"T(java.lang.Math).PI"。 所以對于復雜的參數,我們可以在Spring EL 表達式中使用:"T(com.jarvis.cache.CacheUtil).objectToHashStr(#args)",會生成一個比較短的 Hash字符串。
為了使用方便,在Spring EL表達式,"$hash(...)"會被替換為:"T(com.jarvis.cache.CacheUtil).getUniqueHashStr(...)",例如:
@Cache(expire=720, key="'GOODS.getGoods:'+$hash(#args)") public List<GoodsTO> getGoods(GoodsCriteriaTO goodsCriteria){...}
生成的緩存Key為"GOODS.getGoods:xxx",xxx為args,的轉在的字符串。
在拼緩存Key時,各項數據最好都用特殊字符進行分隔,否則緩存的Key有可能會亂的。比如:a,b 兩個變量a=1,b=11,如果a=11,b=1,兩個變量中間不加特殊字符,拼在一塊,值是一樣的。
-
默認生成緩存Key的方法:CacheUtil.getDefaultCacheKey(String className, String method, Object[] arguments, String subKeySpEL)
-
className 類名稱
-
method 方法名稱
-
arguments 參數
-
subKeySpEL SpringEL表達式
生成的Key格式為:{類名稱}.{方法名稱}{.SpringEL表達式運算結果}:{參數值的Hash字符串}。
當@Cache中不設置key值時,使用默認方式生成緩存Key。
根據自己的情況選擇不同的緩存Key生成策略,用自定義Key使用比較靈活,但維護成本會高些,而且不能出現筆誤。
subKeySpEL 使用說明
根據業務的需要,將緩存Key進行分組。舉個例子,商品的評論列表:
package com.jarvis.example.dao; import ... ... public class GoodsCommentDAO{ @Cache(expire=600, subKeySpEL="#args[0]", autoload=true, requestTimeout=18000) public List<CommentTO> getCommentListByGoodsId(Long goodsId, int pageNo, int pageSize) { ... ... } }
如果商品Id為:100,那么生成緩存Key格式為:com.jarvis.example.dao.GoodsCommentDAO.getCommentListByGoodsId.100:xxxx 在Redis中,能精確刪除商品Id為100的評論列表,執行命令即可: del com.jarvis.example.dao.GoodsCommentDAO.getCommentListByGoodsId.100:*
SpringEL表達式使用起來確實非常方便,如果需要,@Cache中的expire,requestTimeout以及autoload參數都可以用SpringEL表達式來動態設置,但使用起來就變得復雜,所以我們沒有這樣做。
數據實時性
上面商品評論的例子中,如果用戶發表了評論,要立即顯示該如何來處理?
比較簡單的方法就是,在發表評論成功后,立即把緩存中的數據也清除,這樣就可以了。
package com.jarvis.example.dao; import ... ... public class GoodsCommentDAO{ @Cache(expire=600, subKeySpEL="#args[0]", autoload=true, requestTimeout=18000) public List<CommentTO> getCommentListByGoodsId(Long goodsId, int pageNo, int pageSize) { ... ... } @CacheDelete({@CacheDeleteKey(cls=GoodsCommentDAO.class, method="getCommentListByGoodsId", deleteByPrefixKey=true, subKeySpEL=subKeySpEL="#args[0].goodsId" , keyType=CacheKeyType.DEFAULT)}) public void addComment(Comment comment) { ... ...// 省略添加評論代碼 } } }
使用自定義緩存Key的方案:
package com.jarvis.example.dao; import ... ... public class GoodsCommentDAO{ @Cache(expire=600, key="'goods_comment_'+#args[0]+'.list__'+#args[1]+'_'+#args[2]", autoload=true, requestTimeout=18000) public List<CommentTO> getCommentListByGoodsId(Long goodsId, int pageNo, int pageSize) { ... ... } @CacheDelete({@CacheDeleteKey(value="'goods_comment_'+#args[0].goodsId+'*'", keyType=CacheKeyType.DEFINED)}) // 刪除當前所屬商品的所有評論,不刪除其它商品評論 public void addComment(Comment comment) { ... ...// 省略添加評論代碼 } }
刪除緩存AOP 配置:
<aop:aspect ref="cachePointCut" order="1000"> <aop:pointcut id="deleteCachePointcut" expression="execution(* com.jarvis.cache_example.common.dao..*.*(..)) && @annotation(cacheDelete)" /> <aop:after-returning pointcut-ref="deleteCachePointcut" method="deleteCache" returning="retVal"/> </aop:aspect>
@Cache
@Retention(RetentionPolicy.RUNTIME) @Target(ElementType.METHOD) public @interface Cache { /** * 緩存的過期時間,單位:秒 */ int expire(); /** * 自定義緩存Key,如果不設置使用系統默認生成緩存Key的方法 * @return */ String key() default ""; /** * 是否啟用自動加載緩存 * @return */ boolean autoload() default false; /** * 自動緩存的條件,可以為空,使用 SpEL 編寫,返回 true 或者 false,優化級高級autoload,例如:null != #args[0].keyword,當第一個參數的keyword屬性為null時設置為自動加載。 * @return */ String autoloadCondition() default ""; /** * 當autoload為true時,緩存數據在 requestTimeout 秒之內沒有使用了,就不進行自動加載數據,如果requestTimeout為0時,會一直自動加載 * @return */ long requestTimeout() default 36000L; /** * 使用SpEL,將緩存key,根據業務需要進行二次分組 * @return */ String subKeySpEL() default ""; /** * 緩存的條件,可以為空,使用 SpEL 編寫,返回 true 或者 false,只有為 true 才進行緩存,例如:"#args[0]==1",當第一個參數值為1時,才進緩存。 * @return */ String condition() default ""; /** * 緩存的操作類型:默認是READ_WRITE,先緩存取數據,如果沒有數據則從DAO中獲取并寫入緩存;如果是WRITE則從DAO取完數據后,寫入緩存 * @return CacheOpType */ CacheOpType opType() default CacheOpType.READ_WRITE; }
AutoLoadConfig 配置說明
-
threadCnt 處理自動加載隊列的線程數量,默認值為:10;
-
maxElement 自動加載隊列中允許存放的最大容量, 默認值為:20000
-
printSlowLog 是否打印比較耗時的請求,默認值為:true
-
slowLoadTime 當請求耗時超過此值時,記錄目錄(printSlowLog=true 時才有效),單位:毫秒,默認值:500;
-
sortType 自動加載隊列排序算法, 0:按在Map中存儲的順序(即無序);1 :越接近過期時間,越耗時的排在最前;2:根據請求次數,倒序排序,請求次數越多,說明使用頻率越高,造成并發的可能越大。更詳細的說明,請查看代碼com.jarvis.cache.type.AutoLoadQueueSortType
-
checkFromCacheBeforeLoad 加載數據之前去緩存服務器中檢查,數據是否快過期,如果應用程序部署的服務器數量比較少,設置為false, 如果部署的服務器比較多,可以考慮設置為true