Spring-Boot-缓存实战-@Cacheable-这10个坑

3 阅读9分钟

缓存用对了是神器,用错了是埋雷。本文从日常开发高频踩坑点出发,每个坑都配完整代码,看完直接落地。

前言

缓存是性能优化的必备手段,但实际开发中,90%的项目都踩过这些坑:

  • 缓存不生效,查完数据库还是慢
  • 缓存穿透,一个请求打爆数据库
  • 缓存数据不一致,用户看到旧数据
  • 缓存雪崩,线上大规模故障

本文整理了 @Cacheable 日常开发中的 10个高频踩坑点,每个坑都给出问题原因 + 解决方案 + 实战代码。

坑1:@Cacheable 不生效(最常见)

问题现象

接口加了这个注解,但每次都还是查数据库,缓存根本没起作用。

问题原因

// ❌ 忘了加这个注解,缓存永远不生效
@Cacheable(value = "user", key = "#id")
public User getUser(Long id) {
    return userMapper.selectById(id);
}

解决方案

启动类或配置类加 @EnableCaching

@SpringBootApplication
@EnableCaching  // 少了这个,一切缓存都是白搭
public class Application {
    public static void main(String[] args) {
        SpringApplication.run(Application.class, args);
    }
}

避坑检查清单

检查项说明
启动类/配置类有 @EnableCaching开启缓存功能
Maven 依赖已引入spring-boot-starter-cache + 缓存实现(如 caffeine/redis)
方法是 publicAOP 代理限制,private 方法不生效

坑2:缓存 key 写错了(查不到数据)

问题现象

明明缓存里有数据,但接口每次都返回 null,数据库被反复查询。

问题原因

// ❌ key 写成固定字符串,所有请求都命中同一个缓存
@Cacheable(value = "user", key = "'user'")
public User getUser(Long id) {
    return userMapper.selectById(id);
}

解决方案

// ✅ 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.iduser 参数的 id 属性
#root.methodName当前方法名
#root.caches[0].name第一个缓存名称

坑3:缓存穿透(空值也查库)

问题现象

请求一个不存在的用户 ID,每次都查数据库,缓存形同虚设。

问题原因

// ❌ 数据库查不到时返回 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;
}

解决方案

方案一:缓存空值(推荐简单场景)

@Cacheable(value = "user", key = "#id", unless = "#result == null")
public User getUser(Long id) {
    return userMapper.selectById(id);
}

方案二:布隆过滤器(推荐大数据量)

@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 过期瞬间,大量请求同时打到数据库,数据库直接被打挂。

问题原因

热点数据缓存过期策略设置不当,高并发时大量请求同时穿透到数据库。

解决方案

方案一:互斥锁(简单有效)

@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);
    }
}

方案二:逻辑过期(高并发推荐)

@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 设置了相同的过期时间。

解决方案

方案一:过期时间加随机值

@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);
    }
}

方案二:热点数据永不过期

// 热点数据不设置过期时间,更新时手动删除
@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(推荐)

// 读:缓存优先,缓存没有查数据库并写入缓存
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);
}

为什么是删缓存而不是更新缓存?

  • 更新缓存:并发时容易出现数据覆盖,导致数据不一致
  • 删除缓存:下次查询重新加载,保证最终一致

方案二:延迟双删(强一致性场景)

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)清空所有缓存批量删除

正确示例

@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

# 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 序列化

@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;
    }
}

实体类要实现序列化接口

@Data
public class User implements Serializable {  // 必须实现 Serializable
    private static final long serialVersionUID = 1L;
    
    private Long id;
    private String name;
}

坑10:缓存未设置过期时间导致内存泄漏

问题现象

Redis 内存持续增长,大量缓存数据堆积。

问题原因

使用了 @Cacheable 但没有配置过期时间。

解决方案

全局配置默认过期时间

@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();
    }
}

单缓存配置过期时间

// 短期缓存(频繁变化的数据)
@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

总结

缓存是性能优化的重要手段,但也是坑最密集的地方。记住这三条黄金原则:

  1. 缓存优先,读写分离:读操作先查缓存;写操作先更新数据库,再删缓存
  2. 兜底方案必备:穿透、击穿、雪崩三大问题必须有应对方案
  3. 监控是最后的防线:没有监控的缓存是定时炸弹