别再用 HashMap 当缓存,也别再手动写 RedisTemplate 操作缓存了,赶紧把 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 用起来。刚开始可能会觉得配置有点麻烦,但一旦上手,你会发现, 这玩意儿是真的香!
AI大模型学习福利
作为一名热心肠的互联网老兵,我决定把宝贵的AI知识分享给大家。 至于能学习到多少就看你的学习毅力和能力了 。我已将重要的AI大模型资料包括AI大模型入门学习思维导图、精品AI大模型学习书籍手册、视频教程、实战学习等录播视频免费分享出来。
一、全套AGI大模型学习路线
AI大模型时代的学习之旅:从基础到前沿,掌握人工智能的核心技能!

因篇幅有限,仅展示部分资料,需要点击文章最下方名片即可前往获取
二、640套AI大模型报告合集
这套包含640份报告的合集,涵盖了AI大模型的理论研究、技术实现、行业应用等多个方面。无论您是科研人员、工程师,还是对AI大模型感兴趣的爱好者,这套报告合集都将为您提供宝贵的信息和启示。

因篇幅有限,仅展示部分资料,需要点击文章最下方名片即可前往获
三、AI大模型经典PDF籍
随着人工智能技术的飞速发展,AI大模型已经成为了当今科技领域的一大热点。这些大型预训练模型,如GPT-3、BERT、XLNet等,以其强大的语言理解和生成能力,正在改变我们对人工智能的认识。 那以下这些PDF籍就是非常不错的学习资源。

因篇幅有限,仅展示部分资料,需要点击文章最下方名片即可前往获
四、AI大模型商业化落地方案

因篇幅有限,仅展示部分资料,需要点击文章最下方名片即可前往获
作为普通人,入局大模型时代需要持续学习和实践,不断提高自己的技能和认知水平,同时也需要有责任感和伦理意识,为人工智能的健康发展贡献力量
1524

被折叠的 条评论
为什么被折叠?



