缓存用对了是神器,用错了是埋雷。本文从日常开发高频踩坑点出发,每个坑都配完整代码,看完直接落地。
前言
缓存是性能优化的必备手段,但实际开发中,90%的项目都踩过这些坑:
- 缓存不生效,查完数据库还是慢
- 缓存穿透,一个请求打爆数据库
- 缓存数据不一致,用户看到旧数据
- 缓存雪崩,线上大规模故障
本文整理了 @Cacheable 日常开发中的 10个高频踩坑点,每个坑都给出问题原因 + 解决方案 + 实战代码。
坑1:@Cacheable 不生效(最常见)
问题现象
接口加了这个注解,但每次都还是查数据库,缓存根本没起作用。
问题原因
kotlin
// ❌ 忘了加这个注解,缓存永远不生效
@Cacheable(value = "user", key = "#id")
public User getUser(Long id) {
return userMapper.selectById(id);
}
解决方案
启动类或配置类加 @EnableCaching:
less
@SpringBootApplication
@EnableCaching // 少了这个,一切缓存都是白搭
public class Application {
public static void main(String[] args) {
SpringApplication.run(Application.class, args);
}
}
避坑检查清单
| 检查项 | 说明 |
|---|---|
| 启动类/配置类有 @EnableCaching | 开启缓存功能 |
| Maven 依赖已引入 | spring-boot-starter-cache + 缓存实现(如 caffeine/redis) |
| 方法是 public | AOP 代理限制,private 方法不生效 |
坑2:缓存 key 写错了(查不到数据)
问题现象
明明缓存里有数据,但接口每次都返回 null,数据库被反复查询。
问题原因
kotlin
// ❌ key 写成固定字符串,所有请求都命中同一个缓存
@Cacheable(value = "user", key = "'user'")
public User getUser(Long id) {
return userMapper.selectById(id);
}
解决方案
typescript
// ✅ key 动态拼接参数
@Cacheable(value = "user", key = "#id")
public User getUser(Long id) {
return userMapper.selectById(id);
}
// ✅ 多个参数组合 key
@Cacheable(value = "user", key = "#type + ':' + #status")
public List<User> listByType(Integer type, Integer status) {
return userMapper.selectList(type, status);
}
// ✅ 使用参数对象属性
@Cacheable(value = "user", key = "#query.id + ':' + #query.type")
public List<User> search(UserQuery query) {
return userMapper.search(query);
}
key 表达式速查
| 表达式 | 含义 |
|---|---|
| #id | 参数名为 id 的值 |
| #p0 | 第一个参数的值 |
| #user.id | user 参数的 id 属性 |
| #root.methodName | 当前方法名 |
| #root.caches[0].name | 第一个缓存名称 |
坑3:缓存穿透(空值也查库)
问题现象
请求一个不存在的用户 ID,每次都查数据库,缓存形同虚设。
问题原因
sql
// ❌ 数据库查不到时返回 null,但 null 不会被缓存
@Cacheable(value = "user", key = "#id")
public User getUser(Long id) {
User user = userMapper.selectById(id);
if (user == null) {
return null; // null 不会缓存,下次继续查库
}
return user;
}
解决方案
方案一:缓存空值(推荐简单场景)
kotlin
@Cacheable(value = "user", key = "#id", unless = "#result == null")
public User getUser(Long id) {
return userMapper.selectById(id);
}
方案二:布隆过滤器(推荐大数据量)
kotlin
@Service
public class UserCacheService {
private BloomFilter<Long> bloomFilter;
@PostConstruct
public void init() {
bloomFilter = BloomFilter.create(Funnels.longFunnel(), 1000000, 0.01);
// 启动时加载所有有效 ID
List<Long> allIds = userMapper.selectAllIds();
allIds.forEach(bloomFilter::put);
}
public User getUser(Long id) {
// 先检查布隆过滤器
if (!bloomFilter.mightContain(id)) {
return null; // 一定不存在,直接返回
}
return userMapper.selectById(id);
}
}
缓存穿透 vs 缓存击穿 vs 缓存雪崩
| 概念 | 原因 | 解决方案 |
|---|---|---|
| 缓存穿透 | 查询不存在的数据 | 缓存空值、布隆过滤器 |
| 缓存击穿 | 热点 key 过期瞬间大量请求 | 互斥锁、逻辑过期、永不过期 |
| 缓存雪崩 | 大量 key 同时过期 | 过期时间随机、热点数据不过期 |
坑4:缓存击穿(热点数据被打爆)
问题现象
某个热点缓存 key 过期瞬间,大量请求同时打到数据库,数据库直接被打挂。
问题原因
热点数据缓存过期策略设置不当,高并发时大量请求同时穿透到数据库。
解决方案
方案一:互斥锁(简单有效)
kotlin
@Cacheable(value = "user", key = "#id")
public User getUser(Long id) {
// 双重检查锁定
return getUserFromDb(id);
}
private User getUserFromDb(Long id) {
// 尝试获取锁
String lockKey = "lock:user:" + id;
Boolean locked = redisTemplate.opsForValue().setIfAbsent(lockKey, "1", 10, TimeUnit.SECONDS);
if (Boolean.TRUE.equals(locked)) {
try {
// 再次检查缓存(其他线程可能已经写入)
User cached = userMapper.selectById(id);
if (cached != null) {
return cached;
}
return userMapper.selectById(id);
} finally {
redisTemplate.delete(lockKey);
}
} else {
// 等待后重试
try {
Thread.sleep(50);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
return userMapper.selectById(id);
}
}
方案二:逻辑过期(高并发推荐)
java
@Component
public class UserCacheService {
private static final Duration LOGICAL_EXPIRE = Duration.ofMinutes(30);
public User getUser(Long id) {
String key = "cache:user:" + id;
// 1. 先查缓存
String cached = redisTemplate.opsForValue().get(key);
if (cached != null) {
UserCacheVO cacheVO = JSON.parseObject(cached, UserCacheVO.class);
// 检查是否逻辑过期
if (cacheVO.getExpireTime().isAfter(LocalDateTime.now())) {
return cacheVO.getUser();
}
// 逻辑过期,异步更新缓存
CompletableFuture.runAsync(() -> refreshCache(id));
}
// 2. 查数据库
User user = userMapper.selectById(id);
// 3. 写入缓存
saveCache(id, user);
return user;
}
private void saveCache(Long id, User user) {
UserCacheVO cacheVO = new UserCacheVO();
cacheVO.setUser(user);
cacheVO.setExpireTime(LocalDateTime.now().plus(LOGICAL_EXPIRE));
redisTemplate.opsForValue().set("cache:user:" + id, JSON.toJSONString(cacheVO));
}
}
坑5:缓存雪崩(批量 key 同时过期)
问题现象
系统启动或大批量缓存过期时,短时间内大量请求打到数据库,数据库压力暴增。
问题原因
所有缓存 key 设置了相同的过期时间。
解决方案
方案一:过期时间加随机值
java
@Component
public class CacheTTLService {
// 基础过期时间
private static final Duration BASE_TTL = Duration.ofMinutes(30);
public <T> void putWithJitter(String key, T value) {
// 过期时间 = 基础时间 + 0~10分钟随机
long jitter = ThreadLocalRandom.current().nextLong(0, 600);
Duration ttl = BASE_TTL.plusSeconds(jitter);
redisTemplate.opsForValue().set(key, value, ttl);
}
}
方案二:热点数据永不过期
typescript
// 热点数据不设置过期时间,更新时手动删除
@Cacheable(value = "hot:user", key = "#id")
public User getHotUser(Long id) {
return userMapper.selectById(id);
}
// 数据更新时删除缓存
@CacheEvict(value = "hot:user", key = "#user.id")
public void updateUser(User user) {
userMapper.updateById(user);
}
坑6:缓存数据不一致(最坑的场景)
问题现象
用户更新了资料,但过了一会儿还是看到旧数据。或者数据删了,缓存里还有。
问题原因
典型的缓存双写一致性问题:先更新数据库还是先删缓存?顺序不对就会出问题。
解决方案
方案一:Cache Aside(推荐)
typescript
// 读:缓存优先,缓存没有查数据库并写入缓存
public User getUser(Long id) {
String key = "user:" + id;
User user = redisTemplate.opsForValue().get(key);
if (user == null) {
user = userMapper.selectById(id);
if (user != null) {
redisTemplate.opsForValue().set(key, user, 30, TimeUnit.MINUTES);
}
}
return user;
}
// 写:先更新数据库,再删缓存(不是更新缓存)
public void updateUser(User user) {
userMapper.updateById(user); // 先更新数据库
redisTemplate.delete("user:" + user.getId()); // 再删缓存
}
// 删:直接删缓存
public void deleteUser(Long id) {
userMapper.deleteById(id);
redisTemplate.delete("user:" + id);
}
为什么是删缓存而不是更新缓存?
- 更新缓存:并发时容易出现数据覆盖,导致数据不一致
- 删除缓存:下次查询重新加载,保证最终一致
方案二:延迟双删(强一致性场景)
scss
public void updateUser(User user) {
// 1. 先删缓存
redisTemplate.delete("user:" + user.getId());
// 2. 再更新数据库
userMapper.updateById(user);
// 3. 延迟一段时间后再删一次(解决并发问题)
try {
Thread.sleep(500);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
redisTemplate.delete("user:" + user.getId());
}
坑7:@CacheEvict 和 @CachePut 用错了
问题现象
更新数据后缓存没变化,或者查询方法把缓存删了。
问题原因
混淆了 @CachePut 和 @CacheEvict 的使用场景。
解决方案
| 注解 | 用途 | 场景 |
|---|---|---|
| @Cacheable | 读取缓存 | 查询方法 |
| @CachePut | 更新缓存 | 更新后返回数据并缓存 |
| @CacheEvict | 删除缓存 | 删除方法 |
| @CacheEvict(allEntries = true) | 清空所有缓存 | 批量删除 |
正确示例
typescript
@Service
public class UserService {
// 查询 - 缓存读取
@Cacheable(value = "user", key = "#id")
public User getUser(Long id) {
return userMapper.selectById(id);
}
// 更新 - 缓存更新(返回结果写入缓存)
@CachePut(value = "user", key = "#user.id")
public User updateUser(User user) {
userMapper.updateById(user);
return user; // 必须返回结果
}
// 删除 - 缓存删除
@CacheEvict(value = "user", key = "#id")
public void deleteUser(Long id) {
userMapper.deleteById(id);
}
// 清空某类全部缓存(谨慎使用)
@CacheEvict(value = "user", allEntries = true)
public void clearAllUserCache() {
// 清理操作
}
}
坑8:分布式环境下缓存失效
问题现象
本地测试缓存好好的,部署到多实例后缓存混乱,数据不一致。
问题原因
本地缓存(如 Caffeine)只在单个 JVM 实例内有效,多实例部署时各实例缓存独立。
解决方案
必须使用分布式缓存(Redis)
java
# application.yml
spring:
cache:
type: redis
redis:
host: localhost
port: 6379
database: 0
@Configuration
@EnableCaching
public class CacheConfig {
@Bean
public RedisCacheManager cacheManager(RedisConnectionFactory factory) {
RedisCacheConfiguration config = RedisCacheConfiguration.defaultCacheConfig()
.entryTtl(Duration.ofMinutes(30)) // 默认过期时间
.serializeKeysWith(RedisSerializationContext.SerializationPair
.fromSerializer(new StringRedisSerializer()))
.serializeValuesWith(RedisSerializationContext.SerializationPair
.fromSerializer(new GenericJackson2JsonRedisSerializer()));
return RedisCacheManager.builder(factory)
.cacheDefaults(config)
.build();
}
}
坑9:缓存序列化异常
问题现象
Redis 里存的是乱码,或者反序列化时报错 Could not read JSON。
问题原因
未配置正确的序列化器,或存储了不支持序列化的对象。
解决方案
配置 JSON 序列化
arduino
@Configuration
@EnableCaching
public class RedisConfig {
@Bean
public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory factory) {
RedisTemplate<String, Object> template = new RedisTemplate<>();
template.setConnectionFactory(factory);
// Key 序列化
template.setKeySerializer(new StringRedisSerializer());
template.setHashKeySerializer(new StringRedisSerializer());
// Value 序列化 - 使用 JSON
template.setValueSerializer(new GenericJackson2JsonRedisSerializer());
template.setHashValueSerializer(new GenericJackson2JsonRedisSerializer());
template.afterPropertiesSet();
return template;
}
}
实体类要实现序列化接口
java
@Data
public class User implements Serializable { // 必须实现 Serializable
private static final long serialVersionUID = 1L;
private Long id;
private String name;
}
坑10:缓存未设置过期时间导致内存泄漏
问题现象
Redis 内存持续增长,大量缓存数据堆积。
问题原因
使用了 @Cacheable 但没有配置过期时间。
解决方案
全局配置默认过期时间
less
@Configuration
@EnableCaching
public class CacheConfig {
@Bean
public RedisCacheManager cacheManager(RedisConnectionFactory factory) {
RedisCacheConfiguration config = RedisCacheConfiguration.defaultCacheConfig()
.entryTtl(Duration.ofMinutes(30)) // 默认30分钟过期
.disableCachingNullValues(); // 不缓存 null
return RedisCacheManager.builder(factory)
.cacheDefaults(config)
.build();
}
}
单缓存配置过期时间
less
// 短期缓存(频繁变化的数据)
@Cacheable(value = "user", key = "#id", ttl = @TTL(seconds = 300))
// 长期缓存(几乎不变的数据)
@Cacheable(value = "config", key = "#key", ttl = @TTL(hours = 24))
最佳实践速查表
| 检查项 | 说明 | 推荐配置 |
|---|---|---|
| ✅ 启动类加 @EnableCaching | 开启缓存功能 | 必选项 |
| ✅ key 表达式动态拼接 | 避免所有请求命中同一 key | #id、#p0 |
| ✅ 设置合理的过期时间 | 避免内存泄漏 | 15~60分钟 |
| ✅ 过期时间加随机值 | 防止缓存雪崩 | base + random(0, 10min) |
| ✅ 缓存空值防止穿透 | 布隆过滤器或缓存空对象 | unless + null 值过滤 |
| ✅ 热点数据互斥锁 | 防止缓存击穿 | 分布式锁 |
| ✅ 先删缓存后更新 | 保证双写一致 | Cache Aside 模式 |
| ✅ 分布式环境用 Redis | 本地缓存只适合单机 | Redis Cluster |
| ✅ 实体类实现序列化 | 防止反序列化失败 | implements Serializable |
| ✅ 监控缓存命中率 | 及时发现问题 | Actuator + Metrics |
总结
缓存是性能优化的重要手段,但也是坑最密集的地方。记住这三条黄金原则:
- 缓存优先,读写分离:读操作先查缓存;写操作先更新数据库,再删缓存
- 兜底方案必备:穿透、击穿、雪崩三大问题必须有应对方案
- 监控是最后的防线:没有监控的缓存是定时炸弹