Spring Boot 缓存 @Cacheable 实战

很多接口的慢,不是慢在算法,而是慢在反复查数据库。Spring Boot 的缓存抽象就是为了解决这一类问题而生的——它用 @Cacheable 一个注解,就能把方法的返回结果按参数缓存起来,下次同样的请求直接命中缓存、跳过方法体。本文从注解基础讲到 Caffeine/Redis 缓存管理器配置,再延伸到多级缓存与穿透/击穿/雪崩三大经典坑,给出可直接落地的代码。

一、为什么需要缓存抽象

在没有缓存抽象之前,团队往往把”查一次数据库、塞进 Map、下次先查 Map”的逻辑分散写在各个 Service 里。这样做有三个问题:缓存逻辑和业务逻辑耦合、过期策略各自为政、本地 Map 在集群多实例下互相看不到。Spring 的缓存抽象把这一切收敛成统一的注解 + 可插拔的 CacheManager,业务代码只声明”这个方法的结果值得被缓存”,至于存在哪、怎么过期,交给配置决定。

它解决的是”反复读、很少变”场景的加速:配置项、热点商品详情、用户 profile、字典表、聚合统计结果等。对于写多读少或强实时数据,缓存反而是负担。如果你正在做 Spring Boot 3 的迁移升级,缓存抽象层的配置方式基本无变化,可参考Spring Boot 3 升级踩坑实录

二、快速上手:三个核心注解

缓存抽象提供三个最常用注解,先看一段最朴素的使用:

@Service
public class UserService {
    // 以方法参数为 key,把返回值缓存到名为 users 的缓存区
    @Cacheable(cacheNames = "users", key = "#id")
    public UserDTO getUser(Long id) {
        return userRepository.findById(id).orElseThrow();
    }

    // 方法执行后写入缓存(常用于更新场景)
    @CachePut(cacheNames = "users", key = "#user.id")
    public UserDTO saveUser(UserDTO user) {
        return userRepository.save(user);
    }

    // 删除时同步淘汰缓存
    @CacheEvict(cacheNames = "users", key = "#id")
    public void removeUser(Long id) {
        userRepository.deleteById(id);
    }
}

三者职责差异非常清晰,用一张表对比:

注解是否跳过方法体典型用途
@Cacheable命中缓存则跳过读多写少的热点查询
@CachePut永远执行,再写缓存更新数据后刷新缓存
@CacheEvict永远执行,删缓存删除/失效数据时淘汰

三、本地缓存:Caffeine 缓存管理器

Spring Boot 默认只提供一个内存 ConcurrentMapCacheManager,没有淘汰与容量上限,生产不推荐。真正好用的是 Caffeine——一个高性能本地缓存库,支持基于大小、时间和引用的淘汰策略。先引入依赖:

implementation 'org.springframework.boot:spring-boot-starter-cache'
implementation 'com.github.ben-manes.caffeine:caffeine'

然后定义一个带 TTL 与容量上限的 CacheManager:

@Configuration
@EnableCaching
public class CacheConfig {
    @Bean
    public CacheManager cacheManager() {
        Caffeine<Object, Object> caffeine = Caffeine.newBuilder()
                .initialCapacity(100)
                .maximumSize(10_000)            // 最多 1 万条,超出按 LRU 淘汰
                .expireAfterWrite(Duration.ofMinutes(10))  // 写入 10 分钟后过期
                .recordStats();                 // 开启命中率统计
        return new CaffeineCacheManager("users", "products", "dict") {{
            setCaffeine(caffeine);
        }};
    }
}

@EnableCaching 是总开关,漏掉它注解不会生效。maximumSize 控制内存占用,expireAfterWrite 控制新鲜度,二者配合能挡住绝大多数”缓存无限膨胀”事故。本地缓存延迟极低(亚毫秒),但只在单实例内有效,多副本部署时各实例缓存不一致——这就是引入分布式缓存的理由。

四、分布式缓存:接入 Redis

当服务横向扩展成多个实例,本地缓存就不够了。把缓存后端换成 Redis,所有实例共享同一份数据。Spring Data Redis 提供了 RedisCacheManager:

@Bean
public RedisCacheManager redisCacheManager(RedisConnectionFactory factory) {
    RedisCacheConfiguration cfg = RedisCacheConfiguration.defaultCacheConfig()
            .entryTtl(Duration.ofMinutes(15))        // 统一 TTL
            .disableCachingNullValues()              // 不缓存 null,防止穿透
            .serializeValuesWith(RedisSerializationContext
                    .SerializationPair.fromSerializer(new GenericJackson2JsonRedisSerializer()));
    return RedisCacheManager.builder(factory)
            .cacheDefaults(cfg)
            .withCacheConfiguration("users", cfg.entryTtl(Duration.ofMinutes(5)))
            .build();
}

注意 disableCachingNullValues()——它和后面讲的”缓存空值防穿透”是相反的两个流派:这里选择直接不缓存 null,由应用层对空结果做短 TTL 缓存。两种思路都能防穿透,关键是一致,别既缓存 null 又 disable 了。Redis 作为缓存后端的整体设计原则,可延伸阅读Redis 缓存设计:穿透、击穿、雪崩与最佳实践

五、注解进阶:key、condition、sync

真实业务里,缓存键往往不是单一参数。Spring 用 SpEL 表达式描述 key 与条件:

// 多参数拼 key,并只在 status 为生效态时缓存
@Cacheable(cacheNames = "orders",
           key = "#userId + ':' + #status",
           condition = "#status == 'ACTIVE'",
           unless = "#result == null")
public List<OrderDTO> listOrders(Long userId, String status) { ... }

// sync=true:缓存未命中时只放一个线程去加载,其余阻塞等待,专治"击穿"
@Cacheable(cacheNames = "hot", key = "#id", sync = true)
public ProductDTO getHotProduct(Long id) { ... }

condition 在方法执行前判断要不要进缓存,unless 在执行后判断结果值是否值得存(比如结果为空就不存)。sync=true 是解决缓存击穿(热点 key 失效瞬间海量请求同时回源)的官方推荐写法,底层用同步块保证只有一个线程去查库。需要多条注解叠加时,用 @Caching 包裹:

@Caching(
    cacheable = @Cacheable(cacheNames = "user", key = "#id"),
    evict = @CacheEvict(cacheNames = "userList", allEntries = true)
)
public UserDTO reload(Long id) { ... }

六、多级缓存:本地 + Redis 组合

高并发场景下,本地缓存 + Redis 的双层结构是性价比最高的组合:本地挡住绝大多数读,Redis 保证多实例一致。思路是自定义一个 CompositeCacheManager,本地优先、Redis 兜底:

@Bean
public CacheManager multiLevelCacheManager(
        CaffeineCacheManager local, RedisCacheManager redis) {
    CompositeCacheManager composite = new CompositeCacheManager();
    composite.setCacheManagers(List.of(local, redis));
    composite.setFallbackToNoOpCache(false);
    return composite;
}

写流程要先清 Redis 再清本地,读流程本地命中即返回。这种结构把”99% 的读”压在进程内存里,只把回源和一致性交给 Redis,能显著降低 Redis 压力与网络开销。不过要注意本地缓存的失效延迟——它不会主动感知 Redis 的写入,因此 TTL 要设得短一些(如 30 秒到 1 分钟),容忍短暂不一致换取性能。

七、三大经典坑:穿透、击穿、雪崩

缓存不是加上就完事,下面三兄弟是生产事故常客:

问题成因解法
穿透查不存在的数据,缓存与库都没有,请求每次打库缓存空值(短 TTL)、布隆过滤器拦非法 id
击穿单个热点 key 过期瞬间,海量请求同时回源@Cacheable(sync=true)、热点 key 不过期/续期
雪崩大量 key 同一时刻集中过期TTL 加随机抖动、多级缓存分散、限流降级

穿透最典型的修法是”空值也缓存但给极短 TTL”(如 60 秒),让恶意或异常的 id 也被挡在缓存层;更彻底的是布隆过滤器,在查库前先判断 id 是否可能存在。雪崩的核心是”别让过期时间撞车”:expireAfterWrite(10min) 改成 10min + random(0, 5min),把失效时间打散。击穿用上面的 sync=true 即可,Spring 已经帮你想好了。

八、与事务、异步的协作注意

两个容易踩的协作细节。其一,@CacheEvict 默认在方法执行后才淘汰缓存,如果方法抛异常回滚,缓存不会被清,但数据也没提交,整体仍然一致,无需担心;若希望”先清缓存再执行”,加 beforeInvocation = true。其二,缓存写入与事务边界:在 @Transactional 方法里调用带缓存的方法,由于 Spring 代理机制,同类内部调用不会触发缓存切面,必须走注入的 Bean 或从外部调用——这条和Spring Boot 异步线程池 @Async 配置里提到的”同类自调用不生效”是同一个坑。

另外,缓存对象建议用 DTO 而非 JPA 实体:实体带懒加载与持久化上下文,序列化进 Redis 时容易触发 LazyInitializationException,也会把整个对象图无脑写进缓存,既慢又占空间。配合Spring Boot 参数校验 @Valid做入参防御,能让缓存键更干净可控。

九、小结

Spring 缓存抽象的精髓是”业务只声明意图,存储与策略可替换”。落地顺序建议:先用 @Cacheable 把热点读接口圈出来验证命中率,再用 Caffeine 顶住单实例本地缓存,多实例时切换或叠加 Redis,最后针对穿透/击穿/雪崩逐条加固。监控上记得打开 recordStats() 看命中率,命中率上不去的缓存只是徒增复杂度。想进一步压榨启动与运行开销,可以看Spring Boot GraalVM 原生镜像编译实战Docker 镜像瘦身实战,而 JVM 层面的 GC 行为会直接影响缓存对象的存活与回收节奏,相关调优见JVM 内存模型与 GC 调优

上一篇 k9s 实战:Kubernetes 集群终端管理效率翻倍
下一篇 PostgreSQL 分区表实战:设计与性能优化