面對緩存,有哪些問題需要思考?
緩存可以說是無處不在,比如:PC電腦中的內(nèi)存、CPU中有二級緩存、http協(xié)議中的緩存控制、CDN加速技術(shù) 無不都是使用了緩存的思想來解決性能問題。
緩存是用于解決高并發(fā)場景下系統(tǒng)的性能及穩(wěn)定性問題的銀彈。
本文主要是討論我們經(jīng)常使用的分布式緩存Redis在開發(fā)過程中需要考慮的問題。
1. 如何將業(yè)務(wù)邏輯與緩存之間進(jìn)行解耦?
大部分情況,大家都是把緩存操作和業(yè)務(wù)邏輯之間的代碼交織在一起的,比如(代碼一):
- public UserServiceImpl implements UserService {
- @Autowired
- private RedisTemplate<String, User> redisTemplate;
- @Autowired
- private UserMapper userMapper;
- public User getUserById(Long userId) {
- String cacheKey = "user_" + userId;
- User user = redisTemplate.opsForValue().get(cacheKey);
- if(null != user) {
- return user;
- }
- user = userMapper.getUserById(userId);
- redisTemplate.opsForValue().set(cacheKey, user); // 如果user 為null時,緩存就沒有意義了
- return user;
- }
- public void deleteUserById(Long userId) {
- userMapper.deleteUserById(userId);
- String cacheKey = "user_" + userId;
- redisTemplate.opsForValue().del(cacheKey);
- }
- }
從上面的代碼可以看出以下幾個問題:
- 緩存操作非常繁瑣,產(chǎn)生非常多的重復(fù)代碼;
- 緩存操作與業(yè)務(wù)邏輯耦合度非常高,不利于后期的維護(hù);
- 當(dāng)業(yè)務(wù)數(shù)據(jù)為null時,無法確定是否已經(jīng)緩存,會造成緩存無法***;
- 開發(fā)階段,為了排查問題,經(jīng)常需要來回開關(guān)緩存功能,使用上面的代碼是無法做到很方便地開關(guān)緩存功能;
- 當(dāng)業(yè)務(wù)越來越復(fù)雜時,使用緩存的地方越來越多時,很難定位哪些數(shù)據(jù)要進(jìn)行主動刪除;
- 如果不想用Redis,換用別的緩存技術(shù)的話,那是多么痛苦的一件事。
因為高耦合帶來的問題還很多,就不一一列舉了。接下來介紹筆者開源的一個緩存管理框架:AutoLoadCache是如何幫助我們來解決上述問題的。
借鑒于Spring cache的思想使用AOP + Annotation 等技術(shù)實現(xiàn)緩存與業(yè)務(wù)邏輯的解耦。我們再用AutoLoadCache 來重構(gòu)上面的代碼,進(jìn)行對比(代碼二):
- public interface UserMapper {
- @Cache(expire = 120, key = "'user_' + #args[0]")
- User getUserById(Long userId);
- @CacheDelete({ @CacheDeleteKey(value = "'user' + #args[0].id") })
- void updateUser(User user);
- }
- public UserServiceImpl implements UserService {
- @Autowired
- private UserMapper userMapper;
- public User getUserById(Long userId) {
- return userMapper.getUserById(userId);
- }
- @Transactional(rollbackFor=Throwable.class)
- public void updateUser(User user) {
- userMapper.updateUser(user);
- }
- }
AutoloadCache 在AOP攔截到請求后,大概的流程如下:
- 獲取到攔截方法的@Cache注解,并生成緩存key;
- 通過緩存key,去緩存中獲取數(shù)據(jù);
如果緩存***,執(zhí)行如下流程:
- 如果需要自動加載,則把相關(guān)信息保存到自動加載隊列中;
- 否則判斷緩存是否即將過期,如果即將過期,則會發(fā)起異步刷新;
- ***把數(shù)據(jù)返回給用戶;
如果緩存沒有***,執(zhí)行如下流程:
- 選舉出一個leader回到數(shù)據(jù)源中去加載數(shù)據(jù),加載到數(shù)據(jù)后通知其它請求從內(nèi)存中獲取數(shù)據(jù)(拿來主義機(jī)制);
- leader負(fù)責(zé)把數(shù)據(jù)寫入緩存;如果需要自動加載,則把相關(guān)信息保存到自動加載隊列中;
- ***把數(shù)據(jù)返回給用戶;
這里提到的異步刷新、自動加載、拿來主義機(jī)制,我們會在后面再說明。
2. 對緩存進(jìn)行“包裝”
上面代碼一的例子中,當(dāng)從數(shù)據(jù)源獲取的數(shù)據(jù)為null時,緩存就沒有意義了,所獲取這個數(shù)據(jù)的請求,都會回到數(shù)據(jù)源去獲取數(shù)據(jù)。當(dāng)請求量非常大的話,會造成數(shù)據(jù)源負(fù)載過高而宕機(jī)。所以對于null的數(shù)據(jù),需要做特殊處理,比如使用特殊字符串進(jìn)行替換。而在AutoloadCache中使用了一個包裝器對所有緩存數(shù)據(jù)進(jìn)行包裝(代碼三):
- public class CacheWrapper<T> implements Serializable, Cloneable {
- private T cacheObject; // 緩存數(shù)據(jù)
- private long lastLoadTime; // 加載時間
- private int expire; // 緩存時長
- /**
- * 判斷緩存是否已經(jīng)過期
- * @return boolean
- */
- public boolean isExpired() {
- if(expire > 0) {
- return (System.currentTimeMillis() - lastLoadTime) > expire * 1000;
- }
- return false;
- }
- }
在這上面的代碼中,除了封裝了緩存數(shù)據(jù)外,還封裝了數(shù)據(jù)加載時間和緩存時長,通過這兩項數(shù)據(jù),很容易判斷緩存是否即將過期或者已經(jīng)過期。
3. 如何提升緩存key生成表達(dá)式性能?
使用Annotation解決緩存與業(yè)務(wù)之間的耦合后,我們最主要的工作就是如何來設(shè)計緩存KEY了,緩存KEY設(shè)計的粒度越小,緩存的復(fù)用性也就越好。
上面例子中我們是使用Spring EL表達(dá)式來生成緩存KEY,有些人估計會擔(dān)心Spring EL表達(dá)式的性能不好,或者不想用Spring的情況該怎么辦?
框架中為了滿足這些需求,支持?jǐn)U展表達(dá)式解析器:繼承com.jarvis.cache.script. AbstractScriptParser后就可以任你擴(kuò)展。
框架現(xiàn)在除了支持Spring EL表達(dá)式外,還支持Ognl,javascript表達(dá)式。對于性能要求非常高的人,可以使用Ognl,它的性能非常接近原生代碼。
4. 如何解決緩存Key沖突問題?
在實際情況中,可能有多個模塊共用一個Redis服務(wù)器或是一個Redis集群的情況,那么有可能造成緩存key沖突了。
為了解決這個問題AutoLoadCache,增加了namespace。如果設(shè)置了namespace就會在每個緩存Key最前面增加namespace(代碼四):
- public final class CacheKeyTO implements Serializable {
- private final String namespace;
- private final String key;// 緩存Key
- private final String hfield;// 設(shè)置哈希表中的字段,如果設(shè)置此項,則用哈希表進(jìn)行存儲
- public String getCacheKey() { // 生成緩存Key方法
- if(null != this.namespace && this.namespace.length() > 0) {
- return new StringBuilder(this.namespace).append(":").append(this.key).toString();
- }
- return this.key;
- }
- }
5. 壓縮緩存數(shù)據(jù)及提升序列化與反序列化性能
我們希望緩存數(shù)據(jù)包越小越好,能減少內(nèi)存占用,以及減輕帶寬壓力;同時也要考慮序列化與反序列化的性能。
AutoLoadCache為了滿足不同用戶的需要,已經(jīng)實現(xiàn)了基于JDK、Hessian、JacksonJson、Fastjson、JacksonMsgpack等技術(shù)序列化及反序列工具。也可以通過實現(xiàn)com.jarvis.cache.serializer.ISerializer 接口自行擴(kuò)展。
JDK自帶的序列化與反序列化工具產(chǎn)生的數(shù)據(jù)包非常大,而且性能也非常差,不建議大家使用;JacksonJson 和 Fastjson 是基于JSON的,所有用到緩存的函數(shù)的參數(shù)及返回值都必須是具體類型的,不能是不確定類型的(不能是Object, List等),另外有些數(shù)據(jù)轉(zhuǎn)成Json是其一些屬性是會被忽略,存在這種情況時,也不能使用Json;
而Hessian 則是非常不錯的選擇,非常成熟和穩(wěn)定性。阿里的dubbo和HSF兩個RPC框架都是使用了Hessian進(jìn)行序列化和返序列化。
6. 如何減少回源并發(fā)數(shù)?
當(dāng)緩存未***時,都需要回到數(shù)據(jù)源去取數(shù)據(jù),如果這時有多個并發(fā)來請求相同一個數(shù)據(jù)(即相同緩存key請求),都回到數(shù)據(jù)源加載數(shù)據(jù),并寫緩存,造成資源極大的浪費(fèi),也可能造成數(shù)據(jù)源負(fù)載過高而無法服務(wù)。
AutoLoadCache 使用拿來主義機(jī)制和自動加載機(jī)制來解決這個問題:
拿來主義機(jī)制
拿來主交機(jī)制,指的是當(dāng)有多個用戶請求同一個數(shù)據(jù)時,會選舉出一個leader去數(shù)據(jù)源加載數(shù)據(jù),其它用戶則等待其拿到的數(shù)據(jù)。并由leader將數(shù)據(jù)寫入緩存。
自動加載機(jī)制
自動加載機(jī)制,將用戶請求及緩存時間等信息放到一個隊列中,后臺使用線程池定期掃這個隊列,發(fā)現(xiàn)緩存即將過期,則去數(shù)據(jù)源加載***的數(shù)據(jù)放到緩存中。達(dá)到將數(shù)據(jù)長駐內(nèi)存的效果。從而將這些數(shù)據(jù)的請求,全部引向了緩存,而不會回到數(shù)據(jù)源去獲取數(shù)據(jù)。非常適合用于緩存使用非常頻繁的數(shù)據(jù),以及非常耗時的數(shù)據(jù)。
為了防止自動加載隊列過大,設(shè)置了容量限制;同時會將超過一定時間沒有用戶請求的也會從自動加載隊列中移除,把服務(wù)器資源釋放出來,給真正需要的請求。
往緩存里寫數(shù)據(jù)的性能相比讀的性能差非常多,通過上面兩種機(jī)制,可以減少寫緩存的并發(fā),提升緩存服務(wù)能力。
7. 異步刷新
AutoLoadCache 從緩存中獲取到數(shù)據(jù)后,借助于上面提到的CacheWrapper,能很方便判斷緩存是否即將過期, 如果即將過期,則會把發(fā)起異步刷新請求。
使用異步刷新的目的,提前將數(shù)據(jù)緩存起來,避免緩存失效后,大量請求穿透到數(shù)據(jù)源。
8. 支持多種緩存操作
大部分情況下,我們都是對緩存進(jìn)行讀與寫操作,可有時,我們只需要從緩存中讀取數(shù)據(jù),或者只寫數(shù)據(jù),那么可以通過 @Cache 的 opType 指定緩存操作類型。現(xiàn)支持以下幾種操作類型:
- READ_WRITE:讀寫緩存操:如果緩存中有數(shù)據(jù),則使用緩存中的數(shù)據(jù),如果緩存中沒有數(shù)據(jù),則加載數(shù)據(jù),并寫入緩存。默認(rèn)是READ_WRITE;
- WRITE:從數(shù)據(jù)源中加載***的數(shù)據(jù),并寫入緩存。對數(shù)據(jù)源和緩存數(shù)據(jù)進(jìn)行同步;
- READ_ONLY: 只從緩存中讀取,并不會去數(shù)據(jù)源加載數(shù)據(jù)。用于異地讀寫緩存的場景;
- LOAD :只從數(shù)據(jù)源加載數(shù)據(jù),不讀取緩存中的數(shù)據(jù),也不寫入緩存。
另外在@Cache中只能靜態(tài)指寫緩存操作類型,如果想在運(yùn)行時調(diào)整操作類型,需要通過CacheHelper.setCacheOpType()方法來進(jìn)行調(diào)整。
9. 批量刪除緩存
在很多時候,數(shù)據(jù)查詢條件是比較復(fù)雜,我們無法獲取或還原要刪除的緩存key。
AutoLoadCache 為了解決這個問題,使用Redis的hash表來管理這部分的緩存。把需要批量刪除的緩存放在同一個hash表中,如果需要需要批量刪除這些緩存時,直接把這個hash表刪除即可。這時只要設(shè)計合理粒度的緩存key即可。
通過@Cache的hfield設(shè)置hash表的key。
我們舉個商品評論的場景(代碼五):
- public interface ProuductCommentMapper {
- @Cache(expire=600, key="'prouduct_comment_list_'+#args[0]", hfield = "#args[1]+'_'+#args[2]")
- // 例如:prouductId=1, pageNo=2, pageSize=3 時相當(dāng)于Redis命令:HSET prouduct_comment_list_1 2_3 List<Long>
- public List<Long> getCommentListByProuductId(Long prouductId, int pageNo, int pageSize);
- @CacheDelete({@CacheDeleteKey(value="'prouduct_comment_list_'+#args[0].prouductId")})
- // 例如:#args[0].prouductId = 1時,相當(dāng)于Redis命令: DEL prouduct_comment_list_1
- public void addComment(ProuductComment comment) ;
- }
如果添加評論時,我們只需要主動刪除前3頁的評論(代碼六):
- public interface ProuductCommentMapper {
- @Cache(expire=600, key="'prouduct_comment_list_'+#args[0]+'_'+#args[1]", hfield = "#args[2]")
- public List<Long> getCommentListByProuductId(Long prouductId, int pageNo, int pageSize);
- @CacheDelete({
- @CacheDeleteKey(value="'prouduct_comment_list_'+#args[0].prouductId+'_1'"),
- @CacheDeleteKey(value="'prouduct_comment_list_'+#args[0].prouductId+'_2'"),
- @CacheDeleteKey(value="'prouduct_comment_list_'+#args[0].prouductId+'_3'")
- })
- public void addComment(ProuductComment comment) ;
- }
10. 雙寫不一致問題
在“代碼二”中使用updateUser方法更新用戶信息時, 同時會主動刪除緩存中的數(shù)據(jù)。 如果在事務(wù)還沒提交之前又有一個請求去加載用戶數(shù)據(jù),這時就會把數(shù)據(jù)庫中舊數(shù)據(jù)緩存起來,在下次主動刪除緩存或緩存過期之前的這一段時間內(nèi),緩存中的數(shù)據(jù)與數(shù)據(jù)庫中的數(shù)據(jù)是不一致的。AutoloadCache框架為了解決這個問題,引入了一個新的注解:@CacheDeleteTransactional (代碼七):
- public UserServiceImpl implements UserService {
- @Autowired
- private UserMapper userMapper;
- public User getUserById(Long userId) {
- return userMapper.getUserById(userId);
- }
- @Transactional(rollbackFor=Throwable.class)
- @CacheDeleteTransactional
- public void updateUser(User user) {
- userMapper.updateUser(user);
- }
- }
使用@CacheDeleteTransactional注解后,AutoloadCache 會先使用ThreadLocal緩存要刪除緩存KEY,等事務(wù)提交后再去執(zhí)行緩存刪除操作。其實不能說是“解決不一致問題”,而是緩解而已。
緩存數(shù)據(jù)雙寫不一致的問題是很難解決的,即使我們只用數(shù)據(jù)庫(單寫的情況)也會存在數(shù)據(jù)不一致的情況(當(dāng)從數(shù)據(jù)庫中取數(shù)據(jù)時,同時又被更新了),我們只能是減少不一致情況的發(fā)生。對于一些比較重要的數(shù)據(jù),我們不能直接使用緩存中的數(shù)據(jù)進(jìn)行計算并回寫的數(shù)據(jù)庫中,比如扣庫存,需要對數(shù)據(jù)增加版本信息,并通過樂觀鎖等技術(shù)來避免數(shù)據(jù)不一致問題。
11. 與Spring Cache的比較
AutoLoadCache 的思想其實是源自 Spring Cache,都是使用 AOP + Annotation ,將緩存與業(yè)務(wù)邏輯進(jìn)行解耦。區(qū)別在于:
- AutoLoadCache 的AOP不限于Spring 中的AOP技術(shù),即可以脫離Spring 生態(tài)使用,比如成功案例nutz;
- Spring Cache不支持命名空間;
- Spring Cache沒有自動加載、異步刷新、拿來主義機(jī)制;
- Spring Cache使用name 和 key的來管理緩存(即通過name和key就可以操作具體緩存了),而AutoLoadCache 使用的是namespace + key + hfield 來管理緩存,同時每個緩存都可以指定緩存時間(expire)。也就是說Spring Cache 比較適合用來管理Ehcache的緩存,而AutoLoadCache 更加適合管理Redis, Memcache,尤其是Redis,hfield 相關(guān)的功能都是針對它們進(jìn)行開發(fā)的(因為Memcache不支持hash表,所以沒辦法使用hfield相關(guān)的功能)。
- Spring Cache不能針對每個緩存Key,進(jìn)行設(shè)置緩存過期時間。而在緩存管理應(yīng)用中,不同的緩存其緩存時間要盡量設(shè)置為不同的。如果都相同的,那緩存同時失效的可能性會比較大些,這樣穿透到數(shù)據(jù)庫的可能性也就更大了,對系統(tǒng)的穩(wěn)定性是沒有好處的;
- Spring Cache ***的缺點(diǎn)就是無法使用Spring EL表達(dá)式來動態(tài)生成Cache name,而且Cache name是的必須在Spring 配置時指定幾個,非常不方便使用。尤其想在Redis中想精確清除一批緩存,是無法實現(xiàn)的,可能會誤刪除我們不希望被刪除的緩存;
- Spring Cache只能基于Spring 中的AOP及Spring EL表達(dá)式來使用,而AutoloadCache 可以根據(jù)使用者的實際情況進(jìn)行擴(kuò)展;
- AutoLoadCache中使用@CacheDeleteTransactional 來減少雙寫不一致問題,而Spring Cache沒有相應(yīng)的解決方案;作者:家榆_77cd鏈接:http://www.jianshu.com/p/4f52d046c3d2來源:簡書著作權(quán)歸作者所有。商業(yè)轉(zhuǎn)載請聯(lián)系作者獲得授權(quán),非商業(yè)轉(zhuǎn)載請注明出處。