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

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

前言

缓存是性能优化的必备手段,但实际开发中,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

总结

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

  1. 缓存优先,读写分离:读操作先查缓存;写操作先更新数据库,再删缓存
  2. 兜底方案必备:穿透、击穿、雪崩三大问题必须有应对方案
  3. 监控是最后的防线:没有监控的缓存是定时炸弹
相关推荐
沛沛rh453 小时前
用 Rust 实现用户态调试器:mini-debugger项目原理剖析与工程复盘
开发语言·c++·后端·架构·rust·系统架构
消失的旧时光-19433 小时前
Spring Boot + MyBatis 从 0 到 1 跑通查询接口(含全部踩坑)
spring boot·后端·spring·mybatis
SamDeepThinking3 小时前
Spring AOP记录日志,生产环境的代码长什么样
java·后端·架构
小江的记录本4 小时前
【网络安全】《网络安全三大加密算法结构化知识体系》
java·前端·后端·python·安全·spring·web安全
GetcharZp4 小时前
「干掉 Gin?」极致性能的 Go Web 框架 Fiber:这才是真正的“快”!
后端
希望永不加班4 小时前
SpringBoot 中 AOP 实现多数据源切换
java·数据库·spring boot·后端·spring
超级无敌攻城狮5 小时前
Agent 到底是怎么跑起来的
前端·后端·架构
二妹的三爷5 小时前
私有化部署DeepSeek并SpringBoot集成使用(附UI界面使用教程-支持语音、图片)
spring boot·后端·ui
神奇小汤圆5 小时前
程序员面试必备的Java八股文,适合所有的Java求职者
后端