第 24 章:缓存架构
学习目标
- 掌握缓存三大模式:Cache-Aside、Read-Through、Write-Through
- 学会解决缓存穿透、击穿、雪崩
- 多级缓存架构设计
- 避免缓存不一致的坑
一、缓存的三大问题
text
缓存穿透:查询不存在的数据,绕过缓存
缓存击穿:热点 key 过期瞬间,大量请求直接打到 DB
缓存雪崩:大量 key 同时过期,DB 被打挂java
// 缓存穿透
SELECT * FROM product WHERE id = 999999; // 不存在,缓存没,DB 也没java
// 缓存击穿
// 热门商品 cache 过期,1 万请求同时打到 DBjava
// 缓存雪崩
// 1 万个 key 都设了 30 分钟过期,30 分钟后同时失效二、Cache-Aside 模式(最常用)
java
@Service
public class ProductService {
@Resource
private ProductRepository productRepository;
@Resource
private StringRedisTemplate redis;
public Product getProduct(Long id) {
String key = "product:" + id;
// 1. 先查缓存
String json = redis.opsForValue().get(key);
if (json != null) {
return JsonUtil.fromJson(json, Product.class);
}
// 2. 查 DB
Product product = productRepository.findById(id).orElse(null);
if (product == null) {
// 缓存空值,防穿透
redis.opsForValue().set(key, "", Duration.ofSeconds(60));
return null;
}
// 3. 写入缓存
redis.opsForValue().set(key, JsonUtil.toJson(product), Duration.ofSeconds(300));
return product;
}
public void updateProduct(Product product) {
// 1. 先更新 DB
productRepository.save(product);
// 2. 删除缓存(不更新,避免并发写不一致)
redis.delete("product:" + product.getId());
}
}⚠️ 坑 1:先更新缓存再更新 DB,DB 失败回滚但缓存已更新 = 数据不一致。先 DB 后删缓存。
三、缓存穿透
java
// 方案 1:缓存空值
public Product getProduct(Long id) {
String key = "product:" + id;
String json = redis.opsForValue().get(key);
if (json != null) {
return json.isEmpty() ? null : JsonUtil.fromJson(json, Product.class);
}
Product p = productRepository.findById(id).orElse(null);
redis.opsForValue().set(key, p == null ? "" : JsonUtil.toJson(p), Duration.ofSeconds(60));
return p;
}
// 方案 2:布隆过滤器
@Bean
public BloomFilter<Long> productBloomFilter() {
BloomFilter<Long> filter = BloomFilter.create(Funnels.longFunnel(), 1000000, 0.01);
productRepository.findAll().forEach(p -> filter.put(p.getId()));
return filter;
}
public Product getProduct(Long id) {
if (!bloomFilter.mightContain(id)) {
return null; // 一定不存在
}
// 走正常缓存逻辑
}四、缓存击穿
java
// 方案 1:分布式锁
public Product getProduct(Long id) {
String key = "product:" + id;
Product p = redis.get(key);
if (p == null) {
// 只让一个线程去查 DB
RLock lock = redisson.getLock("lock:product:" + id);
lock.lock();
try {
// 双重检查
p = redis.get(key);
if (p == null) {
p = productRepository.findById(id).orElse(null);
redis.set(key, p, Duration.ofSeconds(300));
}
} finally {
lock.unlock();
}
}
return p;
}java
// 方案 2:逻辑过期(不设 TTL,业务判断)
public Product getProduct(Long id) {
String key = "product:" + id;
ProductCache cache = redis.get(key, ProductCache.class);
if (cache != null && !cache.isExpired()) {
return cache.getProduct();
}
// 异步重建(线程池)
CompletableFuture.runAsync(() -> rebuildCache(id));
return cache == null ? productRepository.findById(id).orElse(null) : cache.getProduct();
}五、缓存雪崩
java
// 方案 1:TTL 加随机
public void setProductCache(Long id, Product p) {
long ttl = 300 + ThreadLocalRandom.current().nextInt(60); // 300-360s
redis.opsForValue().set("product:" + id, p, Duration.ofSeconds(ttl));
}
// 方案 2:多级缓存
// L1:本地(Caffeine) 1s 过期
// L2:Redis 5 min 过期
// L3:DB 兜底
// 方案 3:熔断降级
@HystrixCommand(fallbackMethod = "getProductFallback")
public Product getProduct(Long id) {
return productRepository.findById(id).orElse(null);
}
public Product getProductFallback(Long id) {
return new Product(id, "兜底商品", new BigDecimal("0"));
}⚠️ 坑 2:Redis 挂了一瞬间,所有请求都打到 DB = 雪崩。缓存降级开关,Redis 挂了直接返回旧数据。
六、多级缓存架构
text
客户端
↓
[本地缓存(Caffeine)] 100us
↓ 未命中
[Redis] 1ms
↓ 未命中
[DB] 10msjava
// Caffeine 一级
LoadingCache<Long, Product> cache = Caffeine.newBuilder()
.maximumSize(1000)
.expireAfterWrite(Duration.ofSeconds(60))
.build(this::loadFromRedis);
public Product getProduct(Long id) {
return cache.get(id);
}
private Product loadFromRedis(Long id) {
String json = redis.opsForValue().get("product:" + id);
if (json != null) {
return JsonUtil.fromJson(json, Product.class);
}
return productRepository.findById(id).orElse(null);
}java
// 多级缓存不一致问题
// Caffeine 60s 过期,Redis 5min 过期
// 商品价格被改了,Caffeine 60s 内还是旧值
// 解决:Caffeine 失效订阅 Redis pubsub七、缓存一致性
java
// 1. 先 DB 后删缓存(推荐)
@Transactional
public void updateProduct(Product p) {
productRepository.save(p);
redis.delete("product:" + p.getId());
}
// 2. 监听 binlog 异步删除(Canal)
@Component
public class CanalListener {
@CanalEventListener
public void onUpdate(ProductPO old, ProductPO new) {
redis.delete("product:" + new.getId());
}
}
// 3. 延迟双删
public void updateProduct(Product p) {
productRepository.save(p);
redis.delete("product:" + p.getId());
// 延迟 500ms 再删一次
CompletableFuture.runAsync(() -> {
Thread.sleep(500);
redis.delete("product:" + p.getId());
});
}⚠️ 坑 3:"Cache-Aside" 后删缓存,理论上有 1ms 窗口不一致。强一致场景,直接读 DB。
八、热点缓存预热
java
// 启动时加载热点数据
@PostConstruct
public void warmup() {
log.info("开始缓存预热");
// 1. 加载热门商品
List<Product> hot = productRepository.findHot(1000);
hot.forEach(p -> redis.opsForValue()
.set("product:" + p.getId(), p, Duration.ofSeconds(3600)));
// 2. 加载配置
List<Config> configs = configRepository.findAll();
configs.forEach(c -> redis.opsForValue()
.set("config:" + c.getKey(), c.getValue()));
log.info("缓存预热完成,已加载 {} 个商品", hot.size());
}九、缓存监控
java
// 监控指标
MeterRegistry registry = ...;
// 命中率
@EventListener
public void onCacheHit(CacheHitEvent event) {
registry.counter("cache.hit", "cache", event.getCacheName()).increment();
}
@EventListener
public void onCacheMiss(CacheMissEvent event) {
registry.counter("cache.miss", "cache", event.getCacheName()).increment();
}
// 告警:命中率 < 80% 告警本章小结
| 模式 | 适用 |
|---|---|
| Cache-Aside | 通用 |
| Read-Through | 强一致 |
| Write-Through | 写性能高 |
| Write-Behind | 异步写,最终一致 |
| 关键点 | 方案 |
|---|---|
| 穿透 | 布隆过滤器 / 空值缓存 |
| 击穿 | 分布式锁 / 逻辑过期 |
| 雪崩 | TTL 随机 / 多级缓存 |
| 一致性 | 先 DB 后删缓存 / Canal |
动手练习
- Cache-Aside:实现商品查询,先 Redis 后 DB
- 穿透:用布隆过滤器防不存在 ID 打到 DB
- 击穿:用分布式锁模拟热点 key 重建
- 雪崩:对比 TTL 固定 vs TTL 加随机,观察 DB QPS
下一章:第 25 章:高可用设计 →