AutoLoadCache 3.1 發布,優化“拿來主義”機制
3.1 優化“拿來主義”機制
“拿來主義”機制指的是,當有多個請求去獲取同一個數據時,我們先讓其中一個請求先去DAO中獲取數據,并放到緩存中,其它請求則等它完成后,直接去緩存中獲取數據,通過這種方式減輕DAO中的并發。
但經測試發現,剛往Reids中放的數據,立即去取是獲取不到數據的(無法命中),測試代碼已經放到cache-example中。優化后的方案是,不從遠程服務器獲取,而是從本地緩存中獲取第一個請求返回的數據。減少并發的同時,還能減輕網絡壓力。現在使用的緩存技術很多,比如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>3.0</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 自動加載隊列排序算法, :按在Map中存儲的順序(即無序);1 :越接近過期時間,越耗時的排在最前;2:根據請求次數,倒序排序,請求次數越多,說明使用頻率越高,造成并發的可能越大。更詳細的說明,請查看代碼com.jarvis.cache.type.AutoLoadQueueSortType
- checkFromCacheBeforeLoad 加載數據之前去緩存服務器中檢查,數據是否快過期,如果應用程序部署的服務器數量比較少,設置為false, 如果部署的服務器比較多,可以考慮設置為true
- autoLoadPeriod 單個線程中執行自動加載的時間間隔, 此值越小,遍歷自動加載隊列頻率起高,對CPU會越消耗CPU
@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;
}@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"。
數據實時性
下面商品評論的例子中,如果用戶發表了評論,要立即顯示該如何來處理?
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. 對于查詢條件變化比較劇烈的,不要使用自動加載機制。
比如,根據用戶輸入的關鍵字進行搜索數據的方法,不建議使用自動加載。
在事務環境中,如何減少“臟讀”
不要從緩存中取數據,然后應用到修改數據的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>
未來計劃:zookeeper + redis集群方案實現
大概思路是:使用一個獨立程序去管理各個業務的redis配置,這程序啟動時把相關的配置寫到zookeeper中,然后去ping各個redis,如果redis ping不通,則會從zookeeper中刪除,如果恢復了,則加回到zookeeper中。 應用程序監聽zookeeper的配置變化,并使用 一致性哈希算法來分配緩存。
更新日志
3.0 此版本做了大調整,有些功能已經不兼容老版本
不再使用默認緩存Key,所有的緩存都必須自定義緩存Key;原來使用$hash()來調用hash函數,改為使用#hash()進行調用。
之前版本中使用通配符(?和*)進行批量刪除緩存,這種方法性能比較差,需要多次與Redis進行交互,而且隨著緩存Key的數量的增加,性能也會下降,如果有多個Reids實例的話,還需要遍歷每個實例。為了解決這個問題,我們使用hash表保存需要批量刪除的緩存,要批量刪除緩存時,只要把hash表刪除就可以了。
如果在開發階段不想使用Redis來緩存數據,可以使用com.jarvis.cache.map.CachePointCut,把數據緩存到本地內存中,雖然它不支持使用通配符進行批量刪除緩存,但同樣支持使用hash表進行批量刪除緩存。所以轉用Redis緩存數據是沒有任務問題的。
如果需要在MyBatis Mapper中使用@Cache和@CacheDelete,則需要使用com.jarvis.cache.mybatis.CachePointCutProxy 來處理。
2.13 優化多線程并發等機制, 代碼調整如下:
由于我們這里要實現的功能是,當前的線程要等待前一個正在運行線程的結果,但我們不知道前一個線程的執行到哪一步。有可能在我們要執行wait時,已經完成notifyAll了。通過調整邏輯變得更加嚴謹。
2.12 解決jdk1.8之前中 java.lang.NoSuchMethodError: java.util.Map.putIfAbsent 錯誤。
2.11 @CacheDeleteKey中keyType 設置default,以實現向下兼容。
2.10 修改記錄:
優化ConcurrentHashMap 使用,將以下代碼:
Boolean isProcessing=null; try { lock.lock(); if(null == (isProcessing=processing.get(cacheKey))) {// 為發減少數據層的并發,增加等待機制。 processing.put(cacheKey, Boolean.TRUE); } } finally { lock.unlock(); }改為:
Boolean isProcessing=processing.putIfAbsent(cacheKey, Boolean.TRUE);// 為發減少數據層的并發,增加等待機制。
放棄使用 @CacheDeleteKey中keyType, 直接使用它的value值來判斷是自定義緩存Key,還是默認生成的緩存Key。所以keyType 變得多余了。
2.9 修復以下幾個問題
@Cache(expire=0, waitTimeOut=500),當expire=0時,將設置為永久緩存;waitTimeOut 用于設置并發等待時間(毫秒)。
增加自動加載,單個線程內的等待時間間隔:
<bean id="autoLoadConfig" class="com.jarvis.cache.to.AutoLoadConfig"> ... ... <property name="autoLoadPeriod" value="50" /><!--默認值50ms--> </bean>
優化AbstractCacheManager類的loadData方法中線程同步鎖。
2.8 com.jarvis.lib.util.BeanUtil.toString()方法中增加反射緩存,提升反射效率
2.7 當參數類型為 Class,自動生成的緩存Key會出問題。(感謝zhaopeng 提供的代碼)
2.5 2.6 當autoload=true,緩存Key中沒有加上命名空間,對1.9及以上版本有影響
2.4 Jedis更新到2.8
2.3 AdminServlet 增加登錄用戶名和密碼;
2.2 解決Hessian不能正確序列化BigDecimal問題
2.1 對Kryo進行測試,發現問題問題比較多,所以刪除Kryo 支持,用戶可以根據自己的情況實現ISerializer接口。優化HessianSerializer,提升性能,并將HessianSerializer作為默認的序列化和反序列化工具。
2.0 增加了Hessian 和 Kryo 序列化支持,還是使用JDK自帶的處理方法。修改方法如下:
<bean id="jdkSerializer" class="com.jarvis.cache.serializer.JdkSerializer" /> <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" /> </bean>
雖然Kryo效率比較高,但使用Kryo會出現的問題比較多,所以還是慎重使用,系統經常維護的就不太適合使用,經過測試,改變屬性名稱,或刪除中間的屬性等情況都可能反序列出錯誤的值,所以如果遇到有減少或修改的情況要及時清里緩存。如果是增加屬性則會反序列化失敗,這正符合我們的要求。
1.9 增加了命名空間,避免不同的系統之支緩存沖突
來自: http://www.oschina.net//news/70850/autoloadcache-3-1


