第 232 章:缓存体系实战
学习目标
- 多级缓存架构设计
- 缓存三大问题解决方案
- 缓存体系监控
- 缓存与数据库一致性
一、多级缓存全景
1.1 各层职责
| 层级 | 位置 | 特点 | 失效 |
|---|---|---|---|
| 浏览器 | 客户端 | 0 网络成本 | 强 |
| CDN | 边缘 | 全球加速 | 中 |
| Nginx | 反代 | 大文件友好 | 弱 |
| JVM | 应用内 | 微秒级 | 进程退出 |
| Redis | 中间件 | 毫秒级 | 可持久化 |
| DB | 持久层 | 慢 IO | - |
二、缓存策略选择
2.1 Read-Aside(常用)
java
public Product getProduct(Long id) {
String key = "product:" + id;
Product product = cache.get(key);
if (product == null) {
product = productRepo.findById(id).orElseThrow();
cache.put(key, product, 60, TimeUnit.SECONDS);
}
return product;
}2.2 Write-Through
写时同时写缓存和数据库,读永远命中。
2.3 Write-Behind
性能高,但有丢失风险(异步写失败)。
2.4 Cache-Aside 与一致性
java
public void updateProduct(Product p) {
// 先写库
productRepo.save(p);
// 再删缓存
cache.delete("product:" + p.getId());
}下次读时回源 DB + 重新填缓存。
三、缓存三大问题
3.1 雪崩
大量缓存同时过期,全部打到 DB。
解决方案:
yaml
# 1. 过期时间随机化
expire = 60 + random(0, 30) # 60 ~ 90 秒
# 2. 多级缓存
JVM 缓存(本地)→ Redis(共享)→ DB
# 3. 缓存预热
服务启动时,加载热点数据
# 4. 熔断降级
DB 异常时,降级返回兜底数据
# 5. Sentinel 限流
对回源操作限流3.2 穿透
查询不存在的数据,每次都打到 DB。
解决方案:
java
// 1. 缓存空值
public Product getProduct(Long id) {
Product p = cache.get("product:" + id);
if (p == null) {
p = productRepo.findById(id);
if (p == null) {
// 缓存空值,避免击穿
cache.put("product:" + id, NULL_OBJECT, 30, TimeUnit.SECONDS);
return null;
}
cache.put("product:" + id, p);
}
return p == NULL_OBJECT ? null : p;
}
// 2. 布隆过滤器
BloomFilter<Long> bloomFilter = ...; // 启动时填入所有商品 ID
if (!bloomFilter.mightContain(id)) {
return null;
}3.3 击穿
热点 key 过期瞬间,大量请求同时回源。
解决方案:
java
// 1. 分布式锁(单个请求去数据库)
// 只让 1 个请求回源,其他等
public Product getHotProduct(Long id) {
String key = "hot:product:" + id;
Product p = cache.get(key);
if (p == null) {
RLock lock = redisson.getLock(key + ":lock");
if (lock.tryLock(100, 30_000, TimeUnit.MILLISECONDS)) {
try {
// 二次检查
p = cache.get(key);
if (p == null) {
p = productRepo.findById(id);
cache.put(key, p, 60, TimeUnit.SECONDS);
}
} finally {
lock.unlock();
}
} else {
// 等不到锁,稍后重试或返回
try { Thread.sleep(50); } catch (Exception e) {}
return getHotProduct(id);
}
}
return p;
}
// 2. 逻辑过期(后台异步刷新)
@Data
class CacheWrapper<T> {
T data;
long expireTime;
}四、Caffeine 本地缓存
4.1 依赖
xml
<dependency>
<groupId>com.github.ben-manes.caffeine</groupId>
<artifactId>caffeine</artifactId>
</dependency>4.2 配置
java
@Configuration
@EnableCaching
public class CacheConfig {
@Bean
public CacheManager cacheManager() {
CaffeineCacheManager manager = new CaffeineCacheManager();
manager.setCaffeine(Caffeine.newBuilder()
.expireAfterWrite(60, TimeUnit.SECONDS)
.maximumSize(10_000)
.recordStats());
return manager;
}
}4.3 使用
java
@Cacheable(value = "products", key = "#id")
public Product getProduct(Long id) {
return productRepo.findById(id);
}
@CachePut(value = "products", key = "#product.id")
public Product update(Product product) {
return productRepo.save(product);
}
@CacheEvict(value = "products", key = "#id")
public void delete(Long id) {
productRepo.deleteById(id);
}4.4 高级特性
java
Caffeine.newBuilder()
.expireAfterWrite(60, TimeUnit.SECONDS) // 写后过期
.expireAfterAccess(5, TimeUnit.MINUTES) // 访问后过期
.maximumSize(10_000) // 大小上限
.maximumWeight(10_000)
.weigher((key, value) -> ((Product) value).size())
.softValues() // 软引用
.recordStats() // 开启监控
.removalListener((key, value, cause) -> { // 移除监听
log.info("移除 key={}, 原因={}", key, cause);
});五、Redis 缓存
5.1 序列化
java
@Configuration
public class RedisConfig {
@Bean
public RedisTemplate<String, Product> redisTemplate(RedisConnectionFactory factory) {
RedisTemplate<String, Product> template = new RedisTemplate<>();
template.setConnectionFactory(factory);
Jackson2JsonRedisSerializer<Product> serializer =
new Jackson2JsonRedisSerializer<>(objectMapper, Product.class);
template.setKeySerializer(new StringRedisSerializer());
template.setValueSerializer(serializer);
return template;
}
}5.2 批量操作
java
public Map<Long, Product> batchGet(List<Long> ids) {
List<String> keys = ids.stream().map(id -> "product:" + id).collect(Collectors.toList());
// mget 一次拉
List<Object> values = redisTemplate.opsForValue().multiGet(keys);
Map<Long, Product> result = new HashMap<>();
List<Long> missing = new ArrayList<>();
for (int i = 0; i < ids.size(); i++) {
Product p = (Product) values.get(i);
if (p == null) {
missing.add(ids.get(i));
} else {
result.put(ids.get(i), p);
}
}
if (!missing.isEmpty()) {
// 缺失的查库,再 mset
List<Product> products = productRepo.findAllById(missing);
Map<String, Product> toSet = new HashMap<>();
products.forEach(p -> {
result.put(p.getId(), p);
toSet.put("product:" + p.getId(), p);
});
redisTemplate.opsForValue().multiSet(toSet);
redisTemplate.opsForValue().multiSet(toSet);
toSet.forEach((k, v) ->
redisTemplate.expire(k, 60, TimeUnit.SECONDS)
);
}
return result;
}5.3 Pipeline
java
public List<Product> pipelineQuery(List<Long> ids) {
return redisTemplate.executePipelined((RedisCallback<Object>) connection -> {
for (Long id : ids) {
connection.stringCommands().get(("product:" + id).getBytes());
}
return null;
});
}六、缓存一致性
6.1 设计方案
| 方案 | 说明 |
|---|---|
| Cache-Aside | 先 DB 再缓存,延迟双删 |
| 延迟双删 | 删缓存 → 写库 → 延迟 X ms → 删缓存 |
| MQ 异步 | 数据库 binlog → MQ → 缓存同步 |
| Canal | 监听 MySQL binlog |
6.2 Canal 监听 binlog
java
@CanalEventListener
public class MyCanalListener {
@Autowired
private CacheService cacheService;
@ListenPoint(schema = "shop", table = "product")
public void onUpdate(CanalEntry.EventType eventType, CanalEntry.RowChange row) {
if (eventType == CanalEntry.EventType.UPDATE) {
Long id = extractId(row);
cacheService.delete("product:" + id);
}
}
}6.3 写策略详解
java
@Service
public class ProductService {
@Autowired
private CacheService cacheService;
@Autowired
private ProductMapper productMapper;
public void update(Product product) {
// 1. 写库
productMapper.update(product);
// 2. 删缓存(删除,而不是更新)
cacheService.delete("product:" + product.getId());
// 3. 异步延迟双删(避免并发脏读)
CompletableFuture.runAsync(() -> {
try { Thread.sleep(500); } catch (Exception e) {}
cacheService.delete("product:" + product.getId());
});
}
}七、缓存粒度
7.1 选择
| 粒度 | 优点 | 缺点 |
|---|---|---|
| 粗(整表数据) | 简单 | 浪费,可能存无关数据 |
| 细(单条) | 精确 | 多次 IO |
7.2 实践
java
// ❌ 把列表当 key
List<Product> products = cache.get("product:list:hot");
products.get(0) == 单条缓存
// ✅ 单条 key
Product p = cache.get("product:" + id);八、热点数据识别
8.1 统计方式
java
// 计数:Redis INCR
String key = "hot:counter:" + productId;
Long count = redisTemplate.opsForValue().increment(key);
// 定期扫描
List<String> topKeys = redisTemplate.scan(
ScanOptions.scanOptions().match("hot:counter:*").count(100).build()
);8.2 多级缓存
java
// Level 1: Caffeine
// Level 2: Redis
// Level 3: DB读取顺序: JVM → Redis → DB 写入顺序: DB → Redis → JVM(广播清空)
九、缓存监控
9.1 命中率指标
java
// Prometheus + Grafana
registry.counter("cache.hit", "key", key).increment();
registry.counter("cache.miss", "key", key).increment();
// 业务显式监控
"cache_hit_ratio" = hit / (hit + miss)9.2 容量监控
java
// Redis INFO 监控
INFO memory
# used_memory
# used_memory_human
# mem_fragmentation_ratio9.3 关键指标
| 指标 | 阈值 |
|---|---|
| 命中率 | > 95% |
| P99 RT | < 5ms |
| 连接数 | < max-pool |
| 内存使用率 | < 70% |
| 键数 | 监控增长 |
十、实战案例
10.1 商品详情页
java
@Service
@RequiredArgsConstructor
public class ProductService {
private final Cache<String, Product> localCache;
private final RedisTemplate<String, Product> redis;
private final ProductMapper productMapper;
private final BloomFilter<Long> productBloom;
public Product getDetail(Long id) {
// 1. 布隆过滤
if (!productBloom.mightContain(id)) {
return null;
}
// 2. 本地缓存
Product p = localCache.getIfPresent("p:" + id);
if (p != null) return p;
// 3. Redis
p = redis.opsForValue().get("product:" + id);
if (p == null) {
// 4. DB
p = productMapper.selectById(id);
if (p == null) {
redis.opsForValue().set("product:" + id, NULL_OBJ, 30, TimeUnit.SECONDS);
return null;
}
// 写 Redis + 本地
redis.opsForValue().set("product:" + id, p, 60, TimeUnit.SECONDS);
}
localCache.put("p:" + id, p);
return p;
}
}10.2 缓存预热
java
@Component
public class CacheWarmer implements ApplicationRunner {
@Autowired
private ProductService productService;
@Autowired
private RedisTemplate<String, Product> redis;
@Override
public void run(ApplicationArguments args) {
// 启动时加载前 1000 个热门商品到缓存
List<Long> hotIds = hotProductRepo.findHotIds(0, 1000);
Map<String, Product> map = new HashMap<>();
for (Long id : hotIds) {
Product p = productService.getDetail(id);
if (p != null) {
map.put("product:" + id, p);
}
}
redis.opsForValue().multiSet(map);
}
}十一、本章小结
| 层级 | 用途 | 库 |
|---|---|---|
| JVM | 高频热点 | Caffeine |
| Redis | 共享缓存 | Redis Cluster |
| DB | 持久化 | MySQL |
动手练习
- 设计一个三级缓存(浏览器/Caffeine/Redis)
- 用 Caffeine + Redis 实现商品缓存
- 处理缓存三大问题(雪崩/穿透/击穿)
- 用延迟双删保证缓存一致性
下一章:第 233 章:秒杀系统设计