搜 索

Cacheable注解缓存的使用

  • 213阅读
  • 2022年09月18日
  • 0评论
首页 / 编程 / 正文

前言:一个惨痛的教训

话说那年,我写了一个"用户信息查询"接口,代码简洁优雅,自我感觉良好。上线后 QPS 一路飙升,数据库 CPU 直接拉满,DBA 小哥深夜给我打电话,语气中透着一丝疲惫和杀意。

"兄弟,你这接口一秒钟查同一个用户几百次,图啥呢?"

那一刻,我顿悟了:缓存,是程序员对数据库最基本的尊重。

一、@Cacheable 是什么?

@Cacheable 是 Spring Cache 提供的声明式缓存注解,属于"我不想写重复代码"系列的终极解决方案。

它的核心思想很朴素:

  • 第一次调用方法 → 执行方法,结果存缓存
  • 后续相同参数调用 → 直接返回缓存,方法体压根不执行

简单说就是:"这活我干过了,答案在这,别烦我。"

二、快速上手

2.1 添加依赖

<!-- Spring Boot Starter Cache -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-cache</artifactId>
</dependency>

<!-- 如果用 Redis 作为缓存实现 -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>

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 解析 + 磁盘 IO
MySQL(复杂查询)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 引入依赖

<dependency>
    <groupId>com.github.ben-manes.caffeine</groupId>
    <artifactId>caffeine</artifactId>
</dependency>

5.3 配置方式一:简单配置(application.yml)

spring:
  cache:
    type: caffeine
    caffeine:
      spec: maximumSize=10000,expireAfterWrite=5m

spec 参数说明:

  • 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() 是直接调用,不走代理。

解决方案

  1. 注入自己(有点魔幻但有效)
  2. 使用 AopContext.currentProxy()
  3. 拆分到不同的 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();
}

九、最佳实践总结

  1. 缓存命名要有意义userCacheorderCache,别用 cache1cache2
  2. key 设计要唯一:加上业务前缀,避免冲突
  3. 合理设置过期时间:热数据短一点,冷数据长一点
  4. null 值要处理:要么缓存空对象,要么用布隆过滤器
  5. 更新和删除要同步:数据变了,缓存也要跟着变
  6. 监控要到位:缓存命中率、过期淘汰数都要关注

结语

缓存用得好,下班回家早。@Cacheable 是 Spring 送给我们的礼物,用好它,你的数据库会感谢你,你的 DBA 会感谢你,你的用户也会感谢你(虽然他们并不知道发生了什么)。

记住:每一次无意义的数据库查询,都是对服务器资源的犯罪。


如果这篇文章帮你少写了几行代码,少挨了几次 DBA 的骂,记得点个赞。毕竟,独乐乐不如众乐乐,大家一起优雅,才是真的优雅。

评论区
暂无评论
avatar