这是《Spring Boot 与 Spring Cloud 零基础实战指南》的独立章节版。本章从概念、实操和生产排查三个视角展开,代码块保留了原书可直接运行的版本。 缓存用于降低延迟、减少数据库压力、保存临时计算结果。它同时引入一致性问题、过期策略、击穿穿透雪崩和容量治理。是否加缓存,应先量化慢在哪里。
12.1 缓存层次
请求
-> 本地缓存
-> 分布式缓存 Redis
-> 数据库
| 缓存 | 特点 |
|---|---|
| 本地 Caffeine | 低延迟、进程内、容量受 JVM 堆限制 |
| Redis | 多实例共享、容量独立、网络开销 |
| 多级缓存 | 高性能但一致性和失效更复杂 |
| CDN/网关缓存 | 静态或准静态内容 |
多数系统不应一开始就做复杂多级缓存。先 Redis 或 Caffeine,遇到明确收益再演进。
12.2 Spring Cache 抽象
启用:
@EnableCaching
@SpringBootApplication
public class Application {
}
使用:
@Service
public class ProductService {
@Cacheable(cacheNames = "products", key = "#id")
public ProductView get(Long id) {
return repository.findView(id);
}
@CacheEvict(cacheNames = "products", key = "#id")
public void update(Long id, UpdateProductCommand command) {
repository.update(id, command);
}
}
常用注解:
| 注解 | 作用 |
|---|---|
@Cacheable |
先查缓存,未命中执行方法后写入 |
@CachePut |
执行方法并更新缓存 |
@CacheEvict |
删除缓存 |
@Caching |
组合多个缓存操作 |
@CacheConfig |
类级公共配置 |
12.3 key 生成
默认 key 基于参数生成。复杂场景应显式:
@Cacheable(cacheNames = "products", key = "'user:' + #userId + ':page:' + #page")
public Page<ProductView> page(Long userId, int page) {
}
注意:
- key 必须稳定序列化;
- 参数对象应实现 equals/hashCode;
- key 要包含命名空间和版本;
- 避免无界 key;
- key 中不要包含敏感明文。
12.4 RedisCacheManager
@Configuration
public class CacheConfig {
@Bean
public RedisCacheManager cacheManager(RedisConnectionFactory factory) {
RedisCacheConfiguration config = RedisCacheConfiguration.defaultCacheConfig()
.entryTtl(Duration.ofMinutes(10))
.disableCachingNullValues()
.computePrefixWith(name -> "app:" + name + ":")
.serializeValuesWith(SerializationPair.fromSerializer(
new GenericJackson2JsonRedisSerializer()));
Map<String, RedisCacheConfiguration> configs = Map.of(
"products", config.entryTtl(Duration.ofMinutes(5)),
"configs", config.entryTtl(Duration.ofHours(1)));
return RedisCacheManager.builder(factory)
.cacheDefaults(config)
.withInitialCacheConfigurations(configs)
.build();
}
}
序列化选择:
| 方式 | 特点 |
|---|---|
| JDK 序列化 | 简单但可读性差、跨版本风险 |
| JSON | 可读,需处理类型和兼容 |
| GenericJackson2JsonRedisSerializer | 带类型信息,注意类型 ID 风险 |
| 自定义 DTO 序列化 | 契约清晰,推荐核心数据 |
12.5 一致性策略
常见顺序:
先更新数据库,再删除缓存
@Transactional
public void update(Long id, UpdateCommand command) {
repository.update(id, command);
cache.evict("product:" + id);
}
为什么常删缓存而不是更新:
- 避免并发写覆盖;
- 懒加载下次重建;
- 避免写冷数据;
- 删除语义简单。
但该策略仍可能出现短暂不一致。更强方案:
- 延迟双删;
- 版本号;
- TTL 兜底;
- 订阅 binlog/CDC 删除;
- 读写锁或串行化;
- 强一致读数据库。
12.6 缓存问题
| 问题 | 定义 | 应对 |
|---|---|---|
| 穿透 | 查询不存在的数据 | 缓存空值、布隆过滤器、参数校验 |
| 击穿 | 热 key 过期瞬间大量请求 | 互斥重建、逻辑过期、热点永不过期 |
| 雪崩 | 大量 key 同时过期 | TTL 加随机、限流、熔断 |
| 污染 | 缓存错误数据 | 校验来源、版本、灰度 |
| 膨胀 | key 无界增长 | 最大容量、淘汰策略 |
互斥重建示例:
public Product get(Long id) {
String key = "product:" + id;
Product cached = redis.get(key);
if (cached != null) {
return cached;
}
String lockKey = "lock:" + key;
if (redis.setNx(lockKey, "1", Duration.ofSeconds(3))) {
try {
Product product = repository.find(id);
redis.set(key, product, ttlWithJitter());
return product;
} finally {
redis.delete(lockKey);
}
}
return repository.find(id);
}
12.7 TTL 与淘汰
Redis 配置:
maxmemory 2gb
maxmemory-policy allkeys-lru
| 策略 | 说明 |
|---|---|
| noeviction | 内存满拒绝写入 |
| allkeys-lru | 所有 key 近似 LRU |
| volatile-lru | 只淘汰有 TTL 的 key |
| allkeys-lfu | 近似 LFU |
| volatile-ttl | 淘汰剩余时间短的 key |
应用侧建议:
- 每类数据单独 TTL;
- 基础 TTL 加随机偏移;
- 热点数据可逻辑过期;
- 定期统计 key 数量和内存;
- 不允许“永久 + 无界”的业务 key。
12.8 Caffeine
@Bean
public Cache<Long, ProductView> productCache() {
return Caffeine.newBuilder()
.maximumSize(10_000)
.expireAfterWrite(Duration.ofMinutes(5))
.recordStats()
.build();
}
使用:
public ProductView get(Long id) {
return cache.get(id, key -> repository.findView(key));
}
指标:
hitCount
missCount
hitRate
evictionCount
loadAverageTime
本地缓存适合:
- 读取热点;
- 数据可短暂不一致;
- 单实例或可广播失效;
- 数据量有限。
12.9 多级缓存
L1 Caffeine
-> L2 Redis
-> DB
失效:
DB 更新
-> 删除 Redis
-> 发布失效消息
-> 各实例删除本地 L1
问题:
- 消息丢失;
- 实例处理延迟;
- 时钟不同;
- 本地容量抖动;
- 版本不一致。
多级缓存通常只用于极热点数据,且必须监控命中率和不一致窗口。
12.10 缓存治理
上线前检查:
1. key 命名规范
2. 最大 TTL 和最小 TTL
3. 容量和淘汰策略
4. 空值处理
5. 热点保护
6. 序列化兼容
7. 权限和网络隔离
8. 命中率指标
9. 回源限流
10. 回滚方案
监控:
cache_hit_rate
cache_miss_count
cache_eviction_count
cache_get_latency
cache_put_latency
cache_redis_pool_active
cache_redis_errors
origin_query_count
命中率低时要分析:
- key 是否随机化过强;
- TTL 是否过短;
- 容量是否不足;
- 业务是否本身冷数据多;
- 是否值得缓存。
本章小结
Spring Cache 提供统一缓存抽象,底层可用 Caffeine、Redis 等。生产缓存的关键不是注解,而是 key 设计、TTL、容量、一致性、穿透击穿雪崩和命中率监控。先量化收益再引入缓存,并保留数据库兜底和回滚能力。
思考题
- 为什么常使用“更新数据库后删除缓存”?
- 缓存穿透、击穿、雪崩分别如何应对?
- 本地缓存和分布式缓存适合什么数据?
- 多级缓存的一致性难点是什么?
- 缓存命中率低时如何分析?