Spring Boot: 缓存机制
最后更新:2026-08-26
缓存是性能优化的第一招——读多写少的数据,一次缓存省千次查询。
1. 你将学到
@EnableCaching与@Cacheable/@CachePut/@CacheEvict注解- 缓存 key 策略与 condition / unless 条件过滤
- Caffeine 本地缓存配置:TTL / 最大容量 / 淘汰策略
- 缓存穿透与缓存击穿问题的应对策略
- Alice 为日访问量百万的热门商品列表添加 Caffeine 缓存
2. 一个性能工程师的真实故事
(1) 痛点:数据库被打爆
Charlie 在产品发布会上展示了 OrderFlow 的秒杀功能,瞬间涌入 500 thousand 请求,全部打到数据库查询热门商品。MySQL 连接池耗尽,整个系统宕机 10 分钟,直接损失 100 thousand USD。Alice 分析发现,商品详情查询占了总请求的 80%,但这些数据 1 小时内几乎不变。
(2) Spring Cache 的解法
一条注解就能加缓存:
JAVA
@Cacheable(value = "products", key = "#id")
public Product getProduct(Long id) {
return productRepository.findById(id).orElseThrow();
}
第一次查询走数据库,后续直接从缓存读取。
(3) 收益
Alice 为热门商品添加 Caffeine 缓存后,80% 的查询命中缓存,数据库压力降低 5 倍,P99 延迟从 500ms 降到 5ms,成功支撑了下次秒杀活动。
3. Spring Cache 注解体系
(1) 核心注解
| 注解 | 作用 | 典型场景 |
|---|---|---|
@Cacheable |
查询时先查缓存,命中直接返回 | 查询操作 |
@CachePut |
执行方法并更新缓存 | 更新操作 |
@CacheEvict |
清除缓存 | 删除操作 |
@Caching |
组合多个缓存操作 | 复杂场景 |
@EnableCaching |
启用缓存支持 | 配置类 |
graph TD
A["Client Request"] --> B{"@Cacheable<br/>Cache Hit?"}
B -->|Yes| C["Return Cached Data"]
B -->|No| D["Execute Method<br/>Query Database"]
D --> E["Store Result in Cache"]
E --> F["Return Result"]
▶ 示例: @Cacheable 查询缓存
JAVA
@Service
@EnableCaching
public class ProductService {
private final ProductRepository productRepository;
public ProductService(ProductRepository productRepository) {
this.productRepository = productRepository;
}
@Cacheable(value = "products", key = "#id")
public Product getProduct(Long id) {
log.info("Cache miss, querying database for product {}", id);
return productRepository.findById(id)
.orElseThrow(() -> new ResourceNotFoundException("Product", id));
}
@Cacheable(value = "product-list", key = "#keyword + '-' + #page + '-' + #size")
public Page<Product> searchProducts(String keyword, int page, int size) {
return productRepository.findByNameContaining(keyword,
PageRequest.of(page, size));
}
}
输出:
TEXT
📖 仅展示
// 执行成功
▶ 示例: @CachePut 更新缓存
JAVA
@CachePut(value = "products", key = "#result.id")
public Product updateProduct(Long id, UpdateProductRequest request) {
Product product = getProduct(id);
product.setName(request.name());
product.setPrice(request.price());
product.setStock(request.stock());
return productRepository.save(product);
}
输出:
TEXT
📖 仅展示
// 执行成功
▶ 示例: @CacheEvict 清除缓存
JAVA
@CacheEvict(value = "products", key = "#id")
public void deleteProduct(Long id) {
productRepository.deleteById(id);
}
@CacheEvict(value = "product-list", allEntries = true)
public void clearProductListCache() {
log.info("Cleared all product list cache entries");
}
输出:
TEXT
📖 仅展示
// 执行成功
| 注解 | 是否执行方法 | 是否更新缓存 | 适用操作 |
|---|---|---|---|
@Cacheable |
缓存未命中时执行 | 缓存未命中时更新 | 查询 |
@CachePut |
总是执行 | 总是更新 | 更新 |
@CacheEvict |
总是执行 | 清除指定缓存 | 删除 |
4. 缓存 Key 策略
(1) Key 生成规则
| Key 策略 | SpEL 表达式 | 生成示例 |
|---|---|---|
| 单参数 | key = "#id" |
products::123 |
| 多参数组合 | key = "#keyword + '-' + #page" |
product-list::laptop-0 |
| 对象属性 | key = "#request.category" |
products::electronics |
| 默认 | 不指定 | 所有参数组合的 hashCode |
▶ 示例: 自定义 KeyGenerator
JAVA
@Configuration
@EnableCaching
public class CacheConfig {
@Bean("customKeyGenerator")
public KeyGenerator customKeyGenerator() {
return (target, method, params) -> {
StringBuilder sb = new StringBuilder();
sb.append(target.getClass().getSimpleName()).append(":");
sb.append(method.getName()).append(":");
for (Object param : params) {
sb.append(param != null ? param.toString() : "null").append(".");
}
return sb.toString();
};
}
}
// Usage
@Cacheable(value = "products", keyGenerator = "customKeyGenerator")
public Product getProduct(Long id) { /* ... */ }
输出:
TEXT
📖 仅展示
// 执行成功
(2) 条件过滤
▶ 示例: condition 和 unless
JAVA
// Only cache products with price > 100
@Cacheable(value = "expensive-products", key = "#id",
condition = "#id != null")
public Product getExpensiveProduct(Long id) { /* ... */ }
// Do not cache if result has stock == 0
@Cacheable(value = "products", key = "#id",
unless = "#result.stock == 0")
public Product getProduct(Long id) { /* ... */ }
输出:
TEXT
📖 仅展示
// 执行成功
| 参数 | 执行时机 | 含义 |
|---|---|---|
condition |
方法执行前 | 满足条件才缓存 |
unless |
方法执行后 | 满足条件不缓存 |
5. Caffeine 本地缓存配置
(1) Caffeine 特性
| 特性 | 说明 |
|---|---|
| 高性能 | 比 Guava Cache 吞吐量高 30% |
| 异步刷新 | 支持后台异步刷新过期缓存 |
| 统计信息 | 命中率、加载时间等 |
| Window TinyLFU | 最优淘汰算法 |
▶ 示例: Caffeine 缓存配置
XML
<dependency>
<groupId>com.github.ben-manes.caffeine</groupId>
<artifactId>caffeine</artifactId>
</dependency>
输出:
TEXT
📖 仅展示
// 执行成功
JAVA
@Configuration
@EnableCaching
public class CacheConfig {
@Bean
public CacheManager cacheManager() {
CaffeineCacheManager manager = new CaffeineCacheManager();
manager.setCaffeine(caffeineCacheBuilder());
return manager;
}
@Bean
public Caffeine<Object, Object> caffeineCacheBuilder() {
return Caffeine.newBuilder()
.expireAfterWrite(Duration.ofMinutes(30)) // TTL: 30 min after write
.expireAfterAccess(Duration.ofHours(1)) // Evict after 1 hour idle
.maximumSize(10_000) // Max 10,000 entries
.recordStats(); // Enable statistics
}
// Per-cache configuration
@Bean
public CacheManager multiCacheManager() {
Map<String, CaffeineCache> caches = Map.of(
"products", buildCache(Caffeine.newBuilder()
.expireAfterWrite(Duration.ofHours(1))
.maximumSize(5_000)),
"product-list", buildCache(Caffeine.newBuilder()
.expireAfterWrite(Duration.ofMinutes(10))
.maximumSize(1_000))
);
return new SimpleCacheManager() {{
setCaches(caches.values());
}};
}
private CaffeineCache buildCache(Caffeine<Object, Object> builder) {
return new CaffeineCache("cache", builder.build());
}
}
| 配置项 | 含义 | 推荐值 |
|---|---|---|
expireAfterWrite |
写入后过期时间 | 30min - 1h |
expireAfterAccess |
最后访问后过期时间 | 1h - 24h |
maximumSize |
最大缓存条目数 | 根据内存调整 |
recordStats |
启用统计信息 | 开发环境开启 |
6. 缓存穿透与缓存击穿
(1) 常见缓存问题
| 问题 | 原因 | 后果 | 解决方案 |
|---|---|---|---|
| 缓存穿透 | 查询不存在的数据 | 每次都打数据库 | 缓存空值 / 布隆过滤器 |
| 缓存击穿 | 热点 key 过期瞬间大量请求 | 数据库压力骤增 | 互斥锁 / 永不过期 + 异步刷新 |
| 缓存雪崩 | 大量 key 同时过期 | 数据库压力骤增 | 过期时间加随机偏移 |
graph TD
A["Cache Penetration<br/>Query non-existent data"] --> B["Solution: Cache null value"]
C["Cache Breakdown<br/>Hot key expires"] --> D["Solution: Mutex lock / async refresh"]
E["Cache Avalanche<br/>Many keys expire at once"] --> F["Solution: Random TTL offset"]
▶ 示例: 缓存空值防止穿透
JAVA
@Cacheable(value = "products", key = "#id", unless = "#result == null && #result != ''")
public Product getProduct(Long id) {
return productRepository.findById(id).orElse(null);
}
// Alternative: Cache null with short TTL
@Bean
public CacheManager cacheManager() {
return new CaffeineCacheManager() {{
setCaffeine(Caffeine.newBuilder()
.expireAfterWrite(Duration.ofMinutes(5))
.maximumSize(10_000));
}};
}
输出:
TEXT
📖 仅展示
// 执行成功
7. 综合示例:OrderFlow 商品缓存完整实现
JAVA
// CacheConfig.java
@Configuration
@EnableCaching
public class CacheConfig {
@Bean
public CacheManager cacheManager() {
Map<String, Cache> caches = new HashMap<>();
caches.put("products", Caffeine.newBuilder()
.expireAfterWrite(Duration.ofHours(1))
.maximumSize(5_000)
.recordStats()
.build());
caches.put("product-list", Caffeine.newBuilder()
.expireAfterWrite(Duration.ofMinutes(10))
.maximumSize(500)
.build());
caches.put("product-stats", Caffeine.newBuilder()
.expireAfterWrite(Duration.ofMinutes(5))
.maximumSize(100)
.build());
SimpleCacheManager manager = new SimpleCacheManager();
manager.setCaches(caches.values().stream()
.map(c -> new CaffeineCache(c.getClass().getName(), c))
.toList());
return manager;
}
}
// ProductService.java with caching
@Service
public class ProductService {
private final ProductRepository productRepository;
public ProductService(ProductRepository productRepository) {
this.productRepository = productRepository;
}
@Cacheable(value = "products", key = "#id")
public Product getProduct(Long id) {
return productRepository.findById(id)
.orElseThrow(() -> new ResourceNotFoundException("Product", id));
}
@Cacheable(value = "product-list", key = "#keyword + '-' + #pageable.pageNumber + '-' + #pageable.pageSize")
public Page<Product> searchProducts(String keyword, Pageable pageable) {
return productRepository.findByNameContaining(keyword, pageable);
}
@CachePut(value = "products", key = "#result.id")
@CacheEvict(value = "product-list", allEntries = true)
public Product updateProduct(Long id, String name, BigDecimal price, Integer stock) {
Product product = getProduct(id);
product.setName(name);
product.setPrice(price);
product.setStock(stock);
return productRepository.save(product);
}
@CacheEvict(value = {"products", "product-list"}, allEntries = true)
public void deleteProduct(Long id) {
productRepository.deleteById(id);
}
}
❓ 常见问题
Q @Cacheable 在同类方法互调时生效吗?
A 不生效。和 @Transactional 一样,@Cacheable 基于 AOP 代理,同类方法互调不走代理。需要拆分类或注入代理。
Q Caffeine 和 Redis 缓存该选哪个?
A Caffeine 是本地缓存(单进程),速度极快但不支持集群共享。Redis 是分布式缓存,所有实例共享但需要网络开销。通常两者组合使用(L1 Caffeine + L2 Redis)。
Q 缓存和数据库一致性如何保证?
A 常用策略:1)Cache Aside(先更新数据库,再删缓存);2)Write Through(同步写缓存和数据库);3)Write Behind(先写缓存,异步写数据库)。推荐 Cache Aside + 延迟双删。
Q maximumSize 和 maximumWeight 有什么区别?
A maximumSize 限制条目数量,maximumWeight 限制总权重(需要指定 weigher)。如果缓存对象大小差异大,用 maximumWeight 更精确。
Q 如何监控缓存命中率?
A Caffeine 启用 recordStats() 后,通过 Cache.stats() 获取命中率。配合 Micrometer 注册到 Prometheus,在 Grafana 可视化。
Q @CachePut 和 @Cacheable 可以用在同一个方法上吗?
A 不建议。@Cacheable 可能跳过方法执行(缓存命中时),@CachePut 总是执行方法并更新缓存,两者语义冲突。
📖 小节
@Cacheable查缓存 → 缓存未命中查数据库 → 写入缓存@CachePut总是执行方法并更新缓存,适合更新操作@CacheEvict清除缓存,allEntries = true清空整个缓存空间- Key 策略用 SpEL 表达式,
condition/unless过滤缓存条件 - Caffeine 配置 TTL、最大容量、淘汰策略,高性能本地缓存
- 缓存三大问题:穿透(缓存空值)、击穿(互斥锁)、雪崩(随机 TTL)
📝 作业
-
基础题(难度⭐):为 OrderFlow 的商品查询添加
@Cacheable缓存,验证第二次查询不打印 SQL 日志(缓存命中)。 -
进阶题(难度⭐⭐):配置 Caffeine 缓存管理器,为不同缓存空间设置不同的 TTL 和最大容量。实现
@CachePut更新商品时同步更新缓存,@CacheEvict删除商品时清除缓存。 -
挑战题(难度⭐⭐⭐):实现两级缓存(L1 Caffeine + L2 Redis),自定义 CacheResolver 根据缓存名决定使用本地还是分布式缓存,思考两级缓存的一致性策略。