分布式缓存问题
三大经典问题
| 问题 | 说明 |
|---|---|
| 缓存穿透 | 查询不存在的数据,缓存和数据库都没有 |
| 缓存击穿 | 热点 Key 过期,大量请求直接打到数据库 |
| 缓存雪崩 | 大量 Key 同时过期,数据库压力骤增 |
缓存穿透
场景
用户请求 id = -1 的数据
↓
缓存未命中
↓
查询数据库
↓
数据库也没有
↓
返回空
↓
下次请求仍然穿透到数据库解决方案
1. 缓存空对象
public User getUser(Long id) {
String key = "user:" + id;
// 查缓存
String value = redis.get(key);
if (value != null) {
if ("NULL".equals(value)) {
return null; // 缓存的空对象
}
return JSON.parseObject(value, User.class);
}
// 查数据库
User user = userDao.findById(id);
// 缓存结果(包括空对象)
if (user == null) {
redis.set(key, "NULL", 60, TimeUnit.SECONDS); // 短过期时间
} else {
redis.set(key, JSON.toJSONString(user), 3600, TimeUnit.SECONDS);
}
return user;
}2. 布隆过滤器
@Component
public class BloomFilterService {
private BloomFilter<Long> userIdFilter;
@PostConstruct
public void init() {
// 预计元素数量 100 万,误判率 0.01%
userIdFilter = BloomFilter.create(Funnels.longFunnel(), 1000000, 0.0001);
// 初始化:加载所有用户 ID
List<Long> userIds = userDao.findAllIds();
userIds.forEach(userIdFilter::put);
}
public boolean mightContain(Long id) {
return userIdFilter.mightContain(id);
}
}
// 使用
public User getUser(Long id) {
// 布隆过滤器判断
if (!bloomFilterService.mightContain(id)) {
return null; // 一定不存在
}
// 可能存在,继续查询
return getUserFromCacheOrDb(id);
}布隆过滤器原理
位数组 + 多个哈希函数
添加元素:
1. 用 k 个哈希函数计算 k 个位置
2. 将这 k 个位置设为 1
查询元素:
1. 用 k 个哈希函数计算 k 个位置
2. 如果所有位置都是 1 → 可能存在
3. 如果有位置是 0 → 一定不存在
特点:
- 空间效率高
- 查询速度快
- 存在误判(可能误报存在)
- 不存在误判(不会误报不存在)缓存击穿
场景
热点 Key(如秒杀商品)
↓
Key 过期
↓
大量请求同时查询数据库
↓
数据库压力骤增解决方案
1. 互斥锁
public User getUser(Long id) {
String key = "user:" + id;
String lockKey = "lock:user:" + id;
// 查缓存
String value = redis.get(key);
if (value != null) {
return JSON.parseObject(value, User.class);
}
// 获取锁
try {
boolean locked = redis.setIfAbsent(lockKey, "1", 10, TimeUnit.SECONDS);
if (locked) {
// 获取锁成功,查询数据库
User user = userDao.findById(id);
redis.set(key, JSON.toJSONString(user), 3600, TimeUnit.SECONDS);
return user;
} else {
// 获取锁失败,等待重试
Thread.sleep(50);
return getUser(id); // 递归重试
}
} finally {
redis.delete(lockKey);
}
}2. 逻辑过期
public User getUser(Long id) {
String key = "user:" + id;
// 查缓存
String value = redis.get(key);
if (value != null) {
RedisData redisData = JSON.parseObject(value, RedisData.class);
// 判断是否过期
if (redisData.getExpireTime().isAfter(LocalDateTime.now())) {
return redisData.getData();
}
// 过期,异步刷新
executor.submit(() -> refreshCache(id));
// 返回旧数据
return redisData.getData();
}
// 缓存不存在,查数据库
return refreshCache(id);
}
private User refreshCache(Long id) {
User user = userDao.findById(id);
RedisData redisData = new RedisData();
redisData.setData(user);
redisData.setExpireTime(LocalDateTime.now().plusSeconds(3600));
// 不设置 TTL,通过逻辑过期控制
redis.set("user:" + id, JSON.toJSONString(redisData));
return user;
}3. 热点数据永不过期
// 设置热点数据永不过期
redis.set("hot:item:123", data);
// 后台定时刷新
@Scheduled(fixedRate = 300000) // 5分钟
public void refreshHotData() {
List<String> hotKeys = getHotKeys();
hotKeys.forEach(key -> {
Object data = loadDataFromDb(key);
redis.set(key, JSON.toJSONString(data));
});
}缓存雪崩
场景
大量 Key 同时过期
↓
请求全部打到数据库
↓
数据库压力骤增,可能宕机解决方案
1. 过期时间加随机值
public void setCache(String key, Object value) {
// 基础过期时间 + 随机值
int baseExpire = 3600;
int randomExpire = new Random().nextInt(300); // 0-300秒随机
redis.set(key, JSON.toJSONString(value), baseExpire + randomExpire, TimeUnit.SECONDS);
}2. 多级缓存
请求 → 本地缓存 → Redis → 数据库
↓
命中返回
↓
命中返回
↓
命中返回@Service
public class UserService {
private LoadingCache<Long, User> localCache = Caffeine.newBuilder()
.maximumSize(1000)
.expireAfterWrite(5, TimeUnit.MINUTES)
.build(id -> getFromRedis(id));
public User getUser(Long id) {
return localCache.get(id);
}
private User getFromRedis(Long id) {
String value = redis.get("user:" + id);
if (value != null) {
return JSON.parseObject(value, User.class);
}
User user = userDao.findById(id);
if (user != null) {
redis.set("user:" + id, JSON.toJSONString(user), 3600, TimeUnit.SECONDS);
}
return user;
}
}3. 熔断降级
@HystrixCommand(fallbackMethod = "getUserFallback")
public User getUser(Long id) {
return getFromCacheOrDb(id);
}
public User getUserFallback(Long id) {
// 返回默认值或空对象
return new User(id, "默认用户");
}缓存一致性问题
场景
更新数据库成功
↓
更新缓存失败
↓
缓存数据不一致解决方案
1. 先更新数据库,再删除缓存
@Transactional
public void updateUser(User user) {
// 1. 更新数据库
userDao.update(user);
// 2. 删除缓存
redis.delete("user:" + user.getId());
}2. 延迟双删
public void updateUser(User user) {
// 1. 删除缓存
redis.delete("user:" + user.getId());
// 2. 更新数据库
userDao.update(user);
// 3. 延迟删除缓存(防止读请求把旧数据写入缓存)
executor.schedule(() -> {
redis.delete("user:" + user.getId());
}, 500, TimeUnit.MILLISECONDS);
}3. 订阅 Binlog
数据库更新 → Canal 监听 Binlog → 发送消息 → 消费者删除缓存面试高频问题
Q1: 缓存穿透、击穿、雪崩的区别?
| 问题 | 原因 | 解决方案 |
|---|---|---|
| 穿透 | 查询不存在的数据 | 布隆过滤器、缓存空对象 |
| 击穿 | 热点 Key 过期 | 互斥锁、逻辑过期 |
| 雪崩 | 大量 Key 同时过期 | 随机过期时间、多级缓存 |
Q2: 如何保证缓存一致性?
- 先更新数据库,再删除缓存
- 延迟双删
- 订阅 Binlog 异步删除
Q3: 布隆过滤器的原理?
位数组 + 多个哈希函数,判断元素是否可能存在。
总结
分布式缓存问题核心要点:
1. 穿透:布隆过滤器、缓存空对象
2. 击穿:互斥锁、逻辑过期、热点永不过期
3. 雪崩:随机过期、多级缓存、熔断降级
4. 一致性:先更库再删缓存、延迟双删、Binlog