天天看點

品味Spring Cache設計之美

最近負責教育類産品的架構工作,兩位研發同學建議:“團隊封裝的Redis用戶端可否适配Spring Cache,這樣加緩存就會友善多了” 。

于是邊查閱文檔邊實戰,收獲頗豐,寫這篇文章,想和大家分享筆者學習的過程,一起品味Spring Cache設計之美。

品味Spring Cache設計之美

1 寫死

在學習Spring Cache之前,筆者經常會寫死的方式使用緩存。

舉個例子,為了提升使用者資訊的查詢效率,我們對使用者資訊使用了緩存,示例代碼如下:

@Autowire
  private UserMapper userMapper;
  @Autowire
  private StringCommand stringCommand;
  //查詢使用者
  public User getUserById(Long userId) {
   String cacheKey = "userId_" + userId;
   User user=stringCommand.get(cacheKey);
   if(user != null) {
    return user;
   }
   user = userMapper.getUserById(userId);
   if(user != null) {
    stringCommand.set(cacheKey,user);
    return user;
   }
   //修改使用者
   public void updateUser(User user){
    userMapper.updateUser(user);
    String cacheKey = "userId_" + userId.getId();
    stringCommand.set(cacheKey , user);
   }
   //删除使用者
   public void deleteUserById(Long userId){
     userMapper.deleteUserById(userId);
     String cacheKey = "userId_" + userId.getId();
     stringCommand.del(cacheKey);
   }
  }
           

相信很多同學都寫過類似風格的代碼,這種風格符合面向過程的程式設計思維,非常容易了解。但它也有一些缺點:

  1. 代碼不夠優雅。業務邏輯有四個典型動作:存儲,讀取,修改,删除。每次操作都需要定義緩存Key ,調用緩存指令的API,産生較多的重複代碼;
  2. 緩存操作和業務邏輯之間的代碼耦合度高,對業務邏輯有較強的侵入性。

    侵入性主要展現如下兩點:

    • 開發聯調階段,需要去掉緩存,隻能注釋或者臨時删除緩存操作代碼,也容易出錯;
    • 某些場景下,需要更換緩存元件,每個緩存元件有自己的API,更換成本頗高。

2 緩存抽象

首先需要明确一點:Spring Cache不是一個具體的緩存實作方案,而是一個對緩存使用的抽象(Cache Abstraction)。

品味Spring Cache設計之美

2.1 Spring AOP

Spring AOP是基于代理模式(proxy-based)。

通常情況下,定義一個對象,調用它的方法的時候,方法是直接被調用的。

Pojo pojo = new SimplePojo();
 pojo.foo();
           
品味Spring Cache設計之美

将代碼做一些調整,pojo對象的引用修改成代理類。

ProxyFactory factory = new ProxyFactory(new SimplePojo());
factory.addInterface(Pojo.class);
factory.addAdvice(new RetryAdvice());

Pojo pojo = (Pojo) factory.getProxy(); 
//this is a method call on the proxy!
pojo.foo();
           
品味Spring Cache設計之美

調用pojo的foo方法的時候,實際上是動态生成的代理類調用foo方法。

代理類在方法調用前可以擷取方法的參數,當調用方法結束後,可以擷取調用該方法的傳回值,通過這種方式就可以實作緩存的邏輯。

2.2 緩存聲明

緩存聲明,也就是辨別需要緩存的方法以及緩存政策。

Spring Cache 提供了五個注解。

  • @Cacheable:根據方法的請求參數對其結果進行緩存,下次同樣的參數來執行該方法時可以直接從緩存中擷取結果,而不需要再次執行該方法;
  • @CachePut:根據方法的請求參數對其結果進行緩存,它每次都會觸發真實方法的調用;
  • @CacheEvict:根據一定的條件删除緩存;
  • @Caching:組合多個緩存注解;
  • @CacheConfig:類級别共享緩存相關的公共配置。

我們重點講解:@Cacheable,@CachePut,@CacheEvict三個核心注解。

2.2.1 @Cacheable注解

@Cacheble注解表示這個方法有了緩存的功能。

@Cacheable(value="user_cache",key="#userId", unless="#result == null")
public User getUserById(Long userId) {
  User user = userMapper.getUserById(userId);
  return user;
}
           

上面的代碼片段裡,

getUserById

方法和緩存

user_cache

關聯起來,若方法傳回的User對象不為空,則緩存起來。第二次相同參數userId調用該方法的時候,直接從緩存中擷取資料,并傳回。

▍ 緩存key的生成

我們都知道,緩存的本質是

key-value

存儲模式,每一次方法的調用都需要生成相應的Key, 才能操作緩存。

通常情況下,@Cacheable有一個屬性key可以直接定義緩存key,開發者可以使用SpEL語言定義key值。

若沒有指定屬性key,緩存抽象提供了

KeyGenerator

來生成key ,預設的生成器代碼見下圖:

品味Spring Cache設計之美

它的算法也很容易了解:

  • 如果沒有參數,則直接傳回SimpleKey.EMPTY;
  • 如果隻有一個參數,則直接傳回該參數;
  • 若有多個參數,則傳回包含多個參數的SimpleKey對象。

當然Spring Cache也考慮到需要自定義Key生成方式,需要我們實作

org.springframework.cache.interceptor.KeyGenerator

接口。

Object generate(Object target, Method method, Object... params);
           

然後指定@Cacheable的keyGenerator屬性。

@Cacheable(value="user_cache", keyGenerator="myKeyGenerator", unless="#result == null")
public User getUserById(Long userId) 
           

▍ 緩存條件

有的時候,方法執行的結果是否需要緩存,依賴于方法的參數或者方法執行後的傳回值。

注解裡可以通過

condition

屬性,通過Spel表達式傳回的結果是true 還是false 判斷是否需要緩存。

@Cacheable(cacheNames="book", condition="#name.length() < 32")
public Book findBook(String name)
           

上面的代碼片段裡,當參數的長度小于32,方法執行的結果才會緩存。

除了condition,

unless

屬性也可以決定結果是否緩存,不過是在執行方法後。

@Cacheable(value="user_cache",key="#userId", unless="#result == null")
public User getUserById(Long userId) {
           

上面的代碼片段裡,當傳回的結果為null則不緩存。

2.2.2 @CachePut注解

@CachePut注解作用于緩存需要被更新的場景,和 @Cacheable 非常相似,但被注解的方法每次都會被執行。

傳回值是否會放入緩存,依賴于condition和unless,預設情況下結果會存儲到緩存。

@CachePut(value = "user_cache", key="#user.id", unless = "#result != null")
public User updateUser(User user) {
    userMapper.updateUser(user);
    return user;
}
           

當調用updateUser方法時,每次方法都會被執行,但是因為unless屬性每次都是true,是以并沒有将結果緩存。當去掉unless屬性,則結果會被緩存。

2.2.3 @CacheEvict注解

@CacheEvict 注解的方法在調用時會從緩存中移除已存儲的資料。

@CacheEvict(value = "user_cache", key = "#id")
public void deleteUserById(Long id) {
    userMapper.deleteUserById(id);
}
           

當調用deleteUserById方法完成後,緩存key等于參數id的緩存會被删除,而且方法的傳回的類型是Void ,這和@Cacheable明顯不同。

2.3 緩存配置

Spring Cache是一個對緩存使用的抽象,它提供了多種存儲內建。

品味Spring Cache設計之美

要使用它們,需要簡單地聲明一個适當的

CacheManager

- 一個控制和管理

Cache

的實體。

我們以Spring Cache預設的緩存實作Simple例子,簡單探索下CacheManager的機制。

CacheManager非常簡單:

public interface CacheManager {
   @Nullable
   Cache getCache(String name);
   
   Collection<String> getCacheNames();
}
           

在CacheConfigurations配置類中,可以看到不同內建類型有不同的緩存配置類。

品味Spring Cache設計之美

通過SpringBoot的自動裝配機制,建立CacheManager的實作類

ConcurrentMapCacheManager

品味Spring Cache設計之美

ConcurrentMapCacheManager

的getCache方法,會建立

ConcurrentCacheMap

品味Spring Cache設計之美

ConcurrentCacheMap

實作了

org.springframework.cache.Cache

品味Spring Cache設計之美

從Spring Cache的Simple的實作,緩存配置需要實作兩個接口:

  • org.springframework.cache.CacheManager
  • org.springframework.cache.Cache

3 入門例子

首先我們先建立一個工程spring-cache-demo。

品味Spring Cache設計之美

caffeine和Redisson分别是本地記憶體和分布式緩存Redis架構中的佼佼者,我們分别示範如何內建它們。

3.1 內建caffeine

3.1.1 maven依賴

<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-cache</artifactId>
</dependency>
<dependency>
  <groupId>com.github.ben-manes.caffeine</groupId>
  <artifactId>caffeine</artifactId>
  <version>2.7.0</version>
</dependency>
           

3.1.2 Caffeine緩存配置

我們先建立一個緩存配置類MyCacheConfig。

@Configuration
@EnableCaching
public class MyCacheConfig {
  @Bean
  public Caffeine caffeineConfig() {
    return
      Caffeine.newBuilder()
      .maximumSize(10000).
      expireAfterWrite(60, TimeUnit.MINUTES);
  }
  @Bean
  public CacheManager cacheManager(Caffeine caffeine) {
    CaffeineCacheManager caffeineCacheManager = new CaffeineCacheManager();
    caffeineCacheManager.setCaffeine(caffeine);
    return caffeineCacheManager;
  }
}
           

首先建立了一個Caffeine對象,該對象辨別本地緩存的最大數量是10000條,每個緩存資料在寫入60分鐘後失效。

另外,MyCacheConfig類上我們添加了注解:@EnableCaching。

3.1.3 業務代碼

根據緩存聲明這一節,我們很容易寫出如下代碼。

@Cacheable(value = "user_cache", unless = "#result == null")
public User getUserById(Long id) {
    return userMapper.getUserById(id);
}
@CachePut(value = "user_cache", key = "#user.id", unless = "#result == null")
public User updateUser(User user) {
    userMapper.updateUser(user);
    return user;
}
@CacheEvict(value = "user_cache", key = "#id")
public void deleteUserById(Long id) {
    userMapper.deleteUserById(id);
}
           

這段代碼與寫死裡的代碼片段明顯精簡很多。

當我們在Controller層調用 getUserById方法時,調試的時候,配置mybatis日志級别為DEBUG,友善監控方法是否會緩存。

第一次調用會查詢資料庫,列印相關日志:

Preparing: select * FROM user t where t.id = ? 
Parameters: 1(Long)
Total: 1
           

第二次調用查詢方法的時候,資料庫SQL日志就沒有出現了, 也就說明緩存生效了。

3.2 內建Redisson

3.2.1 maven依賴

<dependency>
   <groupId>org.Redisson</groupId>
   <artifactId>Redisson</artifactId>
   <version>3.12.0</version>
</dependency>
           

3.2.2 Redisson緩存配置

@Bean(destroyMethod = "shutdown")
public RedissonClient Redisson() {
  Config config = new Config();
  config.useSingleServer()
        .setAddress("redis://127.0.0.1:6201").setPassword("ts112GpO_ay");
  return Redisson.create(config);
}
@Bean
CacheManager cacheManager(RedissonClient RedissonClient) {
  Map<String, CacheConfig> config = new HashMap<String, CacheConfig>();
 // create "user_cache" spring cache with ttl = 24 minutes and maxIdleTime = 12 minutes
  config.put("user_cache", 
             new CacheConfig(
             24 * 60 * 1000, 
             12 * 60 * 1000));
  return new RedissonSpringCacheManager(RedissonClient, config);
}
           

可以看到,從Caffeine切換到Redisson,隻需要修改緩存配置類,定義CacheManager 對象即可。而業務代碼并不需要改動。

Controller層調用 getUserById方法,使用者ID為1的時候,可以從Redis Desktop Manager裡看到: 使用者資訊已被緩存,user_cache緩存存儲是Hash資料結構。

品味Spring Cache設計之美

因為Redisson預設的編解碼是FstCodec, 可以看到key的名稱是: \xF6\x01。

在緩存配置代碼裡,可以修改編解碼器。

public RedissonClient Redisson() {
  Config config = new Config();
  config.useSingleServer()
        .setAddress("redis://127.0.0.1:6201").setPassword("ts112GpO_ay");
  config.setCodec(new JsonJacksonCodec());
  return Redisson.create(config);
}
           

再次調用 getUserById方法 ,控制台就變成:

品味Spring Cache設計之美

可以觀察到:緩存key已經變成了:["java.lang.Long",1],改變序列化後key和value已發生了變化。

3.3 從清單緩存再次了解緩存抽象

清單緩存在業務中經常會遇到。通常有兩種實作形式:

  1. 整體清單緩存;
  2. 按照每個條目緩存,通過redis,memcached的聚合查詢方法批量擷取清單,若緩存沒有命中,則從資料庫重新加載,并放入緩存裡。

那麼Spring cache整合Redisson如何緩存清單資料呢?

@Cacheable(value = "user_cache")
public List<User> getUserList(List<Long> idList) {
    return userMapper.getUserByIds(idList);
}
           

執行getUserList方法,參數id清單為:[1,3] 。

品味Spring Cache設計之美

執行完成之後,控制台裡可以看到:清單整體直接被緩存起來,使用者清單緩存和使用者條目緩存并沒有共享,他們是平行的關系。

這種情況下,緩存的顆粒度控制也沒有那麼細緻。

類似這樣的思考,很多開發者也向Spring Framework研發團隊提過。

品味Spring Cache設計之美
品味Spring Cache設計之美

官方的回答也很明确:對于緩存抽象來講,它并不關心方法傳回的資料類型,假如是集合,那麼也就意味着需要把集合資料在緩存中儲存起來。

還有一位開發者,定義了一個@CollectionCacheable注解,并做出了原型,擴充了Spring Cache的清單緩存功能。

@Cacheable("myCache")
 public String findById(String id) {
 //access DB backend return item
 }
 @CollectionCacheable("myCache") 
 public Map<String, String> findByIds(Collection<String> ids) {
 //access DB backend,return map of id to item
 }
           

官方也未采納,因為緩存抽象并不想引入太多的複雜性。

寫到這裡,相信大家對緩存抽象有了更進一步的了解。當我們想實作更複雜的緩存功能時,需要對Spring Cache做一定程度的擴充。

4 自定義二級緩存

4.1 應用場景

筆者曾經在原來的項目,高并發場景下多次使用多級緩存。多級緩存是一個非常有趣的功能點,值得我們去擴充。

多級緩存有如下優勢:

  1. 離使用者越近,速度越快;
  2. 減少分布式緩存查詢頻率,降低序列化和反序列化的CPU消耗;
  3. 大幅度減少網絡IO以及帶寬消耗。

程序内緩存做為一級緩存,分布式緩存做為二級緩存,首先從一級緩存中查詢,若能查詢到資料則直接傳回,否則從二級緩存中查詢,若二級緩存中可以查詢到資料,則回填到一級緩存中,并傳回資料。若二級緩存也查詢不到,則從資料源中查詢,将結果分别回填到一級緩存,二級緩存中。

品味Spring Cache設計之美

Spring Cache并沒有二級緩存的實作,我們可以實作一個簡易的二級緩存DEMO,加深對技術的了解。

4.2 設計思路

  1. MultiLevelCacheManager:多級緩存管理器;
  2. MultiLevelChannel:封裝Caffeine和RedissonClient;
  3. MultiLevelCache:實作org.springframework.cache.Cache接口;
  4. MultiLevelCacheConfig:配置緩存過期時間等;

MultiLevelCacheManager是最核心的類,需要實作getCache和getCacheNames兩個接口。

品味Spring Cache設計之美

建立多級緩存,第一級緩存是:Caffeine , 第二級緩存是:Redisson。

品味Spring Cache設計之美

二級緩存,為了快速完成DEMO,我們使用Redisson對Spring Cache的擴充類RedissonCache 。它的底層是RMap,底層存儲是Hash。

品味Spring Cache設計之美

我們重點看下緩存的「查詢」和「存儲」的方法:

@Override
public ValueWrapper get(Object key) {
    Object result = getRawResult(key);
    return toValueWrapper(result);
}

public Object getRawResult(Object key) {
    logger.info("從一級緩存查詢key:" + key);
    Object result = localCache.getIfPresent(key);
    if (result != null) {
        return result;
    }
    logger.info("從二級緩存查詢key:" + key);
    result = RedissonCache.getNativeCache().get(key);
    if (result != null) {
        localCache.put(key, result);
    }
    return result;
}
           

「查詢」資料的流程:

  1. 先從本地緩存中查詢資料,若能查詢到,直接傳回;
  2. 本地緩存查詢不到資料,查詢分布式緩存,若可以查詢出來,回填到本地緩存,并傳回;
  3. 若分布式緩存查詢不到資料,則預設會執行被注解的方法。

下面來看下「存儲」的代碼:

public void put(Object key, Object value) {
    logger.info("寫入一級緩存 key:" + key);
    localCache.put(key, value);
    logger.info("寫入二級緩存 key:" + key);
    RedissonCache.put(key, value);
}
           

最後配置緩存管理器,原有的業務代碼不變。

品味Spring Cache設計之美

執行下getUserById方法,查詢使用者編号為1的使用者資訊。

- 從一級緩存查詢key:1
- 從二級緩存查詢key:1
- ==> Preparing: select * FROM user t where t.id = ? 
- ==> Parameters: 1(Long)
- <== Total: 1
- 寫入一級緩存 key:1
- 寫入二級緩存 key:1
           

第二次執行相同的動作,從日志可用看到從優先會從本地記憶體中查詢出結果。

- 從一級緩存查詢key:1
           

等待30s , 再執行一次,因為本地緩存會失效,是以執行的時候會查詢二級緩存

- 從一級緩存查詢key:1
- 從二級緩存查詢key:1
           

一個簡易的二級緩存就組裝完了。

5 什麼場景選擇Spring Cache

在做技術選型的時候,需要針對場景選擇不同的技術。

筆者認為Spring Cache的功能很強大,設計也非常優雅。特别适合緩存控制沒有那麼細緻的場景。比如門戶首頁,偏靜态展示頁面,榜單等等。這些場景的特點是對資料實時性沒有那麼嚴格的要求,隻需要将資料源緩存下來,過期之後自動重新整理即可。 這些場景下,Spring Cache就是神器,能大幅度提升研發效率。

但在高并發大資料量的場景下,精細的緩存顆粒度的控制上,還是需要做功能擴充。

  1. 多級緩存;
  2. 清單緩存;
  3. 緩存變更監聽器;

筆者也在思考這幾點的過程,研讀了 j2cache , jetcache相關源碼,受益匪淺。後續的文章會重點分享下筆者的心得。

如果我的文章對你有所幫助,還請幫忙點贊、在看、轉發一下,你的支援會激勵我輸出更高品質的文章,非常感謝!

品味Spring Cache設計之美