緩存的那些事

緩存能夠說是無處不在,好比:PC電腦中的內存、CPU中有二級緩存、http協議中的緩存控制、CDN加速技術 無不都是使用了緩存的思想來解決性能問題。javascript

緩存是用於解決高併發場景下系統的性能及穩定性問題的銀彈。java

本文主要是討論咱們常用的分佈式緩存Redis在開發過程當中須要考慮的問題。git

1. 如何將業務邏輯與緩存之間進行解耦?

大部分狀況,你們都是把緩存操做和業務邏輯之間的代碼交織在一塊兒的,好比:github

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);
    }
}

從上面的代碼能夠看出如下幾個問題:redis

  1. 緩存操做很是繁瑣,產生很是多的重複代碼;
  2. 緩存操做與業務邏輯耦合度很是高,不利於後期的維護;
  3. 當業務數據爲null時,沒法肯定是否已經緩存,會形成緩存沒法命中;
  4. 開發階段,爲了排查問題,常常須要來回開關緩存功能,使用上面的代碼是沒法作到很方便地開關緩存功能;
  5. 當業務愈來愈複雜時,使用緩存的地方愈來愈多時,很難定位哪些數據要進行主動刪除;
  6. 若是想用別的緩存技術代替Redis,那就要哭了。。。

由於高耦合帶來的問題還不少,就不一一列舉了。接下來介紹筆者開源的一個緩存管理框架:AutoLoadCache是如何幫助咱們來解決上述問題的。數據庫

借鑑於Spring cache的思想使用AOP + Annotation 等技術實現緩存與業務邏輯的解耦。咱們再用AutoLoadCache 來重構上面的代碼,進行對比:json

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);
    }
}

2. 如何提高緩存key生成表達式性能?

使用Annotation解決緩存與業務之間的耦合後,咱們最主要的工做就是如何來設計緩存KEY了,緩存KEY設計的粒度越小,緩存的複用性也就越好。緩存

上面例子中咱們是使用Spring EL表達式來生成緩存KEY,有些人估計會擔憂Spring EL表達式的性能很差,或者不想用Spring的狀況該怎麼辦?服務器

框架中爲了知足這些需求,支持擴展表達式解析器:繼承com.jarvis.cache.script. AbstractScriptParser後就能夠任你擴展。併發

框架如今除了支持Spring EL表達式外,還支持Ognl,javascript表達式。對於性能要求很是高的人,可使用Ognl,它的性能很是接近原生代碼。

3. 如何解決緩存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;
    }
}

4. 壓縮緩存數據及提高序列化與反序列化性能

咱們但願緩存數據包越小越好,能減小內存佔用,以及減輕帶寬壓力;同時也要考慮序列化與反序列化的性能。

AutoLoadCache爲了知足不一樣用戶的須要,已經實現了基於JDK、Hessian、JacksonJson、Fastjson、JacksonMsgpack等技術序列化及反序列工具。也能夠經過實現com.jarvis.cache.serializer.ISerializer 接口自行擴展。

JDK自帶的序列化與反序列化工具產生的數據包很是大,並且性能也很是差,不建議你們使用;JacksonJson 和 Fastjson 是基於JSON的,全部用到緩存的函數的參數及返回值都必須是具體類型的,不能是不肯定類型的(不能是Object, List<?>等),另外有些數據轉成Json是其一些屬性是會被忽略,存在這種狀況時,也不能使用Json; 而Hessian 則是很是不錯的選擇,技術很是成熟,穩定性很是好。阿里的dubbo和HSF兩個RPC框架都是使用了Hessian進行序列化和返序列化。

5. 如何減小回源併發數?

當緩存未命中時,都須要回到數據源去取數據,若是這時有100個併發來請求同一個數據,這100個請求同時去數據源取數據,並寫緩存,形成資源極大的浪費,也可能形成數據源負載太高而沒法服務。

AutoLoadCache有兩種機制能夠解決這個問題:

  1. 拿來主義機制

    拿來主交機制,指的是當有多個用戶請求同一個數據時,會選舉出一個用戶去數據源加載數據,其它用戶則等待其拿到的數據。

  2. 自動加載機制

    自動加載機制,將用戶請求及緩存時間等信息放到一個隊列中,後臺使用線程池按期掃這個隊列,發現緩存緩存快要過時,則去數據源加載最新的數據放到緩存中。這樣能夠把用戶的不可預期的併發請求,轉成可固定的請求數量。

    自動加載機制設計之初是爲了解決如下問題:

    1. 使用很是頻繁的數據,長期緩存在內存中;
    2. 解決耗時業務;

往緩存裏寫數據性能相對來講要比讀請求慢一些,因此經過上面兩種機制,也能減小寫緩存的併發,提高緩存服務的性能和吞吐量。

6. 異步刷新

當緩存過時後,請求穿透到數據源中,可能會形成系統不穩定。

AutoLoadCache 會在緩存快過時以前發起一個異步請求,去數據源加載數據,來減小這方面的風險。

7. 批量刪除緩存

在不少時候,數據查詢條件是比較複雜,咱們沒法獲取或還原要刪除的緩存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) ;
    
}

8. 雙寫不一致問題

先來看下面的代碼:

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); 
    }
}

使用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,等事務提交後再去執行緩存刪除操做。其實不能說是「解決不一致問題」,而是緩解而已。

緩存數據雙寫不一致的問題是很難解決的,即便咱們只用數據庫(單寫的狀況)也會存在數據不一致的狀況(當從數據庫中取數據時,同時又被更新了),咱們只能是減小不一致狀況的發生。對於一些比較重要的數據,咱們不能直接使用緩存中的數據進行計算並回寫的數據庫中,好比扣庫存,須要對數據增長版本信息,並經過樂觀鎖等技術來避免數據不一致問題。

9. 支持多種緩存操做

大部分狀況下,咱們都是對緩存進行讀與寫操做,可有時,咱們只須要從緩存中讀取數據,或者只寫數據,那麼能夠經過 @Cache 的 opType 指定緩存操做類型。現支持如下幾種操做類型:

  1. READ_WRITE:讀寫緩存操:若是緩存中有數據,則使用緩存中的數據,若是緩存中沒有數據,則加載數據,並寫入緩存。默認是READ_WRITE;
  2. WRITE:從數據源中加載最新的數據,並寫入緩存。對數據源和緩存數據進行同步;
  3. READ_ONLY: 只從緩存中讀取,並不會去數據源加載數據。用於異地讀寫緩存的場景;
  4. LOAD :只從數據源加載數據,不讀取緩存中的數據,也不寫入緩存。

另外在@Cache中只能靜態指寫緩存操做類型,若是想在運行時調整操做類型,須要經過CacheHelper.setCacheOpType()方法來進行調整。

最後歡迎你們到github對AutoLoadCache開源項目Star和Fork進行支持。

相關文章
相關標籤/搜索