前言：一个惨痛的教训话说那年，我写了一个"用户信息查询"接口，代码简洁优雅，自我感觉良好。上线后 QPS 一路飙升，数据库 CPU 直接拉满，DBA 小哥深夜给我打电话，语气中透着一丝疲惫和杀意。"兄弟，你这接口一秒钟查同一个用户几百次，图啥呢？"那一刻，我顿悟了：缓存，是程序员对数据库最基本的尊重。一、@Cacheable 是什么？@Cacheable 是 Spring Cache 提供的声明式缓存注解，属于"我不想写重复代码"系列的终极解决方案。它的核心思想很朴素：第一次调用方法 → 执行方法，结果存缓存后续相同参数调用 → 直接返回缓存，方法体压根不执行简单说就是："这活我干过了，答案在这，别烦我。"二、快速上手2.1 添加依赖 org.springframework.boot spring-boot-starter-cache org.springframework.boot spring-boot-starter-data-redis 2.2 开启缓存功能在启动类上加一个注解，仪式感要有：@SpringBootApplication @EnableCaching // 就这一行，缓存世界的大门向你敞开 public class Application { public static void main(String[] args) { SpringApplication.run(Application.class, args); } }2.3 使用 @Cacheable@Service public class UserService { @Cacheable(value = "userCache", key = "#userId") public User getUserById(Long userId) { // 这行日志是关键，用来验证缓存是否生效 System.out.println(">>> 真的去查数据库了，userId: " + userId); return userRepository.findById(userId).orElse(null); } }测试一下：userService.getUserById(1L); // 控制台输出日志，查了数据库 userService.getUserById(1L); // 静悄悄，缓存命中 userService.getUserById(1L); // 依然静悄悄 userService.getUserById(2L); // 输出日志，新用户要查库恭喜，你的数据库松了一口气。三、核心属性详解@Cacheable 的属性不多，但每个都值得细品。3.1 value / cacheNames —— 缓存的"收纳盒"@Cacheable(value = "userCache") @Cacheable(cacheNames = "userCache") // 两种写法等价 @Cacheable(value = {"cache1", "cache2"}) // 可以同时放多个盒子这玩意就是给缓存分类，就像你家的收纳盒：袜子放一个、内裤放一个，别混了。3.2 key —— 缓存的"门牌号"默认情况下，Spring 会用方法的所有参数生成 key。但很多时候我们需要自定义：// 使用 SpEL 表达式 @Cacheable(value = "userCache", key = "#userId") public User getUserById(Long userId) { ... } // 多参数组合 @Cacheable(value = "userCache", key = "#userId + '_' + #type") public User getUserByIdAndType(Long userId, String type) { ... } // 使用对象的属性 @Cacheable(value = "userCache", key = "#user.id") public User updateUser(User user) { ... } // 使用方法名 + 参数（防止不同方法 key 冲突） @Cacheable(value = "userCache", key = "'getUser_' + #userId") public User getUserById(Long userId) { ... }踩坑预警：key 冲突是缓存的头号杀手。想象一下，getUserById(1L) 和 getOrderById(1L) 用了同一个 key，返回数据直接串台，这 bug 够你查三天。3.3 condition —— 有条件地缓存不是所有数据都值得缓存，比如查出来是 null 的情况：// 只有 userId > 0 才缓存 @Cacheable(value = "userCache", key = "#userId", condition = "#userId > 0") public User getUserById(Long userId) { ... } // 参数长度大于 3 才缓存 @Cacheable(value = "userCache", condition = "#name.length() > 3") public User getUserByName(String name) { ... }condition 在方法执行前判断，决定要不要走缓存逻辑。3.4 unless —— 有条件地不缓存和 condition 相反，unless 在方法执行后判断，可以根据返回值决定是否缓存：// 结果为 null 不缓存 @Cacheable(value = "userCache", key = "#userId", unless = "#result == null") public User getUserById(Long userId) { ... } // 结果状态异常不缓存 @Cacheable(value = "userCache", unless = "#result.status != 1") public User getUserById(Long userId) { ... }划重点：condition 是"要不要查缓存"，unless 是"查完了要不要存"。3.5 keyGenerator —— 自定义 key 生成器当 SpEL 表达式满足不了你的时候，可以自己写生成器：@Configuration public class CacheConfig { @Bean("myKeyGenerator") public KeyGenerator keyGenerator() { return (target, method, params) -> { StringBuilder sb = new StringBuilder(); sb.append(target.getClass().getSimpleName()); sb.append(":"); sb.append(method.getName()); sb.append(":"); for (Object param : params) { sb.append(param.toString()).append("_"); } return sb.toString(); }; } } // 使用 @Cacheable(value = "userCache", keyGenerator = "myKeyGenerator") public User getUserById(Long userId) { ... }四、先搞清楚：查一次数据要多久？在聊缓存实现之前，我们先看一组数据，感受一下不同存储介质的速度差异：存储类型单次查询耗时相对速度备注本地缓存（Caffeine）几十纳秒 ~ 几微秒⚡⚡⚡⚡⚡内存直接读取，快到离谱Redis（本机）0.1ms ~ 0.5ms⚡⚡⚡⚡走网络 + 序列化Redis（同机房）0.5ms ~ 2ms⚡⚡⚡网络延迟是大头Redis（跨机房）2ms ~ 10ms⚡⚡跨机房网络延迟MySQL（简单查询）1ms ~ 10ms⚡⚡走网络 + SQL 解析 + 磁盘 IOMySQL（复杂查询）10ms ~ 100ms+⚡关联、排序、没走索引...划重点：本地缓存比 Redis 快 100~1000 倍，比 MySQL 快 1000~10000 倍。这意味着什么？如果一个接口 QPS 是 1000，每次查 MySQL 耗时 10ms，光数据库查询就要消耗 10 秒的 CPU 时间换成本地缓存，同样的请求只需要 0.01 秒所以，能用本地缓存就别用 Redis，能用 Redis 就别查数据库。五、本地缓存：Caffeine 才是真的快很多同学一提缓存就想到 Redis，但其实对于单机场景或者读多写少的热点数据，本地缓存才是性能天花板。5.1 为什么选 Caffeine？Spring Boot 2.x 之后，官方钦定 Caffeine 作为本地缓存的首选实现（之前是 Guava Cache）。Caffeine 号称"地表最强本地缓存"，采用 W-TinyLFU 淘汰算法，命中率吊打一众竞品。5.2 引入依赖 com.github.ben-manes.caffeine caffeine 5.3 配置方式一：简单配置（application.yml）spring: cache: type: caffeine caffeine: spec: maximumSize=10000,expireAfterWrite=5mspec 参数说明：maximumSize=10000：最多缓存 10000 条数据expireAfterWrite=5m：写入后 5 分钟过期expireAfterAccess=5m：最后一次访问后 5 分钟过期5.4 配置方式二：精细控制（配置类）@Configuration @EnableCaching public class CaffeineCacheConfig { @Bean public CacheManager cacheManager() { CaffeineCacheManager cacheManager = new CaffeineCacheManager(); cacheManager.setCaffeine(Caffeine.newBuilder() .initialCapacity(100) // 初始容量 .maximumSize(10000) // 最大条目数 .expireAfterWrite(5, TimeUnit.MINUTES) // 写入后过期时间 .recordStats()); // 开启统计（生产环境可关闭） return cacheManager; } }5.5 不同缓存不同配置用户信息缓存 30 分钟，配置信息缓存 24 小时？安排：@Configuration @EnableCaching public class CaffeineCacheConfig { @Bean public CacheManager cacheManager() { SimpleCacheManager cacheManager = new SimpleCacheManager(); List<CaffeineCache> caches = new ArrayList<>(); // 用户缓存：最多 5000 条，30 分钟过期 caches.add(new CaffeineCache("userCache", Caffeine.newBuilder() .maximumSize(5000) .expireAfterWrite(30, TimeUnit.MINUTES) .build())); // 配置缓存：最多 1000 条，24 小时过期 caches.add(new CaffeineCache("configCache", Caffeine.newBuilder() .maximumSize(1000) .expireAfterWrite(24, TimeUnit.HOURS) .build())); // 热点数据：最多 100 条，按访问时间过期 caches.add(new CaffeineCache("hotDataCache", Caffeine.newBuilder() .maximumSize(100) .expireAfterAccess(10, TimeUnit.MINUTES) .build())); cacheManager.setCaches(caches); return cacheManager; } }5.6 本地缓存的局限性本地缓存虽然快，但也有硬伤：问题说明多实例数据不一致A 机器更新了，B 机器还是旧数据内存有限缓存太多会 OOM重启即失效应用重启，缓存清空适用场景：单机应用数据量小、更新不频繁的配置信息可以容忍短时间不一致的场景极致性能要求的热点数据（配合 Redis 做二级缓存）六、配合 Redis 使用光有注解没有存储实现，就像光有嘴没有胃。下面配置 Redis 作为缓存后端：6.1 配置文件spring: redis: host: localhost port: 6379 password: your_password # 没密码就删掉这行 database: 0 cache: type: redis redis: time-to-live: 3600000 # 缓存过期时间，单位毫秒（1小时） key-prefix: "app:" # key 前缀 use-key-prefix: true cache-null-values: false # 是否缓存 null 值6.2 自定义 Redis 缓存配置想要更精细的控制？上配置类：@Configuration @EnableCaching public class RedisCacheConfig { @Bean public RedisCacheManager cacheManager(RedisConnectionFactory factory) { // 默认配置 RedisCacheConfiguration defaultConfig = RedisCacheConfiguration.defaultCacheConfig() .entryTtl(Duration.ofHours(1)) // 默认过期时间 1 小时 .serializeKeysWith(RedisSerializationContext.SerializationPair .fromSerializer(new StringRedisSerializer())) .serializeValuesWith(RedisSerializationContext.SerializationPair .fromSerializer(new GenericJackson2JsonRedisSerializer())) .disableCachingNullValues(); // 不缓存 null // 针对不同缓存名称设置不同配置 Map<String, RedisCacheConfiguration> configMap = new HashMap<>(); configMap.put("userCache", defaultConfig.entryTtl(Duration.ofMinutes(30))); // 用户缓存 30 分钟 configMap.put("configCache", defaultConfig.entryTtl(Duration.ofHours(24))); // 配置缓存 24 小时 return RedisCacheManager.builder(factory) .cacheDefaults(defaultConfig) .withInitialCacheConfigurations(configMap) .build(); } }七、缓存三兄弟：@Cacheable、@CachePut、@CacheEvict注解作用适用场景@Cacheable查缓存，没有就执行方法并缓存结果查询操作@CachePut无论如何都执行方法，并更新缓存更新操作@CacheEvict删除缓存删除/更新操作7.1 @CachePut：更新必备@CachePut(value = "userCache", key = "#user.id") public User updateUser(User user) { // 一定会执行，然后把返回值更新到缓存 return userRepository.save(user); }7.2 @CacheEvict：清理门户// 删除单个缓存 @CacheEvict(value = "userCache", key = "#userId") public void deleteUser(Long userId) { userRepository.deleteById(userId); } // 清空整个缓存（慎用！） @CacheEvict(value = "userCache", allEntries = true) public void clearAllUserCache() { // 核弹级操作 } // 方法执行前就删除缓存 @CacheEvict(value = "userCache", key = "#userId", beforeInvocation = true) public void deleteUser(Long userId) { // 即使方法抛异常，缓存也已经删了 }7.3 @Caching：组合技一个方法需要操作多个缓存？用 @Caching 打包：@Caching( cacheable = { @Cacheable(value = "userCache", key = "#userId") }, evict = { @CacheEvict(value = "userListCache", allEntries = true) } ) public User getUserById(Long userId) { ... }八、血泪踩坑实录坑 1：同类方法调用，缓存不生效@Service public class UserService { @Cacheable(value = "userCache", key = "#userId") public User getUserById(Long userId) { ... } public User getUser(Long userId) { // 这样调用，缓存不生效！ return this.getUserById(userId); } }原因：Spring Cache 基于 AOP 代理，this.xxx() 是直接调用，不走代理。解决方案：注入自己（有点魔幻但有效）使用 AopContext.currentProxy()拆分到不同的 Service@Service public class UserService { @Autowired @Lazy private UserService self; // 注入自己 @Cacheable(value = "userCache", key = "#userId") public User getUserById(Long userId) { ... } public User getUser(Long userId) { return self.getUserById(userId); // 通过代理调用 } }坑 2：缓存对象没实现 Serializable用 Redis 缓存时，对象必须可序列化：public class User implements Serializable { private static final long serialVersionUID = 1L; // ... }不然你会收获一个 SerializationException，并且怀疑人生。坑 3：缓存穿透查一个不存在的数据，每次都穿透到数据库：// 错误示范：null 不缓存 @Cacheable(value = "userCache", key = "#userId", unless = "#result == null") public User getUserById(Long userId) { return userRepository.findById(userId).orElse(null); }解决方案：缓存空对象或使用布隆过滤器// 缓存空对象，但设置较短过期时间 @Cacheable(value = "userCache", key = "#userId") public User getUserById(Long userId) { User user = userRepository.findById(userId).orElse(null); if (user == null) { // 返回一个空对象标记，而不是 null return User.EMPTY; } return user; }坑 4：缓存雪崩大量缓存同时过期，请求全打到数据库。解决方案：过期时间加随机值@Bean public RedisCacheManager cacheManager(RedisConnectionFactory factory) { return RedisCacheManager.builder(factory) .cacheDefaults(RedisCacheConfiguration.defaultCacheConfig() .entryTtl(Duration.ofHours(1).plusMinutes(new Random().nextInt(30)))) .build(); }九、最佳实践总结缓存命名要有意义：userCache、orderCache，别用 cache1、cache2key 设计要唯一：加上业务前缀，避免冲突合理设置过期时间：热数据短一点，冷数据长一点null 值要处理：要么缓存空对象，要么用布隆过滤器更新和删除要同步：数据变了，缓存也要跟着变监控要到位：缓存命中率、过期淘汰数都要关注结语缓存用得好，下班回家早。@Cacheable 是 Spring 送给我们的礼物，用好它，你的数据库会感谢你，你的 DBA 会感谢你，你的用户也会感谢你（虽然他们并不知道发生了什么）。记住：每一次无意义的数据库查询，都是对服务器资源的犯罪。如果这篇文章帮你少写了几行代码，少挨了几次 DBA 的骂，记得点个赞。毕竟，独乐乐不如众乐乐，大家一起优雅，才是真的优雅。