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

张开发
2026/4/13 21:23:16 15 分钟阅读

分享文章

Spring-Boot-缓存实战-@Cacheable-这10个坑
缓存用对了是神器用错了是埋雷。本文从日常开发高频踩坑点出发每个坑都配完整代码看完直接落地。前言缓存是性能优化的必备手段但实际开发中90%的项目都踩过这些坑缓存不生效查完数据库还是慢缓存穿透一个请求打爆数据库缓存数据不一致用户看到旧数据缓存雪崩线上大规模故障本文整理了 Cacheable 日常开发中的10个高频踩坑点每个坑都给出问题原因 解决方案 实战代码。坑1Cacheable 不生效最常见问题现象接口加了这个注解但每次都还是查数据库缓存根本没起作用。问题原因// ❌ 忘了加这个注解缓存永远不生效 Cacheable(value user, key #id) public User getUser(Long id) { return userMapper.selectById(id); }解决方案启动类或配置类加EnableCachingSpringBootApplication 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 ListUser listByType(Integer type, Integer status) { return userMapper.selectList(type, status); } ​ // ✅ 使用参数对象属性 Cacheable(value user, key #query.id : #query.type) public ListUser 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 BloomFilterLong bloomFilter; PostConstruct public void init() { bloomFilter BloomFilter.create(Funnels.longFunnel(), 1000000, 0.01); // 启动时加载所有有效 ID ListLong 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()); }坑7CacheEvict 和 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 RedisTemplateString, Object redisTemplate(RedisConnectionFactory factory) { RedisTemplateString, 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总结缓存是性能优化的重要手段但也是坑最密集的地方。记住这三条黄金原则缓存优先读写分离读操作先查缓存写操作先更新数据库再删缓存兜底方案必备穿透、击穿、雪崩三大问题必须有应对方案监控是最后的防线没有监控的缓存是定时炸弹关注我持续更新 Spring Boot 实战干货。如果觉得有用转发给需要的朋友。

更多文章