最近負責教育類產品的架構工作,兩位研發同學建議:“團隊封裝的Redis客戶端可否適配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);
}
}
相信很多同學都寫過類似風格的程式碼,這種風格符合程式導向的程式設計思維,非常容易理解。但它也有一些缺點:
-
程式碼不夠優雅。業務邏輯有四個典型動作:儲存,讀取,修改,刪除。每次操作都需要定義快取Key ,呼叫快取命令的API,產生較多的重複程式碼;
-
快取操作和業務邏輯之間的程式碼耦合度高,對業務邏輯有較強的侵入性。
侵入性主要體現如下兩點:
- 開發聯調階段,需要去掉快取,只能註釋或者臨時刪除快取操作程式碼,也容易出錯;
- 某些場景下,需要更換快取元件,每個快取元件有自己的API,更換成本頗高。
2 快取抽象
首先需要明確一點:Spring Cache不是一個具體的快取實現方案,而是一個對快取使用的抽象(Cache Abstraction)。
2.1 Spring AOP
Spring AOP是基於代理模式(proxy-based)。
通常情況下,定義一個物件,呼叫它的方法的時候,方法是直接被呼叫的。
Pojo pojo = new SimplePojo();
pojo.foo();
將程式碼做一些調整,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();
呼叫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 ,預設的生成器程式碼見下圖:
它的演算法也很容易理解:
- 如果沒有引數,則直接返回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是一個對快取使用的抽象,它提供了多種儲存整合。
要使用它們,需要簡單地宣告一個適當的CacheManager
- 一個控制和管理Cache
的實體。
我們以Spring Cache預設的快取實現Simple例子,簡單探索下CacheManager的機制。
CacheManager非常簡單:
public interface CacheManager {
@Nullable
Cache getCache(String name);
Collection<String> getCacheNames();
}
在CacheConfigurations配置類中,可以看到不同整合型別有不同的快取配置類。
通過SpringBoot的自動裝配機制,建立CacheManager的實現類ConcurrentMapCacheManager
。
而ConcurrentMapCacheManager
的getCache方法,會建立ConcurrentCacheMap
。
ConcurrentCacheMap
實現了org.springframework.cache.Cache
介面。
從Spring Cache的Simple的實現,快取配置需要實現兩個介面:
-
org.springframework.cache.CacheManager
-
org.springframework.cache.Cache
3 入門例子
首先我們先建立一個工程spring-cache-demo。
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資料結構。
因為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方法 ,控制檯就變成:
可以觀察到:快取key已經變成了:["java.lang.Long",1],改變序列化後key和value已發生了變化。
3.3 從列表快取再次理解快取抽象
列表快取在業務中經常會遇到。通常有兩種實現形式:
- 整體列表快取;
- 按照每個條目快取,通過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 Framework研發團隊提過。
官方的回答也很明確:對於快取抽象來講,它並不關心方法返回的資料型別,假如是集合,那麼也就意味著需要把集合資料在快取中儲存起來。
還有一位開發者,定義了一個@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 應用場景
筆者曾經在原來的專案,高併發場景下多次使用多級快取。多級快取是一個非常有趣的功能點,值得我們去擴充套件。
多級快取有如下優勢:
- 離使用者越近,速度越快;
- 減少分散式快取查詢頻率,降低序列化和反序列化的CPU消耗;
- 大幅度減少網路IO以及頻寬消耗。
程式內快取做為一級快取,分散式快取做為二級快取,首先從一級快取中查詢,若能查詢到資料則直接返回,否則從二級快取中查詢,若二級快取中可以查詢到資料,則回填到一級快取中,並返回資料。若二級快取也查詢不到,則從資料來源中查詢,將結果分別回填到一級快取,二級快取中。
Spring Cache並沒有二級快取的實現,我們可以實現一個簡易的二級快取DEMO,加深對技術的理解。
4.2 設計思路
- MultiLevelCacheManager:多級快取管理器;
- MultiLevelChannel:封裝Caffeine和RedissonClient;
- MultiLevelCache:實現org.springframework.cache.Cache介面;
- MultiLevelCacheConfig:配置快取過期時間等;
MultiLevelCacheManager是最核心的類,需要實現getCache和getCacheNames兩個介面。
建立多級快取,第一級快取是:Caffeine , 第二級快取是:Redisson。
二級快取,為了快速完成DEMO,我們使用Redisson對Spring Cache的擴充套件類RedissonCache 。它的底層是RMap,底層儲存是Hash。
我們重點看下快取的「查詢」和「儲存」的方法:
@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;
}
「查詢」資料的流程:
- 先從本地快取中查詢資料,若能查詢到,直接返回;
- 本地快取查詢不到資料,查詢分散式快取,若可以查詢出來,回填到本地快取,並返回;
- 若分散式快取查詢不到資料,則預設會執行被註解的方法。
下面來看下「儲存」的程式碼:
public void put(Object key, Object value) {
logger.info("寫入一級快取 key:" + key);
localCache.put(key, value);
logger.info("寫入二級快取 key:" + key);
RedissonCache.put(key, value);
}
最後配置快取管理器,原有的業務程式碼不變。
執行下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就是神器,能大幅度提升研發效率。
但在高併發大資料量的場景下,精細的快取顆粒度的控制上,還是需要做功能擴充套件。
- 多級快取;
- 列表快取;
- 快取變更監聽器;
筆者也在思考這幾點的過程,研讀了 j2cache , jetcache相關原始碼,受益匪淺。後續的文章會重點分享下筆者的心得。
如果我的文章對你有所幫助,還請幫忙點贊、在看、轉發一下,你的支援會激勵我輸出更高質量的文章,非常感謝!