一直以來對快取都是一知半解,從沒有正經的接觸並使用一次,今天騰出時間研究一下快取技術,開發環境為OpenJDK17
與SpringBoot2.7.5
原始碼下載地址:https://hanzhe.lanzoue.com/iK4AF0hjl3lc
SpringCache基礎概念
介面介紹
首先看看SpringCache中提供的兩個主要介面,第一個是CacheManager
快取管理器介面,在介面名的位置按F4(IDEA Eclipse快捷鍵)
可檢視介面的實現,其中最底下的ConcurrentMapCacheManager
就是快取管理器預設實現,在不進行任何配置的情況下直接使用快取預設使用的就是基於Map集合的快取
在ConcurrentMapCacheManager
實現類中可以看到,該實現類主要維護Map型別的cacheMap
屬性,Value為Cache
型別的介面,點進該介面可以發現他同樣有基於Map的實現類ConcurrentMapCache
,開啟Debug除錯後簡單測試了一下,程式碼走到了這個位置也確定了使用的就是該類
Cache
介面就是就是第二個要了解的介面,梳理一下,CacheManager
為快取管理器並且管理著Cache
物件,而被管理的Cache
提供了操作快取資料的方法
註解介紹
上面介紹了SpringCache中兩個介面,這裡來了解一下快取需要的註解,開發中最常用的就是基於註解的快取
名稱 | 解釋 |
---|---|
@Cacheable | 將方法的返回結果進行快取,後續方法被呼叫直接返回快取中的資料不執行方法,適合查詢 |
@CachePut | 將方法的返回結果進行快取,無論快取中是否有資料都會執行方法並快取結果,適合更新 |
@CacheEvict | 刪除快取中的資料 |
@Caching | 組合使用快取註解 |
@CacheConfig | 統一配置本類的快取註解的屬性 |
@EnableCaching | 用於啟動類或者快取配置類,表示該專案開啟快取功能 |
前三個註解用於對快取資料進行增刪改查操作,@Caching
註解的作用就是將前三個註解組合使用,適用於有關聯關係的快取資料,@CacheConfig
則是針對本類中的快取做一些通用的配置
使用SpringCache
在寫這篇文章之前也參考過一些其他博主的文章,好多文章都要求引用spring-boot-starter-cache
啟動器,但據我測試不引用該啟動器也可以實現快取功能,相關的類和註解在spring-context
包中就已經存在了,我不太清楚為什麼他們要引用spring-boot-starter-cache
,如果您懂的話麻煩在評論區指點下
編寫測試環境
專案新增的依賴
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-all</artifactId>
<version>5.8.10</version>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</dependency>
</dependencies>
實體類
@Data
@NoArgsConstructor
@AllArgsConstructor
public class UserEntity implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
private Integer id;
private String name;
}
Mapper 並沒有查詢資料庫,而是模擬出來的假資料
@Repository
public class UserMapper {
public final List<UserEntity> users = CollUtil.newArrayList();
@PostConstruct
public void init() {
users.add(new UserEntity(1, "使用者" + 1));
users.add(new UserEntity(2, "使用者" + 2));
users.add(new UserEntity(3, "使用者" + 3));
users.add(new UserEntity(4, "使用者" + 4));
}
public List<UserEntity> list() {
return this.users;
}
public UserEntity getOne(Integer id) {
return this.users.stream()
.filter(user -> NumberUtil.equals(user.getId(), id))
.findFirst()
.orElse(null);
}
public void update(UserEntity entity) {
this.delete(entity.getId());
users.add(entity);
}
public void delete(Integer id) {
UserEntity entity = this.getOne(id);
if (ObjectUtil.isNotNull(entity)) {
users.remove(entity);
}
}
}
Service
@Slf4j
@Service
public class UserService {
@Autowired
private UserMapper mapper;
public List<UserEntity> selectList() {
List<UserEntity> list = mapper.list();
log.info("list:{}", list.size());
return list;
}
public UserEntity getOne(Integer id) {
log.info("getOne:{}", id);
return mapper.getOne(id);
}
public UserEntity update(UserEntity entity) {
log.info("update:{}", entity);
mapper.update(entity);
return entity;
}
public void delete(Integer id) {
log.info("delete:{}", id);
mapper.delete(id);
}
public void clear() {}
}
Controller
@RestController
@RequestMapping("/user")
public class UserController {
@Autowired
private UserService service;
@GetMapping("selectList")
public Object selectList() {
return success(service.selectList());
}
@GetMapping("getOne")
public Object getOne(Integer id) {
return success(service.getOne(id));
}
@GetMapping("update")
public Object update(UserEntity entity) {
service.update(entity);
return success();
}
@GetMapping("delete")
public Object delete(Integer id) {
service.delete(id);
return success();
}
@GetMapping("clear")
public Object clear() {
service.clear();
return success();
}
/* ---------工具方法 --------- */
public Map<String, Object> success(Object obj) {
Map<String, Object> result = success();
result.put("data", obj);
return result;
}
public Map<String, Object> success() {
Map<String, Object> result = MapUtil.newHashMap();
result.put("code", 200);
result.put("msg", "success");
return result;
}
}
最後在啟動類使用 @EnableCache 註解啟用快取
@EnableCaching
@SpringBootApplication
public class BootCacheApplication {
public static void main(String[] args) {
SpringApplication.run(BootCacheApplication.class, args);
}
}
測試環境的編寫到此位置,可以試著請求一下介面看看是否搭建成功,以及控制檯中是否有對應的日誌列印
使用註解快取
註解快取主要是@Cacheable、@CachePut、@CacheEvict這三種,且引數都基本相同,用過一次的引數基本就不重複說了,接下來測試在Service層中新增快取註解
@Cacheable
該註解適用於查詢方法,將查詢返回的結果放到快取中,下次接收到請求直接返回快取中的資料,先將註解按照下面的寫法加到程式碼中,然後呼叫看看效果
// @Cacheable(cacheNames = "USERS", key = "#root.methodName")
@Cacheable(cacheNames = "USERS", key = "'selectList'")
public List<UserEntity> selectList() { ... }
// @Cacheable(cacheNames = "USERS", key = "#id")
@Cacheable(cacheNames = "USERS", key = "#root.args[0]")
public UserEntity getOne(Integer id) { ... }
註解加上後反覆請求這兩個介面,可以發現相同的請求Service日誌只列印了一次,因為資料已經新增到快取不會在執行Service程式碼了
cacheNames
現在來看一下@Cacheable註解中的引數,首先來看cacheNames,該引數可以理解為一個組,cacheNames相同的快取會放到同一個Cache物件中進行管理,例如USERS中只維護與使用者相關的快取,DEPTHS中只維護部門相關的快取,就是ConcurrentMapCacheManager
中cacheMap的結構
key與SpEL表示式
第二個引數key代表的是快取資料在該組中的唯一標識,透過觀察Cache物件可以看出來
需要注意的是key的引數需要使用SpEL表示式,如果想直接使用字串作為key的話需要用單引號括起來
- #root.methodName:獲取方法名
- #id:獲取引數列表中的id屬性
- #root.args[0]:獲取引數列表中第一個引數
- #result:返回結果物件
- 更多用法詳見官方文件
condition
除了上面用到的兩個引數之外,這裡在介紹一個bool型別引數condition,該引數的作用是做條件判斷,只有判斷結果為true註解才會生效,現在修改一下Service中的註解,新增condition條件
/**
* 只有ID為1時才進行快取,其他資料直接執行Service程式碼
*/
@Cacheable(cacheNames = "USERS", key = "#root.args[0]", condition = "#id == 1")
public UserEntity getOne(Integer id) { ... }
unless
condition的作用是隻有判斷結果為true結果才生效,同時有個與他相對的unless註解,判斷結果為true時註解失效
/**
* 只有ID為1時不執行快取,每次都會執行Service程式碼
* 其他資料正常快取
*/
@Cacheable(cacheNames = "USERS", key = "#root.args[0]", unless = "#id == 1")
public UserEntity getOne(Integer id) { ... }
cacheManager
當系統中配置了多個快取實現的時候,可以在註解中傳入快取管理器的bean名稱來指定該快取使用哪個實現,如下圖所示,這裡就不演示了
sync 瞭解即可
在多執行緒的情況下快取資料可能會被重複操作多次,如果快取資料比較敏感可以使用sycn屬性將快取資料設定為多執行緒安全,不過一般很少有人會將敏感資料存放到快取中,所以sync預設為關閉狀態,也很少會有人開啟他,而且需要注意的是並不是所有快取實現類都可以實現該功能,目前可以肯定的是Spring官方給出的所有CacheMapper實現類都支援這個屬性,屬性並不常用圖省事兒這裡也就不演示了
keyGenerator 瞭解即可
修改快取key的生成規則,在註解中使用keyGenerator
引數後就不能在使用key
,快取key的生成規則由keyGenerator來決定,如果想對某個介面使用自定義規則key,需要向ioc容器中注入SimpleKeyGenerator型別的bean,然後將bean的名稱傳入keyGenerator
即可,如下圖所示,這裡就不演示了
@CachePut
在使用該註解之前先來做一個小測試,將getOne註解上的condition和unless條件清除,讀取ID為1的使用者資料,然後修改該使用者的資料,修改過後在讀取一次該使用者的資料,看看效果如何
可以發現雖然我修改了ID為1的使用者資料,但是查詢該資料時返回的仍是舊資料,這時就需要使用@CachePut註解更新快取資料,該註解會用方法的返回結果更新掉快取中的舊資料
/**
* 在getOne中快取資料的key為#root.args[0],代表的是引數列表中第一位,也就是使用者ID
* 那麼在update方法中用來更新快取資料的也應該是使用者ID,也就是返回結果中的id: #result.id
*/
@CachePut(cacheNames = "USERS", key = "#result.id")
public UserEntity update(UserEntity entity) { ... }
@CacheEvict
@CacheEvict註解的作用就是刪除快取中的舊資料,透過引數key來指定刪除的是哪一條,同時該註解還有allEntries引數,在不使用key的情況下設定allEntries為true可以清空該cacheNames下所有快取
@CacheEvict(cacheNames = "USERS", key = "#id")
public void delete(Integer id) { ... }
@CacheEvict(cacheNames = "USERS", allEntries = true)
public void clear() {}
這裡可以自行呼叫測試並檢視日誌列印情況,我就不上傳截圖了
@Caching
該註解的作用是組合其他註解使用,例如刪除了該使用者後,還需要刪除該使用者的登入資訊,就可以使用到該註解
@Caching(evict= {
@CacheEvict(cacheNames = "USERS", key = "#id"),
@CacheEvict(cacheNames = "TOKENS", key = "#id")
})
public void delete(Integer id) { ... }
@CacheConfig
@CacheConfig可以針對當前類的所有快取註解進行統一配置,例如之前在每個註解上都使用了cacheNames屬性,在使用了@CacheConfig註解後只需要在該類上標註cacheNames那麼類中的註解就可以省去該引數了
@Slf4j
@Service
@CacheConfig(cacheNames = "USERS")
public class UserService {
@Autowired
private UserMapper mapper;
@Cacheable(key = "'selectList'")
public List<UserEntity> selectList() { ... }
@Cacheable(key = "#root.args[0]")
public UserEntity getOne(Integer id) { ... }
@CachePut(key = "#result.id")
public UserEntity update(UserEntity entity) { ... }
@Caching(evict= {
@CacheEvict(key = "#id"),
@CacheEvict(cacheNames = "TOKENS", key = "#id")
})
public void delete(Integer id) { ... }
@CacheEvict(allEntries = true)
public void clear() {}
}
使用程式設計式快取
程式設計式快取,指在程式碼中操作快取資料,之前提到過SpringCache中有提供Cache介面,我們透過程式碼操作快取靠的就是這個介面,直接在Controller裡演示一下
@RestController
@RequestMapping("/user")
public class UserController {
@Autowired
private CacheManager cacheManager;
@GetMapping("/test")
public Object test() {
// 透過cacheManager獲取維護使用者快取的Cache物件
Cache cache = cacheManager.getCache("USERS");
// 向快取中新增資料
cache.put(8, new UserEntity(8, "使用者8"));
cache.put(9, new UserEntity(9, "使用者9"));
// 列印測試
System.out.println(cache.get(8, UserEntity.class));
System.out.println(cache.get(9, UserEntity.class));
// 移除其中一個快取資料
cache.evict(8);
// 列印測試
System.out.println(cache.get(8, UserEntity.class));
System.out.println(cache.get(9, UserEntity.class));
// 清空快取資料
cache.clear();
// 列印測試
System.out.println(cache.get(8, UserEntity.class));
System.out.println(cache.get(9, UserEntity.class));
return success();
}
// 省略多餘程式碼 .....
}
整合Redis為快取實現
整合Redis需要引用Redis的場景啟動器
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
新增Redis場景啟動器後重新整理Maven依賴,然後在回過頭來看CacheManager介面的實現類,會發現多了基於Redis的快取實現
之後在配置檔案中新增Redis的連線資訊,重啟專案就可以請求介面進行測試了
spring:
redis:
host: 192.168.1.34
port: 6379
password: redis
database: 1
修改配置檔案
我們可以透過配置檔案來對快取進行一些設定,找到快取的自動配置類CacheAutoConfiguration
可以看到類中啟用了CacheProperties
在CacheProperties
類可以知道配置檔案中可以設定哪些屬性,例如指定使用Redis作為快取實現,不過當我們引入Redis場景啟動器後,快取的預設實現已經被設定為Redis,所以這個設定也可以忽略
spring:
cache:
type: redis
指定cacheNames集
在快取註解中使用的cacheNames
的作用是對快取資料進行分組,當快取中沒有這個組的時候會自動建立這個組,同時該屬性可以在配置檔案中設定,不過需要注意的是一旦在配置檔案中指定cacheNames,那麼快取註解將不再提供自動建立的功能,使用不存在的cacheNames會報錯
spring:
cache:
type: redis
cache-names:
- USERS
- DEPT
- ...
Redis配置項
配置項一個個介紹比較麻煩,這裡直接將Redis的配置項列出來,配置項對應CacheProperties.redis
屬性
spring:
cache:
# 指定Redis作為快取實現
type: redis
# 指定專案中的cacheNames
cache-names:
- USERS
redis:
# 快取過期時間為10分鐘,單位為毫秒
time-to-live: 600000
# 是否允許快取空資料,當查詢到的結果為空時快取空資料到redis中
cache-null-values: true
# 為Redis的KEY拼接字首
key-prefix: "BOOT_CACHE:"
# 是否拼接KEY字首
use-key-prefix: true
# 是否開啟快取統計
enable-statistics: false
修改Redis快取為JSON
快取功能已經實現,但是根據之前的測試來看,存到Redis中的資料是一堆亂碼不利於檢視和維護,這裡修改下Redis快取的序列化
原始碼分析
原始碼分析部分比較枯燥,可跳過
在快取的自動配置類CacheAutoConfiguration
中可以看到使用@Import
註解引用了CacheConfigurationImportSelector
類,該類實現了ImportSelector
介面,可以動態的向ioc容器中注入指定的bean
而在CacheConfigurationImportSelector
類中遍歷了CacheType列舉,這個CacheType正對應著一開始在配置檔案中所寫的spring.cache.type=redis
,在程式碼結束的位置也看到了Redis的快取配置類RedisCacheConfiguration
這裡挑重點直接看RedisCacheConfiguration
配置類,在該配置類中使用@Bean向ioc容器中新增了CacheManager的實現,被Bean標註的方法引數列表預設都是可以在ioc容器中找到的,而這個引數列表中包含RedisCacheConfiguration
需要注意當前所在的位置是autoconfigure.cache
包,而引數列表中的RedisCacheConfiguration類是data.redis.cache
包下的,這是兩個不同的類
這裡順著該物件往下看呼叫,先是呼叫了determineConfiguration
,而後呼叫了createConfiguration
,在createConfiguration
可以看出Redis預設使用的是JDK的序列化實現
回過頭來看一眼,引數列表中好像並不是RedisCacheConfiguration物件,而是RedisCacheConfiguration型別的ObjectProvider
物件,這裡解釋一下ObjectProvider
是物件提供者,他會優先取ioc容器中該型別的Bean,如果沒有就使用自己的物件,具體可以看determineConfiguration
方法
這樣一來事情思路就理清了,只要使用ObjectProvider
的特點,自己向IOC容器中提供一個RedisCacheConfiguration
物件就可以覆蓋掉原本的配置了
功能實現程式碼
先建立個Redis快取配置類,編寫一個@Bean的方法返回RedisCacheConfiguration
物件,為了防止配置檔案中的配置項失效,這裡直接將上面的createConfiguration
方法體複製過來,將CacheProperties
放到引數列表中,他會自己去ioc容器中取,另一個引數是JDK序列化用到的,這裡用不上就不拿過來了
然後將程式碼中的JDK序列化刪掉,透過Ctrl + P(IDEA Eclipse快捷鍵)
可以看到他需要RedisSerializer
型別的序列化物件
點開這個介面檢視他的實現類,發現Redis提供了兩個JSON序列化物件
下面的是帶有泛型的序列化器,這裡推薦通用的GenericJackson2JsonRedisSerializer
,該類有空參構造直接new物件即可,替換掉JDK序列化
@Configuration
public class CustomRedisConfig {
@Bean
public RedisCacheConfiguration redisCacheConfiguration(CacheProperties cacheProperties) {
// 獲取Redis配置資訊
CacheProperties.Redis redisProperties = cacheProperties.getRedis();
// 獲取Redis預設配置
RedisCacheConfiguration config = RedisCacheConfiguration.defaultCacheConfig();
// 指定序列化器為GenericJackson2JsonRedisSerializer
config = config.serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(new GenericJackson2JsonRedisSerializer()));
// 過期時間設定
if (redisProperties.getTimeToLive() != null) {
config = config.entryTtl(redisProperties.getTimeToLive());
}
// KEY字首配置
if (redisProperties.getKeyPrefix() != null) {
config = config.prefixCacheNameWith(redisProperties.getKeyPrefix());
}
// 快取空值配置
if (!redisProperties.isCacheNullValues()) {
config = config.disableCachingNullValues();
}
// 是否啟用字首
if (!redisProperties.isUseKeyPrefix()) {
config = config.disableKeyPrefix();
}
return config;
}
}
這樣一來JSON序列化功能就完成了,可以重啟檢查一下效果,由於反序列化的需要,JSON的每個物件中都新增了class資訊
修改字首生成規則
Redis的KEY一般用:
來區分層級,這是個約定俗成的習慣,我使用的Redis視覺化工具也會基於:
將key放在不同資料夾下進行分組展示,可是RedisCache生成的KEY中間有個雙冒號,導致視覺化介面中有一層資料夾是空的,強迫症表示難以接受,這個問題必須解決
字首的生成靠的是CacheKeyPrefix
,點開這個類可以看到他將雙冒號直接寫死在程式碼中了,我們需要自定義介面去繼承他,然後代替他
public interface CustomKeyPrefix extends CacheKeyPrefix {
String SEPARATOR = ":";
String compute(String cacheName);
static CustomKeyPrefix simple() {
return (name) -> name + SEPARATOR;
}
static CustomKeyPrefix prefixed(String prefix) {
Assert.notNull(prefix, "Prefix must not be null!");
return (name) -> prefix + name + SEPARATOR;
}
}
回到剛剛建立的Redis快取配置類中,
@Configuration
public class CustomRedisConfig {
@Bean
public RedisCacheConfiguration redisCacheConfiguration(CacheProperties cacheProperties) {
// 獲取Redis配置資訊
CacheProperties.Redis redisProperties = cacheProperties.getRedis();
// 獲取Redis預設配置
RedisCacheConfiguration config = RedisCacheConfiguration.defaultCacheConfig();
// 指定序列化器為GenericJackson2JsonRedisSerializer
config = config.serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(new GenericJackson2JsonRedisSerializer()));
// 過期時間設定
if (redisProperties.getTimeToLive() != null) {
config = config.entryTtl(redisProperties.getTimeToLive());
}
// 替換字首生成器(有字首和無字首)
config = config.computePrefixWith(CustomKeyPrefix.simple());
if (redisProperties.getKeyPrefix() != null) {
config = config.computePrefixWith(CustomKeyPrefix.prefixed(redisProperties.getKeyPrefix()));
}
// 快取空值配置
if (!redisProperties.isCacheNullValues()) {
config = config.disableCachingNullValues();
}
// 是否啟用字首
if (!redisProperties.isUseKeyPrefix()) {
config = config.disableKeyPrefix();
}
return config;
}
}
問題結局,可以重啟後檢查一下效果,非常滴完美