很多接口的慢,不是慢在算法,而是慢在反复查数据库。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 调优。




