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