高效的緩存管理解決方案-AutoLoadCache
AutoLoadCache 是一個高效的緩存管理解決方案,而且實現了自動加載(或叫預加載)和“拿來主義”機制,能非常巧妙地解決系統的性能及并發問題。
現在使用的緩存技術很多,比如Redis、 Memcache 、 EhCache等,甚至還有使用ConcurrentHashMap 或 HashTable 來實現緩存。但在緩存的使用上,每個人都有自己的實現方式,大部分是直接與業務代碼綁定,隨著業務的變化,要更換緩存方案時,非常麻煩。接下來我們就使用AOP + Annotation 來解決這個問題,同時使用自動加載機制來實現數據“常駐內存”,并通過“拿來主義”機制來減輕因并發給系統帶來的壓力。
框架設計,如下圖所示:
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>${version}</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" />
<property name="autoLoadPeriod" value="50" />
</bean>
<!-- 可以通過implements com.jarvis.cache.serializer.ISerializer<Object> 實現 Kryo 和 FST Serializer 工具,框架的核對不在這里,所以不提供過多的實現 -->
<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>
如果需要使用本地內存來緩存數據,可以使用: com.jarvis.cache.map.CachePointCut
AOP 配置:
<aop:config proxy-target-class="true">
<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配置,能更好地支持,不同的數據使用不同的緩存服務器的情況。
注意 如果需要在MyBatis Mapper中使用,則需要使用com.jarvis.cache.mybatis.CachePointCutProxy 來處理。
3. 將需要使用緩存操作的方法前增加 @Cache和 @CacheDelete注解(Redis為例子)
AutoLoadConfig 配置說明
- threadCnt 處理自動加載隊列的線程數量,默認值為:10;
- maxElement 自動加載隊列中允許存放的最大容量, 默認值為:20000
- printSlowLog 是否打印比較耗時的請求,默認值為:true
- slowLoadTime 當請求耗時超過此值時,記錄目錄(printSlowLog=true 時才有效),單位:毫秒,默認值:500;
- sortType 自動加載隊列排序算法, 0:按在Map中存儲的順序(即無序);1 :越接近過期時間,越耗時的排在最前;2:根據請求次數,倒序排序,請求次數越多,說明使用頻率越高,造成并發的可能越大。更詳細的說明,請查看代碼com.jarvis.cache.type.AutoLoadQueueSortType
- checkFromCacheBeforeLoad 加載數據之前去緩存服務器中檢查,數據是否快過期,如果應用程序部署的服務器數量比較少,設置為false, 如果部署的服務器比較多,可以考慮設置為true
- autoLoadPeriod 單個線程中執行自動加載的時間間隔, 此值越小,遍歷自動加載隊列頻率起高,對CPU會越消耗CPU
- functions 注冊自定義SpEL函數
@Cache
public @interface Cache {
/**
* 緩存的過期時間,單位:秒,如果為0則表示永久緩存
* @return 時間
*/
int expire();
/**
* 自定義緩存Key,支持Spring EL表達式
* @return String 自定義緩存Key
*/
String key() default "";
/**
* 設置哈希表中的字段,如果設置此項,則用哈希表進行存儲,支持Spring EL表達式
* @return String
*/
String hfield() default "";
/**
* 是否啟用自動加載緩存, 緩存時間必須大于120秒時才有效
* @return boolean
*/
boolean autoload() default false;
/**
* 自動緩存的條件,可以為空,使用 SpEL 編寫,返回 true 或者 false,如果設置了此值,autoload() 就失效,例如:null != #args[0].keyword,當第一個參數的keyword屬性為null時設置為自動加載。
* @return String SpEL表達式
*/
String autoloadCondition() default "";
/**
* 當autoload為true時,緩存數據在 requestTimeout 秒之內沒有使用了,就不進行自動加載數據,如果requestTimeout為0時,會一直自動加載
* @return long 請求過期
*/
long requestTimeout() default 36000L;
/**
* 緩存的條件,可以為空,使用 SpEL 編寫,返回 true 或者 false,只有為 true 才進行緩存
* @return String
*/
String condition() default "";
/**
* 緩存的操作類型:默認是READ_WRITE,先緩存取數據,如果沒有數據則從DAO中獲取并寫入緩存;如果是WRITE則從DAO取完數據后,寫入緩存
* @return CacheOpType
*/
CacheOpType opType() default CacheOpType.READ_WRITE;
/**
* 并發等待時間(毫秒),等待正在DAO中加載數據的線程返回的等待時間。
* @return 時間
*/
int waitTimeOut() default 500;
/**
* 擴展緩存
* @return
*/
ExCache[] exCache() default @ExCache(expire=-1, key="");
}
@ExCache
public @interface ExCache {
/**
* 緩存的過期時間,單位:秒,如果為0則表示永久緩存
* @return 時間
*/
int expire();
/**
* 自定義緩存Key,支持Spring EL表達式
* @return String 自定義緩存Key
*/
String key();
/**
* 設置哈希表中的字段,如果設置此項,則用哈希表進行存儲,支持Spring EL表達式
* @return String
*/
String hfield() default "";
/**
* 緩存的條件,可以為空,使用 SpEL 編寫,返回 true 或者 false,只有為 true 才進行緩存
* @return String
*/
String condition() default "";
/**
* 通過SpringEL表達式獲取需要緩存的數據,如果沒有設置,則默認使用 #retVal
* @return
*/
String cacheObject() default "";
}
@CacheDelete
public @interface CacheDelete {
CacheDeleteKey[] value();// 支持刪除多個緩存
}
@CacheDeleteKey
public @interface CacheDeleteKey {
/**
* 緩存的條件,可以為空,使用 SpEL 編寫,返回 true 或者 false,只有為 true 才進行緩存
* @return String
*/
String condition() default "";
/**
* 刪除緩存的Key,支持使用SpEL表達式, 當value有值時,是自定義緩存key。
* @return String
*/
String value();
/**
* 哈希表中的字段,支持使用SpEL表達式
* @return String
*/
String hfield() default "";
}
緩存Key的生成
在@Cache中設置key,可以是字符串或Spring EL表達式:
例如:
@Cache(expire=600, key="'goods.getGoodsById'+#args[0]")
public GoodsTO getGoodsById(Long id){...}
為了使用方便,調用hash 函數可以將任何Object轉為字符串,使用方法如下:
@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,兩個變量中間不加特殊字符,拼在一塊,值是一樣的。
Spring EL表達式支持調整類的static 變量和方法,比如:"T(java.lang.Math).PI"。
提供的SpEL上下文數據
名字 | 描述 | 示例 |
args | 當前被調用的方法的參數列表 | #args[0] |
retVal | 方法執行后的返回值(僅當方法執行之后才有效,如@Cache(opType=CacheOpType.WRITE),@ExCache() | #retVal |
提供的SpEL函數
名字 | 描述 | 示例 |
hash | 將Object 對象轉換為唯一的Hash字符串 | #hash(#args) |
empty | 判斷Object對象是否為空 | #empty(#args[0]) |
自定義SpEL函數
通過AutoLoadConfig 的functions 注冊自定義函數,例如:
<bean id="autoLoadConfig" class="com.jarvis.cache.to.AutoLoadConfig">
<property name="functions">
<map>
<entry key="isEmpty" value="com.jarvis.cache.CacheUtil" />
<!--#isEmpty(#args[0]) 表示調com.jarvis.cache.CacheUtil中的isEmpty方法-->
</map>
</property>
</bean>
數據實時性
下面商品評論的例子中,如果用戶發表了評論,要立即顯示該如何來處理?
package com.jarvis.example.dao;
import ... ...
public class GoodsCommentDAO{
@Cache(expire=600, key="'goods_comment_list_'+#args[0]", hfield = "#args[1]+'_'+#args[2]", autoload=true, requestTimeout=18000)
// goodsId=1, pageNo=2, pageSize=3 時相當于Redis命令:HSET goods_comment_list_1 2_3 List
public List<CommentTO> getCommentListByGoodsId(Long goodsId, int pageNo, int pageSize) {
... ...
}
@CacheDelete({@CacheDeleteKey(value="'goods_comment_list_'+#args[0].goodsId")}) // 刪除當前所屬商品的所有評論,不刪除其它商品評論
// #args[0].goodsId = 1時,相當于Redis命令: DEL goods_comment_list_1
public void addComment(Comment comment) {
... ...// 省略添加評論代碼
}
@CacheDelete({@CacheDeleteKey(value="'goods_comment_list_'+#args[0]", hfield = "#args[1]+'_'+#args[2]")})
// goodsId=1, pageNo=2, pageSize=3 時相當于Redis命令:DEL goods_comment_list_1 2_3
public void removeCache(Long goodsId, int pageNo, int pageSize) {
... ...// 使用空方法來刪除緩存
}
}
注意事項
1. 當@Cache中 autoload 設置為 ture 時,對應方法的參數必須都是Serializable的。
AutoLoadHandler中需要緩存通過深度復制后的參數。
2. 參數中只設置必要的屬性值,在DAO中用不到的屬性值盡量不要設置,這樣能避免生成不同的緩存Key,降低緩存的使用率。
例如:
public CollectionTO<AccountTO> getAccountByCriteria(AccountCriteriaTO criteria) {
List<AccountTO> list=null;
PaginationTO paging=criteria.getPaging();
if(null != paging && paging.getPageNo() > 0 && paging.getPageSize() > 0) {// 如果需要分頁查詢,先查詢總數
criteria.setPaging(null);// 減少緩存KEY的變化,在查詢記錄總數據時,不用設置分頁相關的屬性值
Integer recordCnt=accountDAO.getAccountCntByCriteria(criteria);
if(recordCnt > 0) {
criteria.setPaging(paging);
paging.setRecordCnt(recordCnt);
list=accountDAO.getAccountByCriteria(criteria);
}
return new CollectionTO<AccountTO>(list, recordCnt, criteria.getPaging().getPageSize());
} else {
list=accountDAO.getAccountByCriteria(criteria);
return new CollectionTO<AccountTO>(list, null != list ? list.size() : 0, 0);
}
}
3. 注意AOP失效的情況;
例如:
TempDAO {
public Object a() {
return b().get(0);
}
@Cache(expire=600)
public List<Object> b(){
return ... ...;
}
}
通過 new TempDAO().a() 調用b方法時,AOP失效,也無法進行緩存相關操作。
4. 自動加載緩存時,不能在緩存方法內疊加查詢參數值;
例如:
@Cache(expire=600, autoload=true, key="'myKey'+#hash(#args[0])")
public List<AccountTO> getDistinctAccountByPlayerGet(AccountCriteriaTO criteria) {
List<AccountTO> list;
int count=criteria.getPaging().getThreshold() ;
// 查預設查詢數量的10倍
criteria.getPaging().setThreshold(count * 10);
… …
}
因為自動加載時,AutoLoadHandler 緩存了查詢參數,執行自動加載時,每次執行時 threshold 都會乘以10,這樣threshold的值就會越來越大。
5. 對于一些比較耗時的方法盡量使用自動加載。
6. 對于查詢條件變化比較劇烈的,不要使用自動加載機制。
比如,根據用戶輸入的關鍵字進行搜索數據的方法,不建議使用自動加載。
7. 如果DAO方法中需要從ThreadLocal 獲取數據時,不能使用自動加載機制(@Cache的autoload值不能設置為true)。自動加載是用新的線程中模擬用戶請求的,這時ThreadLocal的數據都是空的。
在事務環境中,如何減少“臟讀”
不要從緩存中取數據,然后應用到修改數據的SQL語句中
在事務完成后,再刪除相關的緩存
在事務開始時,用一個ThreadLocal記錄一個HashSet,在更新數據方法執行完時,把要刪除緩存的相關參數封裝成在一個Bean中,放到這個HashSet中,在事務完成時,遍歷這個HashSet,然后刪除相關緩存。
大部分情況,只要做到第1點就可以了,因為保證數據庫中的數據準確才是最重要的。因為這種“臟讀”的情況只能減少出現的概率,不能完成解決。一般只有在非常高并發的情況才有可能發生。就像12306,在查詢時告訴你還有車票,但最后支付時不一定會有。
使用規范
將調接口或數據庫中取數據,封裝在DAO層,不能什么地方都有調接口的方法。
自動加載緩存時,不能在緩存方法內疊加(或減)查詢條件值,但允許設置值。
DAO層內部,沒使用@Cache的方法,不能調用加了@Cache的方法,避免AOP失效。
對于比較大的系統,要進行模塊化設計,這樣可以將自動加載,均分到各個模塊中。
為什么要使用自動加載機制?
首先我們想一下系統的瓶頸在哪里?
在高并發的情況下數據庫性能極差,即使查詢語句的性能很高;如果沒有自動加載機制的話,在當緩存過期時,訪問洪峰到來時,很容易就使數據庫壓力大增。
往緩存寫數據與從緩存讀數據相比,效率也差很多,因為寫緩存時需要分配內存等操作。使用自動加載,可以減少同時往緩存寫數據的情況,同時也能提升緩存服務器的吞吐量。
還有一些比較耗時的業務。
如何減少DAO層并發
- 使用緩存;
- 使用自動加載機制;“寫”數據往往比讀數據性能要差,使用自動加載也能減少寫并發。
- 從DAO層加載數據時,增加等待機制(拿來主義):如果有多個請求同時請求同一個數據,會先讓其中一個請求去取數據,其它的請求則等待它的數據,避免造成DAO層壓力過大。
可擴展性及維護性
- 通過AOP實現緩存與業務邏輯的解耦。
- 非常方便更換緩存服務器或緩存實現(比如:從Memcache換成Redis,或使用hashmap);
- 非常方便增減緩存服務器(如:增加Redis的節點數);
- 非常方便增加或去除緩存,方便測試期間排查問題;
- 通過Spring配置,能很簡單方便使用,也很容易修改維護;支持配置多種緩存實現;
- 可以通過繼承AbstractCacheManager,自己實現維護的操作方法,也可以增加除Memcache、Redis外的緩存技術支持。
緩存管理頁面
從1.0版本開始增加緩存管理頁面。
web.xml配置:
<servlet>
<servlet-name>cacheadmin</servlet-name>
<servlet-class>com.jarvis.cache.admin.servlet.AdminServlet</servlet-class>
<init-param>
<param-name>cacheManagerNames</param-name>
<param-value>cachePointCut</param-value>
</init-param>
<load-on-startup>1</load-on-startup>
</servlet>
<servlet-mapping>
<servlet-name>cacheadmin</servlet-name>
<url-pattern>/cacheadmin</url-pattern>
</servlet-mapping>
顯示內容,如下圖所示: