使用 Spring Cache 實現緩存,這種方式才叫優雅!
兄弟們,咱們做 Java 開發的,誰沒踩過性能的坑啊?前陣子我朋友小楊就跟我吐槽,說他寫的用戶查詢接口,本地測試的時候唰唰快,一上生產環境,用戶量稍微一上來,數據庫直接被干到 CPU 100%,日志里全是紅色的慢查詢警告。老板在旁邊盯著,他手忙腳亂地改代碼,最后沒辦法,手動寫了個 HashMap 當緩存應急 。 結果嘛,你懂的,多實例部署的時候緩存不一致,又加班到半夜。
其實這種事兒真不用這么狼狽,Spring 早就給咱們準備好了解決方案 ——Spring Cache。這玩意兒就像個 “緩存管家”,你不用手動寫代碼操作 Redis、Ehcache 這些中間件,只要給方法貼個注解,它就幫你把 “查緩存→有就返回→沒有查庫→存緩存” 這一套流程全搞定。今天咱們就好好聊聊,怎么用 Spring Cache 實現優雅的緩存,讓你從此告別 “手動緩存火葬場”。
一、先搞明白:Spring Cache 到底是個啥?
可能有人會說,“緩存不就是存數據嘛,我自己寫個工具類調用 Redis 也能搞定”。這話沒毛病,但你想想,要是每個查詢方法都寫一遍 “查緩存、存緩存” 的邏輯,代碼得有多冗余?而且萬一以后要換緩存中間件,從 Redis 換成 Memcached,不得每個方法都改一遍?
Spring Cache 的核心思路就是 “解耦”—— 把緩存邏輯和業務邏輯分開。它基于 AOP(面向切面編程)實現,當你調用一個加了緩存注解的方法時,Spring 會先攔截這個調用,幫你處理緩存相關的操作,業務代碼里完全不用管緩存的事兒。
打個比方,你就像餐廳里的廚師(負責業務邏輯),Spring Cache 就是服務員(負責緩存)。客人要一份宮保雞丁(調用方法),服務員會先去備餐區看看有沒有做好的(查緩存),有就直接端給客人;沒有就告訴廚師做一份(執行業務邏輯),做好后再把一份放進備餐區(存緩存),下次客人再要就不用麻煩廚師了。你看,廚師全程不用管備餐區的事兒,專心做菜就行 —— 這就是優雅!
而且 Spring Cache 是 “抽象層”,它不關心底層用的是 Redis 還是 Ehcache,你只要配置好對應的 “緩存管理器”,就能無縫切換。比如開發環境用內存緩存(ConcurrentMapCache)方便測試,生產環境換成 Redis,業務代碼一行都不用改 —— 這波操作誰看了不夸一句?
二、快速上手:3 步搞定 Spring Cache 基礎使用
光說不練假把式,咱們先從最基礎的例子開始,用 Spring Boot+Spring Cache+Redis 實現一個用戶查詢的緩存功能。別擔心,步驟很簡單,跟著做就行。
第一步:搭環境(引入依賴)
首先得有個 Spring Boot 項目,然后在 pom.xml 里加兩個關鍵依賴:Spring Cache 的起步依賴,還有 Redis 的起步依賴(畢竟生產環境大多用 Redis)。
<!-- Spring Cache 起步依賴 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-cache</artifactId>
</dependency>
<!-- Redis 起步依賴(底層用 lettuce 客戶端) -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<!-- 要是用Jackson序列化,再加個這個(后面會講為啥需要) -->
<dependency>
<groupId>com.fasterxml.jackson.datatype</groupId>
<artifactId>jackson-datatype-jsr310</artifactId>
</dependency>如果是 Gradle 項目,對應的依賴也差不多,這里就不贅述了。記住,Spring Boot 2.x 和 3.x 的依賴坐標基本一致,不用糾結版本問題(除非你用的是特別老的 2.0 之前的版本,那得升級了兄弟)。
第二步:開開關(加注解啟用緩存)
在 Spring Boot 的啟動類上,加個@EnableCaching注解,告訴 Spring “我要啟用緩存功能啦”。就像你開空調之前要按一下電源鍵一樣簡單。
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cache.annotation.EnableCaching;
@SpringBootApplication
@EnableCaching // 關鍵:啟用Spring Cache
public class UserServiceApplication {
public static void main(String[] args) {
SpringApplication.run(UserServiceApplication.class, args);
}
}第三步:寫業務(給方法貼緩存注解)
接下來就是核心了 —— 給業務方法加緩存注解。咱們先定義一個 User 實體類,再寫個 UserService,里面有個根據 id 查詢用戶的方法,給這個方法加@Cacheable注解。
先看 User 實體類(注意要實現 Serializable,后面講序列化會用到):
import lombok.Data;
import java.io.Serializable;
import java.time.LocalDateTime;
@Data // lombok注解,省掉getter、setter
public class User implements Serializable { // 實現Serializable,Redis序列化需要
private Long id;
private String username;
private String phone;
private LocalDateTime createTime;
}然后是 UserService,這里咱們模擬數據庫查詢(用 Thread.sleep 模擬慢查詢,突出緩存的作用):
import org.springframework.cache.annotation.Cacheable;
import org.springframework.stereotype.Service;
import java.time.LocalDateTime;
import java.util.concurrent.TimeUnit;
@Service
public class UserService {
// 模擬數據庫查詢:根據id查用戶
// @Cacheable:表示這個方法的結果要被緩存
// value:緩存的“命名空間”,相當于給緩存分個組,避免key沖突
// key:緩存的key,這里用SpEL表達式,取方法參數id的值
@Cacheable(value = "userCache", key = "#id")
public User getUserById(Long id) {
// 模擬數據庫查詢的耗時操作(比如查MySQL)
try {
TimeUnit.SECONDS.sleep(2); // 睡2秒,模擬慢查詢
} catch (InterruptedException e) {
e.printStackTrace();
}
// 模擬從數據庫查出來的數據
User user = new User();
user.setId(id);
user.setUsername("用戶" + id);
user.setPhone("1380013800" + (id % 10));
user.setCreateTime(LocalDateTime.now());
return user;
}
}最后寫個 Controller 測試一下:
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class UserController {
@Autowired
private UserService userService;
@GetMapping("/user/{id}")
public User getUser(@PathVariable Long id) {
long start = System.currentTimeMillis();
User user = userService.getUserById(id);
long end = System.currentTimeMillis();
System.out.println("查詢耗時:" + (end - start) + "毫秒");
return user;
}
}現在啟動項目,用 Postman 或者瀏覽器訪問http://localhost:8080/user/1:
第一次訪問:控制臺會打印 “查詢耗時:約 2000 毫秒”,因為要走數據庫查詢,然后把結果存到 Redis。
第二次訪問:控制臺直接打印 “查詢耗時:約 10 毫秒”,因為直接從 Redis 拿緩存了!
你看,就加了個@Cacheable注解,緩存就生效了,業務代碼里完全沒寫任何 Redis 相關的邏輯 —— 這難道不優雅嗎?比你手動寫redisTemplate.opsForValue().get()、redisTemplate.opsForValue().set()清爽多了吧?
三、核心注解:這 5 個注解搞定 90% 的緩存場景
剛才用了@Cacheable,但 Spring Cache 的本事可不止這一個。它總共提供了 5 個核心注解,覆蓋了 “查、增、改、刪” 所有緩存操作。咱們一個個講,結合實際業務場景,保證你一看就懂。
1. @Cacheable:查緩存,有則用,無則存
這是最常用的注解,作用是 “查詢緩存”:調用方法前先查緩存,如果緩存存在,直接返回緩存的值,不執行方法;如果緩存不存在,執行方法,把結果存到緩存里。
剛才的例子已經用過了,這里再補充幾個關鍵屬性,都是實戰中必用的:
屬性 | 作用 | 例子 |
value | 緩存命名空間(必填),可以理解為緩存的 “文件夾”,避免 key 沖突 | value = "userCache" |
key | 緩存的 key(可選),用 SpEL 表達式,默認是所有參數的組合 | key = "#id"(取參數 id)、key = "#user.id"(取對象參數的 id) |
condition | 緩存的 “前置條件”(可選),滿足條件才緩存,SpEL 表達式返回 boolean | condition = "#id > 100"(只有 id>100 才緩存) |
unless | 緩存的 “排除條件”(可選),方法執行后判斷,滿足則不緩存 | unless = "#result == null"(結果為 null 不緩存) |
cacheManager | 指定用哪個緩存管理器(可選),比如有的方法用 Redis,有的用 Ehcache | cacheManager = "redisCacheManager" |
舉個帶條件的例子,比如 “只緩存 id 大于 100 的用戶,并且結果不為 null”:
@Cacheable(
value = "userCache",
key = "#id",
condition = "#id > 100", // 前置條件:id>100才查緩存/存緩存
unless = "#result == null" // 排除條件:結果為null不存緩存
)
public User getUserById(Long id) {
// 業務邏輯不變...
}這里要注意condition和unless的區別:condition是在方法執行前判斷的,如果不滿足,連緩存都不查,直接執行方法;unless是在方法執行后判斷的,不管怎么樣都會執行方法,只是結果不存緩存。別搞混了哈!
2. @CachePut:更新緩存,先執行方法再存緩存
比如你更新用戶信息的時候,得把緩存里的舊數據更成新的吧?這時候就用@CachePut。它的邏輯是:先執行方法(不管緩存有沒有),然后把方法的結果存到緩存里(覆蓋舊的緩存,如果有的話)。
舉個更新用戶的例子:
// @CachePut:更新緩存,執行方法后把結果存到緩存
// key和查詢方法保持一致,都是#user.id,這樣才能覆蓋舊緩存
@CachePut(value = "userCache", key = "#user.id")
public User updateUser(User user) {
// 模擬更新數據庫(這里省略JDBC/MyBatis代碼)
try {
TimeUnit.SECONDS.sleep(1); // 模擬耗時
} catch (InterruptedException e) {
e.printStackTrace();
}
// 模擬更新后的用戶數據(實際應該從數據庫查最新的)
user.setCreateTime(LocalDateTime.now()); // 更新時間
return user;
}比如你先查了 user.id=1(緩存里存了舊數據),然后調用 updateUser 更新 user.id=1 的信息,@CachePut會先執行更新邏輯,再把新的 user 對象存到緩存里,覆蓋原來的舊緩存。下次再查 user.id=1,拿到的就是最新的數據了 —— 完美解決緩存和數據庫不一致的問題。
3. @CacheEvict:刪除緩存,執行方法后清緩存
當你刪除用戶的時候,緩存里的舊數據也得刪掉吧?不然別人還能查到已經刪除的用戶,這就出 bug 了。@CacheEvict就是干這個的,它的邏輯是:執行方法(比如刪除數據庫記錄),然后刪除對應的緩存。
舉個刪除用戶的例子:
// @CacheEvict:刪除緩存,執行方法后刪除指定緩存
@CacheEvict(value = "userCache", key = "#id")
public void deleteUser(Long id) {
// 模擬刪除數據庫記錄
try {
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("用戶" + id + "已從數據庫刪除");
}還有個常用的屬性allEntries,比如你更新了用戶列表,想把 “userCache” 這個命名空間下的所有緩存都刪掉(避免列表緩存和數據庫不一致),就可以設allEntries = true:
// allEntries = true:刪除value="userCache"下的所有緩存
@CacheEvict(value = "userCache", allEntries = true)
public void batchUpdateUser(List<User> userList) {
// 批量更新數據庫邏輯...
}另外還有個beforeInvocation屬性,默認是false(方法執行后刪緩存),如果設為true,會在方法執行前刪緩存。什么時候用呢?比如方法執行可能會拋異常,你又希望不管成功失敗都刪緩存,就可以用這個。不過一般情況下用默認的false就行,避免方法執行失敗了,緩存卻被刪了,導致下次查詢穿透到數據庫。
4. @Caching:組合注解,一次搞定多個緩存操作
有時候一個方法需要同時做多個緩存操作,比如 “更新用戶信息后,既要更新用戶的緩存,又要刪除用戶列表的緩存”,這時候單個注解就不夠用了,得用@Caching來組合。
@Caching里可以包含cacheable、put、evict三個屬性,每個屬性都是一個數組,可以放多個對應的注解。
舉個實戰例子:更新用戶信息后,更新用戶緩存(@CachePut),同時刪除用戶列表的緩存(@CacheEvict):
// @Caching:組合多個緩存操作
@Caching(
put = {
// 更新用戶緩存(key是用戶id)
@CachePut(value = "userCache", key = "#user.id")
},
evict = {
// 刪除用戶列表緩存(假設列表緩存的key是"userList")
@CacheEvict(value = "userListCache", key = "'userList'")
}
)
public User updateUserAndClearListCache(User user) {
// 更新數據庫邏輯...
user.setCreateTime(LocalDateTime.now());
returnuser;
}這樣一來,調用這個方法的時候,Spring 會同時執行@CachePut和@CacheEvict兩個操作,既更新了單個用戶的緩存,又清空了列表緩存 —— 不用寫兩個方法,也不用手動操作緩存,太方便了!
5. @CacheConfig:類級注解,統一配置緩存屬性
如果一個 Service 里的所有方法都用同一個value(緩存命名空間)或者cacheManager,那每個方法都寫一遍豈不是很麻煩?@CacheConfig就是用來解決這個問題的,它是類級別的注解,可以統一配置當前類所有緩存方法的公共屬性。
比如 UserService 里的所有方法都用value = "userCache",就可以這么寫:
import org.springframework.cache.annotation.CacheConfig;
importorg.springframework.cache.annotation.Cacheable;
importorg.springframework.stereotype.Service;
@Service
// @CacheConfig:統一配置當前類的緩存屬性
@CacheConfig(value = "userCache")
publicclassUserService {
// 不用再寫value="userCache"了,繼承類上的配置
@Cacheable(key = "#id")
public User getUserById(Long id) {
// 邏輯...
}
// 同樣不用寫value,key還是要寫(因為每個方法的key可能不一樣)
@CachePut(key = "#user.id")
public User updateUser(User user) {
// 邏輯...
}
// 也不用寫value
@CacheEvict(key = "#id")
public void deleteUser(Long id) {
// 邏輯...
}
}注意哈,@CacheConfig只能配置公共屬性,像key這種每個方法可能不一樣的屬性,還是得在方法上單獨寫。而且方法上的配置會覆蓋類上的配置,比如你在方法上寫了value = "otherCache",就會覆蓋@CacheConfig里的value = "userCache"—— 這個優先級要記清楚。
四、進階配置:從 “能用” 到 “好用”,這些配置不能少
剛才的例子用的是默認配置,但生產環境里肯定不夠用。比如默認的 Redis 緩存序列化方式是 JDK 序列化,存到 Redis 里的是一堆亂碼;默認沒有緩存過期時間,數據會一直存在 Redis 里,占內存;還有不同的業務可能需要不同的緩存策略 —— 這些都得靠自定義配置來解決。
咱們一步步來,把 Spring Cache 配置得 “好用” 起來。
1. 解決 Redis 緩存亂碼:自定義序列化方式
先看個坑:剛才的例子里,你用 Redis 客戶端(比如 Redis Desktop Manager)查看緩存,會發現 key 是userCache::1,但 value 是一堆亂碼,根本看不懂。這是因為 Spring Cache 默認用的是JdkSerializationRedisSerializer,這種序列化方式雖然能用,但可讀性太差,而且占空間。
解決辦法很簡單:把序列化方式換成Jackson2JsonRedisSerializer,這樣存到 Redis 里的是 JSON 格式,又好懂又省空間。
寫個 Redis 緩存配置類:
import org.springframework.cache.CacheManager;
import org.springframework.cache.annotation.EnableCaching;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.cache.RedisCacheConfiguration;
import org.springframework.data.redis.cache.RedisCacheManager;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.serializer.*;
import java.time.Duration;
import java.util.HashMap;
import java.util.Map;
@Configuration
@EnableCaching// 這里加也行,啟動類加也行,只要加一次
publicclass RedisCacheConfig {
// 自定義Redis緩存管理器
@Bean
public CacheManager redisCacheManager(RedisConnectionFactory redisConnectionFactory) {
// 1. 配置序列化方式
// Key的序列化:用StringRedisSerializer,key是字符串
RedisSerializer<String> keySerializer = new StringRedisSerializer();
// Value的序列化:用Jackson2JsonRedisSerializer,轉成JSON
Jackson2JsonRedisSerializer<Object> valueSerializer = new Jackson2JsonRedisSerializer<>(Object.class);
// 配置Jackson,解決LocalDateTime等Java 8時間類型序列化問題
com.fasterxml.jackson.databind.ObjectMapper objectMapper = new com.fasterxml.jackson.databind.ObjectMapper();
// 開啟類型信息,反序列化時能知道對象的類型(避免List等集合反序列化出錯)
objectMapper.activateDefaultTyping(
com.fasterxml.jackson.databind.ObjectMapper.DefaultTyping.NON_FINAL,
com.fasterxml.jackson.databind.jsontype.TypeSerializer.DefaultImpl.NON_FINAL
);
// 支持Java 8時間類型(LocalDateTime、LocalDate等)
objectMapper.registerModule(new com.fasterxml.jackson.datatype.jsr310.JavaTimeModule());
valueSerializer.setObjectMapper(objectMapper);
// 2. 配置默認的緩存規則(比如默認過期時間30分鐘)
RedisCacheConfiguration defaultCacheConfig = RedisCacheConfiguration.defaultCacheConfig()
.entryTtl(Duration.ofMinutes(30)) // 默認過期時間30分鐘
.serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(keySerializer)) // 序列化key
.serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(valueSerializer)) // 序列化value
.disableCachingNullValues(); // 禁止緩存null值(可選,看業務需求)
// 3. 配置不同緩存命名空間的個性化規則(比如userCache過期1小時,userListCache過期10分鐘)
Map<String, RedisCacheConfiguration> cacheConfigurations = new HashMap<>();
cacheConfigurations.put("userCache", defaultCacheConfig.entryTtl(Duration.ofHours(1))); // userCache過期1小時
cacheConfigurations.put("userListCache", defaultCacheConfig.entryTtl(Duration.ofMinutes(10))); // userListCache過期10分鐘
// 4. 創建緩存管理器
return RedisCacheManager.builder(redisConnectionFactory)
.cacheDefaults(defaultCacheConfig) // 默認規則
.withInitialCacheConfigurations(cacheConfigurations) // 個性化規則
.build();
}
}再在 application.yml 里配置 Redis 的連接信息(不然連不上 Redis):
spring:
redis:
host: localhost # Redis地址,生產環境填實際地址
port: 6379 # Redis端口
password: # Redis密碼,沒有就空著
database: 0 # Redis數據庫索引(默認0)
lettuce: # Spring Boot 2.x默認用lettuce客戶端,比jedis好
pool:
max-active: 8 # 最大連接數
max-idle: 8 # 最大空閑連接數
min-idle: 2 # 最小空閑連接數
max-wait: 1000ms # 連接池最大阻塞等待時間現在再啟動項目,調用 getUserById (1),去 Redis 里看:
- key 還是userCache::1,沒變。
- value 變成了 JSON 格式:{"@class":"com.example.demo.entity.User","id":1,"username":"用戶1","phone":"13800138001","createTime":["java.time.LocalDateTime",["2024-05-20T15:30:45.123"]]}—— 雖然多了點類型信息,但至少能看懂了,而且 Java 8 的 LocalDateTime 也能正常序列化 / 反序列化了。
這個配置解決了兩個大問題:緩存亂碼和時間類型序列化失敗,生產環境必備!
2. 自定義 Key 生成策略:不用再手動寫 key
剛才的例子里,每個方法都要寫key = "#id"、key = "#user.id",要是方法參數多了,key 寫起來很麻煩,還容易出錯。Spring Cache 允許我們自定義 Key 生成策略,以后不用再手動寫 key 了。
比如我們定義一個 “Key 生成器”:key 由 “方法名 + 參數值” 組成,這樣能保證唯一性。
寫個自定義 KeyGenerator:
import org.springframework.cache.interceptor.KeyGenerator;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import java.lang.reflect.Method;
import java.util.Arrays;
@Configuration
publicclass CustomKeyGeneratorConfig {
// 自定義Key生成器,Bean名稱是customKeyGenerator
@Bean("customKeyGenerator")
public KeyGenerator customKeyGenerator() {
returnnew KeyGenerator() {
@Override
publicObject generate(Object target, Method method, Object... params) {
// 生成規則:方法名 + 參數列表(比如 getUserById[1])
String key = method.getName() + "[" + Arrays.toString(params) + "]";
System.out.println("生成的緩存key:" + key);
return key;
}
};
}
}然后在方法上用keyGenerator屬性指定用這個生成器,不用再寫key了:
@Service
@CacheConfig(value = "userCache", keyGenerator = "customKeyGenerator") // 類級配置,指定Key生成器
public class UserService {
// 不用寫key了,Key生成器會自動生成:getUserById[1]
@Cacheable
public User getUserById(Long id) {
// 邏輯...
}
// 自動生成key:updateUser[User(id=1, username=xxx, ...)]
@CachePut
public User updateUser(User user) {
// 邏輯...
}
}這樣一來,不管方法有多少個參數,Key 生成器都會自動生成唯一的 key,再也不用手動寫復雜的 SpEL 表達式了。當然,你也可以根據自己的業務需求修改生成規則,比如加上類名(避免不同 Service 的方法名重復導致 key 沖突),靈活得很。不過要注意:key和keyGenerator不能同時用,用了一個就不能用另一個,不然會報錯 —— 這個坑別踩。
3. 多緩存管理器:Redis 和 Ehcache 按需切換
有時候項目里可能需要用多種緩存,比如本地緩存用 Ehcache(快),分布式緩存用 Redis(多實例共享)。Spring Cache 支持配置多個緩存管理器,然后在方法上指定用哪個。
比如我們再配置一個 Ehcache 的緩存管理器:
首先加 Ehcache 的依賴:
<!-- Ehcache 依賴 -->
<dependency>
<groupId>org.ehcache</groupId>
<artifactId>ehcache</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-cache</artifactId>
</dependency>然后寫 Ehcache 的配置文件(src/main/resources/ehcache.xml):
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="http://www.ehcache.org/schema/ehcache-core.xsd"
updateCheck="false">
<!-- 本地緩存:默認配置 -->
<defaultCache
maxEntriesLocalHeap="1000" <!-- 堆內存最大緩存條目 -->
eternal="false" <!-- 是否永久有效 -->
timeToIdleSeconds="60" <!-- 空閑時間(秒),超過這個時間沒人用就過期 -->
timeToLiveSeconds="60" <!-- 存活時間(秒),不管用不用都過期 -->
memoryStoreEvictionPolicy="LRU"> <!-- 淘汰策略:LRU(最近最少使用) -->
</defaultCache>
<!-- 自定義緩存:localCache(本地緩存) -->
<cache name="localCache"
maxEntriesLocalHeap="500"
eternal="false"
timeToIdleSeconds="30"
timeToLiveSeconds="30"
memoryStoreEvictionPolicy="LRU">
</cache>
</config>再在配置類里加 Ehcache 的緩存管理器:
import org.springframework.cache.CacheManager;
import org.springframework.cache.ehcache.EhCacheCacheManager;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import net.sf.ehcache.CacheManager;
import net.sf.ehcache.config.ConfigurationFactory;
@Configuration
publicclass MultiCacheManagerConfig {
// 1. Redis緩存管理器(之前寫過,這里省略,注意Bean名稱是redisCacheManager)
// 2. Ehcache緩存管理器(Bean名稱是ehcacheCacheManager)
@Bean("ehcacheCacheManager")
public CacheManager ehcacheCacheManager() {
// 加載Ehcache配置文件
net.sf.ehcache.CacheManager ehcacheManager = CacheManager.create(ConfigurationFactory.parseConfiguration(getClass().getResourceAsStream("/ehcache.xml")));
returnnew EhCacheCacheManager(ehcacheManager);
}
}然后在方法上用cacheManager屬性指定用哪個緩存管理器:
@Service
public class UserService {
// 用Redis緩存管理器(分布式緩存)
@Cacheable(value = "userCache", cacheManager = "redisCacheManager")
public User getUserById(Long id) {
// 邏輯...
}
// 用Ehcache緩存管理器(本地緩存,適合不共享的臨時數據)
@Cacheable(value = "localCache", cacheManager = "ehcacheCacheManager")
public List<String> getLocalTempData() {
// 模擬獲取本地臨時數據(比如配置信息,不用共享)
try {
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
returnArrays.asList("temp1", "temp2", "temp3");
}
}這樣一來,getUserById用 Redis 緩存(多實例部署時能共享),getLocalTempData用 Ehcache 本地緩存(速度快,不占 Redis 資源)—— 按需選擇,靈活高效。
五、踩坑指南:這些問題不注意,上線準出幺蛾子
Spring Cache 雖然好用,但要是不注意這些細節,上線了肯定出問題。我整理了幾個實戰中最容易踩的坑,幫你避坑。
1. 緩存穿透:查不存在的數據,一直打數據庫
問題描述:比如有人故意查user.id=-1(數據庫里根本沒有這個用戶),@Cacheable會執行方法,返回 null,默認情況下 null 不會被緩存(除非你配置了enableCachingNullValues())。這樣一來,每次查id=-1都會穿透到數據庫,要是有人惡意刷這個接口,數據庫直接就崩了。
解決方案:
- 方案一:緩存 null 值。在 Redis 緩存配置里把disableCachingNullValues()改成enableCachingNullValues(),這樣查不到的數據也會緩存(value 是 null),下次再查就直接返回 null,不打數據庫了。
但要注意:緩存 null 值會占空間,所以要給這類緩存設置較短的過期時間(比如 5 分鐘),避免浪費內存。
- 方案二:參數校驗。在 Controller 或 Service 里先判斷參數是否合法,比如id <=0直接返回錯誤,根本不執行查詢邏輯。
舉個例子:
@GetMapping("/user/{id}")
public Result<User> getUser(@PathVariable Long id) {
// 參數校驗:id<=0直接返回錯誤
if (id <= 0) {
return Result.fail("用戶id不合法");
}
User user = userService.getUserById(id);
return Result.success(user);
}這種方式最徹底,從源頭阻止無效請求。
2. 緩存擊穿:熱點數據過期,大量請求打數據庫
問題描述:比如某個用戶是熱點數據(比如網紅用戶,id=10086),緩存過期的瞬間,有 1000 個請求同時查這個用戶,這時候緩存里沒有,所有請求都會穿透到數據庫,把數據庫打垮 —— 這就是緩存擊穿。
解決方案:
- 方案一:熱點數據永不過期。對于特別熱點的數據,比如首頁 Banner、網紅用戶信息,設置緩存永不過期(entryTtl(Duration.ofDays(365*10))),然后通過定時任務(比如 Quartz)定期更新緩存,這樣就不會有過期的瞬間。
- 方案二:互斥鎖。在查詢方法里加鎖,只有一個請求能去數據庫查,查到后更新緩存,其他請求等待鎖釋放后再查緩存。
舉個用 Redis 分布式鎖實現的例子(用 Redisson 客戶端,比自己寫鎖簡單):
首先加 Redisson 依賴:
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson-spring-boot-starter</artifactId>
<version>3.23.3</version> <!-- 選和Spring Boot兼容的版本 -->
</dependency>然后修改 Service 方法:
import org.redisson.api.RedissonClient;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.stereotype.Service;
import java.util.concurrent.TimeUnit;
@Service
publicclass UserService {
@Autowired
private RedissonClient redissonClient;
@Cacheable(value = "userCache", key = "#id")
public User getUserById(Long id) {
// 1. 判斷是不是熱點數據(這里假設id=10086是熱點數據)
if (id == 10086) {
// 2. 獲取分布式鎖(鎖的key:userLock:10086)
String lockKey = "userLock:" + id;
RLock lock = redissonClient.getLock(lockKey);
try {
// 3. 加鎖(等待10秒,持有鎖30秒,防止死鎖)
boolean locked = lock.tryLock(10, 30, TimeUnit.SECONDS);
if (locked) {
try {
// 4. 加鎖成功,查數據庫(這里模擬)
return queryUserFromDb(id);
} finally {
// 5. 釋放鎖
lock.unlock();
}
} else {
// 6. 加鎖失敗,等待100毫秒后重試(遞歸調用自己)
TimeUnit.MILLISECONDS.sleep(100);
return getUserById(id);
}
} catch (InterruptedException e) {
thrownew RuntimeException("獲取鎖失敗");
}
} else {
// 非熱點數據,直接查數據庫
return queryUserFromDb(id);
}
}
// 模擬從數據庫查數據
private User queryUserFromDb(Long id) {
try {
TimeUnit.SECONDS.sleep(2);
} catch (InterruptedException e) {
e.printStackTrace();
}
User user = new User();
user.setId(id);
user.setUsername("用戶" + id);
user.setPhone("1380013800" + (id % 10));
user.setCreateTime(LocalDateTime.now());
return user;
}
}這樣一來,即使熱點數據緩存過期,也只有一個請求能去查數據庫,其他請求都等鎖釋放后查緩存,不會打垮數據庫。
3. 緩存雪崩:大量緩存同時過期,數據庫被壓垮
問題描述:比如你給所有緩存都設置了同一個過期時間(比如凌晨 3 點),到了 3 點,大量緩存同時過期,這時候正好有大量用戶訪問,所有請求都穿透到數據庫,數據庫直接扛不住 —— 這就是緩存雪崩。
解決方案:
- 方案一:過期時間加隨機值。給每個緩存的過期時間加個隨機數,比如默認 30 分鐘,再加上 0-10 分鐘的隨機值,這樣緩存就不會同時過期了。
在 Redis 緩存配置里修改:
import java.util.Random;
// 個性化緩存規則時加隨機值
Map<String, RedisCacheConfiguration> cacheConfigurations = new HashMap<>();
Random random = new Random();
// userCache過期時間:1小時 ± 10分鐘
long userCacheTtl = 3600 + random.nextInt(600);
cacheConfigurations.put("userCache", defaultCacheConfig.entryTtl(Duration.ofSeconds(userCacheTtl)));
// userListCache過期時間:10分鐘 ± 2分鐘
long userListCacheTtl = 600 + random.nextInt(120);
cacheConfigurations.put("userListCache", defaultCacheConfig.entryTtl(Duration.ofSeconds(userListCacheTtl)));- 方案二:分批次過期。把緩存分成多個批次,比如按用戶 id 的尾號分 10 批,每批的過期時間差 10 分鐘,這樣即使一批過期,也只有 10% 的請求會打數據庫,壓力小很多。
- 方案三:Redis 集群。用 Redis 集群(主從 + 哨兵)保證 Redis 的高可用,即使某個 Redis 節點掛了,其他節點還能提供緩存服務,避免 Redis 掛了導致所有請求打數據庫。
4. 緩存一致性:數據庫改了,緩存沒更
問題描述:比如你更新了數據庫里的用戶信息,但緩存里的還是舊數據,這時候用戶查到的就是舊數據 —— 這就是緩存一致性問題。這個問題在分布式系統里很常見,比如多實例部署時,實例 A 更新了數據庫,但實例 B 的緩存還是舊的。
解決方案:
- 方案一:更新數據庫后立即更新 / 刪除緩存。就是咱們之前用的@CachePut(更新緩存)和@CacheEvict(刪除緩存),這是最常用的方式,適合大部分場景。
注意:要先更數據庫,再更緩存。如果先更緩存,再更數據庫,中間有其他請求查緩存,拿到的是新緩存,但數據庫還是舊的,會導致不一致。
- 方案二:延遲雙刪。如果更新操作比較頻繁,或者多實例部署時緩存同步有延遲,可以用 “延遲雙刪”:先刪緩存,再更數據庫,過一會兒再刪一次緩存。
舉個例子:
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
import java.util.concurrent.TimeUnit;
@Service
publicclass UserService {
@Autowired
private StringRedisTemplate stringRedisTemplate;
// 延遲雙刪:更新用戶信息
public User updateUserWithDelayDelete(User user) {
String cacheKey = "userCache::" + user.getId();
// 第一步:先刪除緩存
stringRedisTemplate.delete(cacheKey);
// 第二步:更新數據庫
User updatedUser = updateUserInDb(user);
// 第三步:延遲1秒后再刪除一次緩存(用異步線程,不阻塞主流程)
delayDeleteCache(cacheKey, 1);
return updatedUser;
}
// 異步延遲刪除緩存
@Async// 要在啟動類加@EnableAsync啟用異步
public void delayDeleteCache(String key, long delaySeconds) {
try {
TimeUnit.SECONDS.sleep(delaySeconds);
stringRedisTemplate.delete(key);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
// 模擬更新數據庫
private User updateUserInDb(User user) {
// 數據庫更新邏輯...
return user;
}
}為什么要刪兩次?因為第一步刪緩存后,可能有其他實例已經查了數據庫(舊數據),正在往緩存里存,這時候第二步更新數據庫后,緩存里還是舊數據。延遲 1 秒再刪一次,就能把這個舊緩存刪掉,保證一致性。
- 方案三:用 Canal 監聽數據庫 binlog。如果業務對緩存一致性要求特別高,可以用 Canal 監聽 MySQL 的 binlog 日志,當數據庫數據變化時,Canal 會觸發事件,自動更新 / 刪除 Redis 緩存。這種方式比較復雜,但一致性最好,適合大型項目。
六、實戰案例:完整的用戶服務緩存實現
講了這么多理論,咱們來個完整的實戰案例,把前面的知識點串起來。實現一個用戶服務,包含 “查單個用戶、查用戶列表、更新用戶、刪除用戶” 四個接口,用 Spring Cache+Redis 實現緩存,解決常見問題。
1. 項目結構
com.example.demo
├── config
│ ├── RedisCacheConfig.java// Redis緩存配置
│ └── CustomKeyGeneratorConfig.java// 自定義Key生成器
├── entity
│ └── User.java// 用戶實體類
├── service
│ ├── UserService.java// 用戶服務接口
│ └── impl
│ └── UserServiceImpl.java// 用戶服務實現(帶緩存)
├── controller
│ └── UserController.java// 控制層
└── DemoApplication.java // 啟動類2. 關鍵代碼實現
(1)User.java(實體類)
import lombok.Data;
import java.io.Serializable;
import java.time.LocalDateTime;
@Data
public class User implements Serializable {
private Long id;
private String username;
private String phone;
private Integer age;
private LocalDateTime createTime;
private LocalDateTime updateTime;
}(2)RedisCacheConfig.java(緩存配置)
import org.springframework.cache.CacheManager;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.cache.RedisCacheConfiguration;
import org.springframework.data.redis.cache.RedisCacheManager;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.serializer.*;
import java.time.Duration;
import java.util.HashMap;
import java.util.Map;
import java.util.Random;
@Configuration
publicclass RedisCacheConfig {
@Bean
public CacheManager redisCacheManager(RedisConnectionFactory redisConnectionFactory) {
// 1. 配置序列化
RedisSerializer<String> keySerializer = new StringRedisSerializer();
Jackson2JsonRedisSerializer<Object> valueSerializer = new Jackson2JsonRedisSerializer<>(Object.class);
com.fasterxml.jackson.databind.ObjectMapper objectMapper = new com.fasterxml.jackson.databind.ObjectMapper();
objectMapper.activateDefaultTyping(
com.fasterxml.jackson.databind.ObjectMapper.DefaultTyping.NON_FINAL,
com.fasterxml.jackson.databind.jsontype.TypeSerializer.DefaultImpl.NON_FINAL
);
objectMapper.registerModule(new com.fasterxml.jackson.datatype.jsr310.JavaTimeModule());
valueSerializer.setObjectMapper(objectMapper);
// 2. 默認緩存配置(過期時間30分鐘,加隨機值避免雪崩)
Random random = new Random();
long defaultTtl = 1800 + random.nextInt(300); // 30分鐘 ± 5分鐘
RedisCacheConfiguration defaultCacheConfig = RedisCacheConfiguration.defaultCacheConfig()
.entryTtl(Duration.ofSeconds(defaultTtl))
.serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(keySerializer))
.serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(valueSerializer))
.enableCachingNullValues(); // 緩存null值,解決穿透
// 3. 個性化緩存配置
Map<String, RedisCacheConfiguration> cacheConfigs = new HashMap<>();
// userCache:1小時 ± 10分鐘
long userCacheTtl = 3600 + random.nextInt(600);
cacheConfigs.put("userCache", defaultCacheConfig.entryTtl(Duration.ofSeconds(userCacheTtl)));
// userListCache:10分鐘 ± 2分鐘
long userListCacheTtl = 600 + random.nextInt(120);
cacheConfigs.put("userListCache", defaultCacheConfig.entryTtl(Duration.ofSeconds(userListCacheTtl)));
// 4. 創建緩存管理器
return RedisCacheManager.builder(redisConnectionFactory)
.cacheDefaults(defaultCacheConfig)
.withInitialCacheConfigurations(cacheConfigs)
.build();
}
}(3)CustomKeyGeneratorConfig.java(Key 生成器)
import org.springframework.cache.interceptor.KeyGenerator;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import java.lang.reflect.Method;
import java.util.Arrays;
@Configuration
publicclass CustomKeyGeneratorConfig {
@Bean("customKeyGenerator")
public KeyGenerator customKeyGenerator() {
return (target, method, params) -> {
// 生成規則:類名.方法名[參數列表](避免不同類方法名重復)
String className = target.getClass().getSimpleName();
String methodName = method.getName();
String paramStr = Arrays.toString(params);
return className + "." + methodName + "[" + paramStr + "]";
};
}
}(4)UserService.java(接口)
import com.example.demo.entity.User;
import java.util.List;
publicinterface UserService {
// 查單個用戶
User getUserById(Long id);
// 查用戶列表
List<User> getUserList(Integer pageNum, Integer pageSize);
// 更新用戶
User updateUser(User user);
// 刪除用戶
void deleteUser(Long id);
}(5)UserServiceImpl.java(服務實現,帶緩存)
import com.example.demo.entity.User;
import com.example.demo.service.UserService;
import org.springframework.cache.annotation.*;
import org.springframework.stereotype.Service;
import java.time.LocalDateTime;
import java.util.Arrays;
import java.util.List;
import java.util.concurrent.TimeUnit;
@Service
// 統一配置:緩存命名空間、Key生成器
@CacheConfig(value = "userCache", keyGenerator = "customKeyGenerator")
publicclass UserServiceImpl implements UserService {
// 模擬數據庫(實際項目中是MyBatis/JPA)
private List<User> mockDb() {
return Arrays.asList(
createUser(1L, "張三", "13800138001", 20),
createUser(2L, "李四", "13800138002", 25),
createUser(3L, "王五", "13800138003", 30)
);
}
private User createUser(Long id, String username, String phone, Integer age) {
User user = new User();
user.setId(id);
user.setUsername(username);
user.setPhone(phone);
user.setAge(age);
user.setCreateTime(LocalDateTime.now());
user.setUpdateTime(LocalDateTime.now());
return user;
}
/**
* 查單個用戶:用緩存,解決穿透(緩存null)、擊穿(熱點數據加鎖)
*/
@Override
@Cacheable(unless = "#result == null") // 結果為null也緩存(配置里已enableCachingNullValues)
public User getUserById(Long id) {
// 模擬熱點數據(id=1是熱點)
if (id == 1) {
// 這里可以加分布式鎖,參考前面的緩存擊穿解決方案,省略代碼
}
// 模擬數據庫查詢耗時
try {
TimeUnit.SECONDS.sleep(2);
} catch (InterruptedException e) {
e.printStackTrace();
}
// 從模擬數據庫查詢
return mockDb().stream()
.filter(user -> user.getId().equals(id))
.findFirst()
.orElse(null); // 查不到返回null,會被緩存
}
/**
* 查用戶列表:用單獨的緩存命名空間,避免和單個用戶緩存沖突
*/
@Override
@Cacheable(value = "userListCache") // 覆蓋類上的value,用userListCache
public List<User> getUserList(Integer pageNum, Integer pageSize) {
// 模擬數據庫查詢耗時
try {
TimeUnit.SECONDS.sleep(2);
} catch (InterruptedException e) {
e.printStackTrace();
}
// 模擬分頁(實際項目用PageHelper)
int start = (pageNum - 1) * pageSize;
int end = Math.min(start + pageSize, mockDb().size());
return mockDb().subList(start, end);
}
/**
* 更新用戶:更新緩存,同時刪除列表緩存(保證一致性)
*/
@Override
@Caching(
put = {
@CachePut(key = "#user.id") // 更新單個用戶緩存
},
evict = {
@CacheEvict(value = "userListCache", allEntries = true) // 刪除列表緩存
}
)
public User updateUser(User user) {
// 模擬數據庫更新耗時
try {
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
// 模擬更新數據庫(實際項目會更新MySQL)
User updatedUser = createUser(
user.getId(),
user.getUsername(),
user.getPhone(),
user.getAge()
);
updatedUser.setUpdateTime(LocalDateTime.now());
return updatedUser;
}
/**
* 刪除用戶:刪除緩存,同時刪除列表緩存
*/
@Override
@Caching(
evict = {
@CacheEvict(key = "#id"), // 刪除單個用戶緩存
@CacheEvict(value = "userListCache", allEntries = true) // 刪除列表緩存
}
)
public void deleteUser(Long id) {
// 模擬數據庫刪除耗時
try {
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
// 模擬刪除數據庫記錄(實際項目會刪MySQL)
System.out.println("用戶" + id + "已從數據庫刪除");
}
}(6)UserController.java(控制層)
import com.example.demo.entity.User;
import com.example.demo.service.UserService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@RestController
@RequestMapping("/user")
publicclass UserController {
@Autowired
private UserService userService;
// 查單個用戶
@GetMapping("/{id}")
public User getById(@PathVariable Long id) {
long start = System.currentTimeMillis();
User user = userService.getUserById(id);
long end = System.currentTimeMillis();
System.out.println("查詢耗時:" + (end - start) + "ms");
return user;
}
// 查用戶列表(分頁)
@GetMapping("/list")
public List<User> getList(
@RequestParam(defaultValue = "1") Integer pageNum,
@RequestParam(defaultValue = "2") Integer pageSize) {
long start = System.currentTimeMillis();
List<User> list = userService.getUserList(pageNum, pageSize);
long end = System.currentTimeMillis();
System.out.println("列表查詢耗時:" + (end - start) + "ms");
return list;
}
// 更新用戶
@PutMapping
public User update(@RequestBody User user) {
return userService.updateUser(user);
}
// 刪除用戶
@DeleteMapping("/{id}")
public String delete(@PathVariable Long id) {
userService.deleteUser(id);
return"刪除成功";
}
}(7)DemoApplication.java(啟動類)
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cache.annotation.EnableCaching;
import org.springframework.scheduling.annotation.EnableAsync;
@SpringBootApplication
@EnableCaching // 啟用緩存
@EnableAsync // 啟用異步(延遲雙刪用)
public class DemoApplication {
public static void main(String[] args) {
SpringApplication.run(DemoApplication.class, args);
}
}3. 測試驗證
啟動項目后,用 Postman 測試:
1)查單個用戶:GET http://localhost:8080/user/1
- 第一次耗時約 2000ms,存緩存。
- 第二次耗時約 10ms,讀緩存。
- 查id=999(不存在),返回 null,緩存 null 值,下次查耗時約 10ms。
2)查用戶列表:GET http://localhost:8080/user/list?pageNum=1&pageSize=2
- 第一次耗時約 2000ms,存緩存到userListCache。
- 第二次耗時約 10ms,讀緩存。
3)更新用戶:PUT http://localhost:8080/user,請求體:
{
"id": 1,
"username": "張三更新",
"phone": "13800138001",
"age": 21
}- 執行后,userCache里 id=1 的緩存被更新,userListCache被清空。
- 再查id=1,拿到更新后的用戶;再查列表,耗時約 2000ms(重新存緩存)。
4)刪除用戶:DELETE http://localhost:8080/user/1
- 執行后,userCache里 id=1 的緩存被刪除,userListCache被清空。
- 再查id=1,耗時約 2000ms(重新查庫并存緩存)。
所有功能都正常,緩存也能正確工作,常見的緩存問題也都有解決方案 —— 這就是一個生產環境可用的 Spring Cache 實現!
七、總結:為什么說 Spring Cache 是優雅的緩存方式?
看到這里,你應該明白為什么我說 Spring Cache 優雅了吧?咱們總結一下:
- 代碼更簡潔:不用手動寫 “查緩存、存緩存、刪緩存” 的邏輯,一個注解搞定,業務代碼更純粹。
- 解耦更徹底:緩存邏輯和業務邏輯完全分開,換緩存中間件(Redis→Ehcache)不用改業務代碼。
- 配置更靈活:支持自定義序列化、Key 生成器、過期時間,還能多緩存管理器切換,滿足各種場景。
- 問題有解法:針對緩存穿透、擊穿、雪崩、一致性這些常見問題,都有成熟的解決方案,踩坑成本低。
最后給大家一個建議:別再用 HashMap 當緩存,也別再手動寫 RedisTemplate 操作緩存了,趕緊把 Spring Cache 用起來。剛開始可能會覺得配置有點麻煩,但一旦上手,你會發現, 這玩意兒是真的香!
































