缓存策略优化
面试高频考点:缓存穿透/击穿/雪崩、缓存一致性策略、缓存预热、多级缓存架构
一、缓存基础
为什么需要缓存?
没有缓存:
客户端 → API 服务 → 数据库查询 → 返回结果
响应时间:100-500ms
有缓存:
客户端 → API 服务 → 缓存命中 → 返回结果
响应时间:1-5ms
性能提升:20-100 倍缓存应用场景
| 场景 | 是否适合缓存 | 原因 |
|---|---|---|
| 商品详情 | 是 | 读多写少,数据变化少 |
| 用户信息 | 是 | 访问频繁,更新较少 |
| 新闻列表 | 是 | 读多写少 |
| 股票行情 | 否 | 数据实时变化 |
| 验证码 | 否 | 一次性数据 |
| 秒杀库存 | 谨慎 | 需要强一致性 |
二、缓存三大问题
1. 缓存穿透(Cache Penetration)
定义:查询不存在的数据,缓存和数据库都没有,导致每次都查询数据库。
请求 key="不存在的key"
↓
缓存未命中
↓
查询数据库
↓
数据库也没有
↓
返回 null(没有缓存 null)
↓
下次请求继续穿透...危害:
- 大量请求直达数据库
- 可能导致数据库压力过大甚至宕机
- 恶意攻击利用此漏洞
解决方案:
// 方案一:缓存空值
public Object get(String key) {
Object value = redis.get(key);
if (value != null) {
return "NULL".equals(value) ? null : value; // 空值特殊处理
}
value = database.query(key);
if (value == null) {
redis.set(key, "NULL", 60); // 缓存空值,短过期时间
} else {
redis.set(key, value, 3600);
}
return value;
}
// 方案二:布隆过滤器
@Component
public class CacheFilter {
private BloomFilter<String> bloomFilter;
@PostConstruct
public void init() {
// 预计元素 100 万,误判率 0.01%
bloomFilter = BloomFilter.create(
Funnels.stringFunnel(Charset.defaultCharset()),
1000000, 0.0001);
// 预热:加载所有存在的 key
List<String> allKeys = database.getAllKeys();
allKeys.forEach(bloomFilter::put);
}
public Object get(String key) {
// 先检查布隆过滤器
if (!bloomFilter.mightContain(key)) {
return null; // 一定不存在,直接返回
}
// 可能存在,查询缓存和数据库
return getFromCacheOrDb(key);
}
}
// 方案三:接口层校验
public Object getData(String id) {
// 参数校验,过滤非法请求
if (StringUtils.isBlank(id) || !id.matches("^[0-9]+$")) {
throw new IllegalArgumentException("Invalid id");
}
// ...
}2. 缓存击穿(Cache Breakdown)
定义:热点 key 过期瞬间,大量并发请求同时查询数据库。
热点 key 过期
↓
大量并发请求同时发现缓存不存在
↓
同时查询数据库
↓
数据库压力瞬间激增解决方案:
// 方案一:互斥锁(Mutex Lock)
public Object getWithLock(String key) {
Object value = redis.get(key);
if (value != null) {
return value;
}
String lockKey = "lock:" + key;
try {
// 尝试获取锁
boolean locked = redis.setnx(lockKey, "1", 10); // 10 秒超时
if (locked) {
// 获取锁成功,查询数据库
value = database.query(key);
redis.set(key, value, 3600);
return value;
} else {
// 获取锁失败,等待后重试
Thread.sleep(50);
return getWithLock(key); // 递归重试
}
} finally {
redis.del(lockKey);
}
}
// 方案二:逻辑过期(不设置 TTL)
@Component
public class HotKeyCache {
@Data
public static class CacheData {
private Object data;
private Long expireTime; // 逻辑过期时间
}
public Object getWithLogicalExpire(String key) {
String json = redis.get(key);
if (json == null) {
// 缓存不存在,需要重建(理论上热点数据会预热)
return rebuildCache(key);
}
CacheData cacheData = JSON.parseObject(json, CacheData.class);
// 检查是否逻辑过期
if (cacheData.getExpireTime() > System.currentTimeMillis()) {
return cacheData.getData(); // 未过期,直接返回
}
// 已过期,异步重建
asyncRebuildCache(key);
// 返回旧数据(保证可用性)
return cacheData.getData();
}
@Async
public void asyncRebuildCache(String key) {
String lockKey = "lock:" + key;
if (redis.setnx(lockKey, "1", 10)) {
try {
Object data = database.query(key);
CacheData cacheData = new CacheData();
cacheData.setData(data);
cacheData.setExpireTime(System.currentTimeMillis() + 3600000);
redis.set(key, JSON.toJSONString(cacheData)); // 不设置 TTL
} finally {
redis.del(lockKey);
}
}
}
}
// 方案三:热点数据永不过期
public void cacheHotData(String key, Object value) {
// 热点数据设置较长过期时间或不设置过期
redis.set(key, value); // 永不过期
// 或
redis.set(key, value, 7 * 24 * 3600); // 7 天
}3. 缓存雪崩(Cache Avalanche)
定义:大量缓存同时过期,或缓存服务宕机,导致所有请求直达数据库。
场景一:大量缓存同时过期
凌晨 0 点,一批缓存同时过期
↓
大量请求同时查询数据库
↓
数据库压力激增
场景二:缓存服务宕机
Redis 宕机
↓
所有请求直达数据库
↓
数据库瞬间崩溃解决方案:
// 方案一:过期时间加随机值
public void setWithRandomExpire(String key, Object value) {
int baseExpire = 3600; // 基础过期时间 1 小时
int randomExpire = new Random().nextInt(600); // 随机 0-10 分钟
redis.set(key, value, baseExpire + randomExpire);
}
// 方案二:多级缓存
@Component
public class MultiLevelCache {
@Autowired
private RedisTemplate redisTemplate;
private Cache<String, Object> localCache = Caffeine.newBuilder()
.maximumSize(10000)
.expireAfterWrite(300, TimeUnit.SECONDS) // 本地缓存 5 分钟
.build();
public Object get(String key) {
// L1: 本地缓存
Object value = localCache.getIfPresent(key);
if (value != null) {
return value;
}
// L2: Redis 缓存
value = redisTemplate.opsForValue().get(key);
if (value != null) {
localCache.put(key, value);
return value;
}
// L3: 数据库
value = database.query(key);
if (value != null) {
redisTemplate.opsForValue().set(key, value, 3600);
localCache.put(key, value);
}
return value;
}
}
// 方案三:缓存高可用
// Redis Cluster 或 Sentinel 部署,保证缓存服务可用
// 方案四:熔断降级
@CircuitBreaker(name = "cache", fallbackMethod = "fallback")
public Object getWithCircuitBreaker(String key) {
return cacheService.get(key);
}
public Object fallback(String key, Exception e) {
// 降级策略:返回默认值或查询数据库
return getDefaultData();
}三、缓存一致性策略
缓存更新策略
| 策略 | 一致性 | 性能 | 适用场景 |
|---|---|---|---|
| Cache Aside | 中 | 高 | 通用 |
| Read Through | 高 | 中 | 读多写少 |
| Write Through | 高 | 中 | 写多 |
| Write Behind | 低 | 高 | 写多、允许延迟 |
Cache Aside Pattern(旁路缓存)
读流程:
1. 先查缓存
2. 缓存命中 → 返回
3. 缓存未命中 → 查数据库 → 写缓存 → 返回写流程:
1. 更新数据库
2. 删除缓存(推荐)/ 更新缓存@Service
public class UserService {
// 读操作
public User getUser(Long id) {
String key = "user:" + id;
String json = redis.get(key);
if (json != null) {
return JSON.parseObject(json, User.class);
}
// 缓存未命中,查询数据库
User user = userMapper.selectById(id);
if (user != null) {
redis.set(key, JSON.toJSONString(user), 3600);
}
return user;
}
// 写操作:先更新数据库,再删除缓存
@Transactional
public void updateUser(User user) {
userMapper.updateById(user);
// 删除缓存
String key = "user:" + user.getId();
redis.del(key);
}
}为什么是删除缓存而不是更新缓存?
场景:频繁更新
更新缓存方案:
- 每次更新都写缓存
- 如果更新频繁但读取少,浪费资源
删除缓存方案:
- 只删除,下次读取时再加载
- 延迟更新,减少写缓存次数双写一致性问题的解决方案
问题:并发场景下,数据库和缓存可能不一致。
线程 A: 更新数据库 → 删除缓存
线程 B: 读取旧数据 → 写入缓存
时序:
1. 线程 A 更新数据库为 "新值"
2. 线程 B 读取数据库(还是旧值?)
3. 线程 B 写入缓存
4. 线程 A 删除缓存
结果:缓存中没有数据(已被删除),看似没问题更复杂的场景:
线程 A: 删除缓存 → 更新数据库
线程 B: 读缓存未命中 → 读数据库 → 写缓存
时序:
1. 线程 A 删除缓存
2. 线程 B 读缓存未命中
3. 线程 B 读数据库(还是旧值)
4. 线程 B 写入缓存(旧值)
5. 线程 A 更新数据库
结果:缓存是旧值,数据库是新值 → 不一致!解决方案:
// 方案一:延迟双删
public void updateWithDoubleDelete(User user) {
String key = "user:" + user.getId();
// 1. 先删除缓存
redis.del(key);
// 2. 更新数据库
userMapper.updateById(user);
// 3. 延迟后再删除缓存
try {
Thread.sleep(500); // 延迟时间大于读操作耗时
} catch (InterruptedException e) {
// ignore
}
redis.del(key);
}
// 方案二:消息队列保证最终一致性
@Transactional
public void updateUser(User user) {
// 更新数据库
userMapper.updateById(user);
// 发送消息到 MQ
mqProducer.send("cache-delete", "user:" + user.getId());
}
// MQ 消费者
@Consumer(topic = "cache-delete")
public void onMessage(String key) {
redis.del(key);
}
// 方案三:Canal 监听 Binlog
// Canal 订阅 MySQL Binlog,自动同步到缓存
// 方案四:分布式锁(强一致性要求高时)
public void updateWithLock(User user) {
String lockKey = "lock:user:" + user.getId();
try {
if (redis.setnx(lockKey, "1", 10)) {
String key = "user:" + user.getId();
// 删除缓存
redis.del(key);
// 更新数据库
userMapper.updateById(user);
}
} finally {
redis.del(lockKey);
}
}四、缓存预热
定义:系统启动时,提前加载热点数据到缓存。
@Component
public class CacheWarmup implements CommandLineRunner {
@Autowired
private RedisTemplate redisTemplate;
@Autowired
private ProductMapper productMapper;
@Override
public void run(String... args) {
log.info("开始缓存预热...");
// 1. 加载热点商品
List<Product> hotProducts = productMapper.selectHotProducts(100);
for (Product product : hotProducts) {
String key = "product:" + product.getId();
redisTemplate.opsForValue().set(key, product, 24, TimeUnit.HOURS);
}
// 2. 加载布隆过滤器
BloomFilter<String> bloomFilter = BloomFilter.create(
Funnels.stringFunnel(Charset.defaultCharset()),
100000, 0.0001);
List<String> allIds = productMapper.selectAllIds();
allIds.forEach(bloomFilter::put);
redisTemplate.opsForValue().set("bloom:product", bloomFilter);
log.info("缓存预热完成,预热商品数量: {}", hotProducts.size());
}
}
// 定时刷新热点数据
@Scheduled(cron = "0 0 * * * ?") // 每小时刷新
public void refreshHotData() {
List<Product> hotProducts = productMapper.selectHotProducts(100);
for (Product product : hotProducts) {
String key = "product:" + product.getId();
redisTemplate.opsForValue().set(key, product, 24, TimeUnit.HOURS);
}
}五、多级缓存架构
┌─────────────────────────────────────────────────────────────┐
│ 请求流程 │
│ │
│ 客户端请求 │
│ ↓ │
│ ┌─────────┐ │
│ │ CDN 缓存 │ ← 静态资源、图片 │
│ └────┬────┘ │
│ ↓ 未命中 │
│ ┌─────────────┐ │
│ │ Nginx 缓存 │ ← 页面缓存、API 响应缓存 │
│ └──────┬──────┘ │
│ ↓ 未命中 │
│ ┌─────────────┐ │
│ │ 本地缓存 │ ← Caffeine/Guava,毫秒级延迟 │
│ │ (JVM 内存) │ │
│ └──────┬──────┘ │
│ ↓ 未命中 │
│ ┌─────────────┐ │
│ │ 分布式缓存 │ ← Redis Cluster,微秒级延迟 │
│ │ (Redis) │ │
│ └──────┬──────┘ │
│ ↓ 未命中 │
│ ┌─────────────┐ │
│ │ 数据库 │ ← MySQL/PostgreSQL │
│ └─────────────┘ │
└─────────────────────────────────────────────────────────────┘多级缓存实现
@Service
public class ProductCacheService {
// L1: 本地缓存 (Caffeine)
private Cache<String, Product> localCache = Caffeine.newBuilder()
.maximumSize(10000)
.expireAfterWrite(5, TimeUnit.MINUTES)
.recordStats() // 开启统计
.build();
@Autowired
private RedisTemplate<String, Object> redisTemplate;
@Autowired
private ProductMapper productMapper;
public Product getProduct(Long id) {
String key = "product:" + id;
// L1: 本地缓存
Product product = localCache.getIfPresent(key);
if (product != null) {
log.debug("L1 cache hit: {}", key);
return product;
}
// L2: Redis 缓存
product = (Product) redisTemplate.opsForValue().get(key);
if (product != null) {
log.debug("L2 cache hit: {}", key);
localCache.put(key, product); // 回填 L1
return product;
}
// L3: 数据库
product = productMapper.selectById(id);
if (product != null) {
// 回填缓存
redisTemplate.opsForValue().set(key, product, 1, TimeUnit.HOURS);
localCache.put(key, product);
}
return product;
}
// 更新时清除多级缓存
public void updateProduct(Product product) {
String key = "product:" + product.getId();
// 更新数据库
productMapper.updateById(product);
// 清除 L1 缓存
localCache.invalidate(key);
// 清除 L2 缓存
redisTemplate.delete(key);
}
// 获取缓存统计
public CacheStats getStats() {
return localCache.stats();
}
}本地缓存 vs 分布式缓存
| 特性 | 本地缓存 | 分布式缓存 |
|---|---|---|
| 延迟 | 纳秒级 | 微秒级 |
| 容量 | 受 JVM 内存限制 | 可扩展 |
| 一致性 | 弱(各节点独立) | 强(共享) |
| 可靠性 | 进程重启丢失 | 持久化 |
| 适用场景 | 热点数据、配置 | 共享数据、会话 |
六、Redis 缓存最佳实践
Key 命名规范
格式:业务:模块:id[:属性]
示例:
user:info:1001 # 用户信息
user:followers:1001 # 用户粉丝列表
product:detail:2001 # 商品详情
order:status:3001 # 订单状态
cache:config:system # 系统配置过期时间设计
// 根据数据热度设置不同过期时间
public void setCache(String key, Object value, CacheLevel level) {
int expire;
switch (level) {
case HOT: // 热点数据:长过期
expire = 24 * 3600;
break;
case WARM: // 温数据:中等过期
expire = 3600;
break;
case COLD: // 冷数据:短过期
expire = 300;
break;
default:
expire = 600;
}
// 加随机值防止雪崩
expire += new Random().nextInt(60);
redis.set(key, value, expire);
}大 Key 问题
// 问题:单个 Key 的 Value 过大
// 影响:阻塞其他操作、网络传输慢
// 检测大 Key
redis-cli --bigkeys
// 解决方案
// 1. 拆分大 Key
// 原:user:1001:detail -> 整个用户对象
// 拆:user:1001:info, user:1001:profile, user:1001:settings
// 2. 压缩
String compressed = compress(JSON.toJSONString(value));
redis.set(key, compressed);
// 3. 使用 Hash 结构
redis.hset("user:1001", "name", "张三");
redis.hset("user:1001", "age", "25");七、面试要点
Q1: 什么是缓存穿透、击穿、雪崩?
回答要点:
| 问题 | 原因 | 解决方案 |
|---|---|---|
| 穿透 | 查询不存在的数据 | 缓存空值、布隆过滤器 |
| 击穿 | 热点 Key 过期 | 互斥锁、逻辑过期 |
| 雪崩 | 大量缓存同时过期 | 随机过期时间、多级缓存 |
Q2: 如何保证缓存和数据库一致性?
回答要点:
- 采用 Cache Aside 模式:先更新数据库,再删除缓存
- 延迟双删保证最终一致性
- 使用消息队列异步删除缓存
- 强一致性要求高时使用分布式锁
Q3: 为什么删除缓存而不是更新缓存?
回答要点:
- 更新缓存可能有并发问题
- 频繁更新但读取少时浪费资源
- 删除后延迟加载,减少写次数
- 复杂数据更新成本高
Q4: 什么是多级缓存?
回答要点:
请求 → CDN → Nginx → 本地缓存 → Redis → 数据库
优势:
1. 减少网络开销
2. 降低延迟
3. 提高可用性
4. 减轻数据库压力Q5: 如何设计缓存预热?
回答要点:
- 系统启动时加载热点数据
- 定时任务刷新缓存
- 监控访问量动态调整热点数据
- 结合布隆过滤器防止穿透
小结
- 缓存穿透:布隆过滤器、缓存空值
- 缓存击穿:互斥锁、逻辑过期
- 缓存雪崩:随机过期时间、多级缓存、熔断降级
- 缓存一致性:先更新数据库,再删除缓存
- 多级缓存:本地缓存 + 分布式缓存
- 缓存预热:启动加载、定时刷新