Appearance
第73课:缓存策略
🎯 学习目标
- 理解缓存的目标是降低延迟和保护后端资源,而不是简单“把数据放 Redis”。
- 掌握本地缓存、分布式缓存、多级缓存、缓存旁路等常见模式。
- 能处理缓存穿透、缓存击穿、缓存雪崩、数据一致性和热点 Key 问题。
- 能根据业务一致性要求选择 TTL、主动更新、延迟双删、消息通知等策略。
- 能识别缓存滥用、无界缓存、大 Key、缓存污染等风险。
📖 一、为什么需要缓存
缓存适合解决两类问题:
text
降低响应时间:热点数据不用每次查数据库或远程服务。
保护后端资源:高并发请求先被缓存吸收,减少数据库压力。典型场景:
text
商品详情
用户基础资料
配置项
字典表
权限菜单
首页推荐
热点排行榜不适合缓存的场景:
text
强一致账户余额
频繁变化且访问不高的数据
一次性查询
体积巨大且命中率低的数据缓存不是银弹。引入缓存后,系统会多出一致性、容量、过期、穿透和监控问题。
📖 二、缓存层级
| 类型 | 示例 | 特点 |
|---|---|---|
| JVM 本地缓存 | Caffeine、ConcurrentHashMap | 极快,但单机有效 |
| 分布式缓存 | Redis、Memcached | 多实例共享,适合集群 |
| HTTP 缓存 | CDN、浏览器缓存 | 适合静态资源和公开内容 |
| 数据库缓存 | Buffer Pool | 数据库内部机制 |
多级缓存示例:
text
请求 -> 本地 Caffeine -> Redis -> 数据库读取路径越靠前越快,但一致性越复杂。
📖 三、Cache Aside 模式
最常见模式是缓存旁路:
java
public ProductDTO getProduct(Long productId) {
String key = "product:" + productId;
ProductDTO cached = redisTemplate.opsForValue().get(key);
if (cached != null) {
return cached;
}
Product product = productRepository.findById(productId)
.orElseThrow(() -> new NotFoundException("product not found"));
ProductDTO dto = ProductDTO.from(product);
redisTemplate.opsForValue().set(key, dto, Duration.ofMinutes(10));
return dto;
}写入时通常:
java
@Transactional
public void updateProduct(ProductUpdateRequest request) {
productRepository.update(request);
redisTemplate.delete("product:" + request.getId());
}为什么是删除缓存,而不是更新缓存?
text
删除更简单
避免多个写入并发时旧值覆盖新值
下次读取自然回源重建📖 四、缓存穿透
缓存穿透指查询不存在的数据,缓存和数据库都没有,每次都打到数据库。
攻击者可能请求大量不存在的 ID:
text
product:-1
product:999999999解决方案:
1. 参数校验
非法 ID 直接拒绝。
2. 缓存空值
java
if (product == null) {
redisTemplate.opsForValue().set(key, NullValue.INSTANCE, Duration.ofMinutes(1));
return null;
}空值 TTL 要短,避免新数据创建后长期查不到。
3. 布隆过滤器
对海量 ID 判断“可能存在/一定不存在”。适合数据量大且恶意穿透风险高的场景。
📖 五、缓存击穿
缓存击穿指某个热点 Key 过期瞬间,大量请求同时回源数据库。
解决方案:
1. 互斥锁重建
java
public ProductDTO getProduct(Long productId) {
String key = "product:" + productId;
ProductDTO cached = getFromCache(key);
if (cached != null) {
return cached;
}
String lockKey = "lock:" + key;
boolean locked = tryLock(lockKey, Duration.ofSeconds(5));
if (locked) {
try {
ProductDTO secondCheck = getFromCache(key);
if (secondCheck != null) {
return secondCheck;
}
return rebuildCache(productId);
} finally {
unlock(lockKey);
}
}
sleepBriefly();
return getFromCacheOrFallback(key);
}2. 逻辑过期
缓存值里存过期时间。发现逻辑过期时,先返回旧值,同时异步重建。
适合热点商品、首页配置等允许短暂旧数据的场景。
📖 六、缓存雪崩
缓存雪崩指大量 Key 同时失效,导致请求集中打到数据库。
常见原因:
text
大量 Key 使用相同 TTL
Redis 集群故障
缓存服务重启
预热失败解决方案:
text
TTL 加随机抖动
热点数据不过期或逻辑过期
多级缓存
限流降级
Redis 高可用
缓存预热TTL 抖动示例:
java
long ttlSeconds = 3600 + ThreadLocalRandom.current().nextLong(300);
redisTemplate.opsForValue().set(key, value, Duration.ofSeconds(ttlSeconds));📖 七、缓存一致性
缓存和数据库很难做到强一致。常见策略:
| 策略 | 说明 | 适用场景 |
|---|---|---|
| 先更新 DB,再删除缓存 | 简单常用 | 大多数业务 |
| 延迟双删 | 更新 DB 前后都删,第二次延迟删 | 并发读写较多 |
| 消息通知失效 | DB 更新后发消息删除缓存 | 多服务缓存同步 |
| 逻辑过期 | 允许短暂旧值 | 热点数据 |
| 强一致读 DB | 不走缓存 | 账户余额等 |
常用方式:
text
更新数据库成功后删除缓存。
如果删除失败,记录日志并重试,或通过消息队列补偿。不要幻想所有业务都能缓存强一致。关键是明确业务能接受多长时间的不一致。
📖 八、本地缓存 Caffeine
Caffeine 适合 JVM 内热点小数据:
java
Cache<Long, ProductDTO> cache = Caffeine.newBuilder()
.maximumSize(10_000)
.expireAfterWrite(Duration.ofMinutes(5))
.recordStats()
.build();
ProductDTO product = cache.get(productId, id -> loadFromRedisOrDb(id));注意:
text
必须限制最大容量
集群内每个实例都有自己的本地缓存
更新时要考虑广播失效或短 TTL⚠️ 九、常见陷阱
1. 无界缓存
java
Map<String, Object> cache = new ConcurrentHashMap<>();没有容量限制就是内存泄漏。
2. 大 Key
一个 Key 存几 MB 数据,会导致网络传输慢、Redis 阻塞、删除成本高。
3. 热 Key
一个超级热点 Key 被大量请求访问,可能压垮 Redis 单分片。可以本地缓存、复制 Key、请求合并或热点隔离。
4. 缓存命中率低
命中率低的缓存只增加复杂度,没有收益。必须监控命中率。
🆚 十、Java vs C 对比
| 维度 | C 服务 | Java 后端 |
|---|---|---|
| 本地缓存 | 手写哈希表或第三方库 | Caffeine、Guava Cache |
| 分布式缓存 | hiredis 等客户端 | Spring Data Redis、Redisson |
| 过期淘汰 | 常需自行设计 | 框架支持 TTL 和淘汰策略 |
| 一致性 | 手动协调 | 结合事务、消息、注解和 AOP |
Java 生态提供了大量缓存组件,但缓存策略仍然必须由业务一致性要求决定。
💡 十一、最佳实践
- 只缓存读多写少、命中率高、允许短暂不一致的数据。
- 所有缓存必须设置容量或 TTL。
- Key 命名统一,例如
业务:对象:ID。 - 缓存值不要过大,大对象拆分或只缓存必要字段。
- 更新数据库后删除缓存,并设计删除失败的补偿。
- 热点 Key 使用本地缓存、逻辑过期或请求合并保护数据库。
- TTL 加随机抖动,避免大量 Key 同时过期。
- 监控命中率、Redis 延迟、Key 大小、连接池、错误率。
🎓 小结
缓存的本质是用空间换时间,用一致性复杂度换性能。好的缓存策略不是“哪里慢就加 Redis”,而是根据数据特征、访问频率、一致性要求和失败模式设计完整方案。
🧭 十二、缓存设计检查清单
设计一个缓存前,先回答:
text
1. 这个数据读写比例是多少?
2. 允许多久的不一致?
3. 缓存命中率预期是多少?
4. Key 的数量级是多少?
5. Value 最大有多大?
6. TTL 怎么设置,是否需要随机抖动?
7. 数据更新时如何失效缓存?
8. 删除缓存失败怎么补偿?
9. 热点 Key 怎么保护?
10. Redis 故障时系统如何降级?如果这些问题都答不上来,就不要急着加缓存。
📌 十三、缓存 Key 设计
推荐格式:
text
业务域:对象:标识示例:
text
user:profile:1001
product:detail:2001
order:summary:3001
config:payment:channel多租户系统要包含租户:
text
tenant:88:user:profile:1001Key 设计要稳定、可读、可批量定位。不要使用无意义拼接或包含不确定顺序的对象字符串。
📌 十四、缓存监控指标
缓存上线后至少监控:
text
命中率
平均延迟
P99 延迟
Key 数量
内存使用
大 Key 数量
热 Key
连接池占用
错误率
回源数据库次数命中率低时要问:
text
是不是 TTL 太短?
是不是 Key 设计过细?
是不是数据本来就不是热点?
是不是更新太频繁?
是不是存在缓存穿透?缓存本身也要被观测,否则它会从优化手段变成新的故障来源。
🔍 十五、自测问题
text
缓存穿透、击穿、雪崩分别是什么?
为什么更新数据库后通常删除缓存,而不是直接更新缓存?
空值缓存的 TTL 为什么要短?
热点 Key 有哪些处理方式?
为什么本地缓存需要最大容量?
逻辑过期适合什么场景?
什么业务不能随便走缓存?
缓存一致性为什么很难做到绝对强一致?📌 十六、学习建议
建议自己实现一个商品详情缓存,并模拟:
text
不存在商品的穿透请求
热点商品过期瞬间的并发请求
大量 Key 同时过期
更新数据库后删除缓存失败这些场景比单纯会写 redisTemplate.opsForValue().set() 更接近真实项目。