分布式缓存设计
问题
如何设计一个高可用的分布式缓存系统?
答案
多级缓存架构
缓存读写策略
| 策略 | 读 | 写 | 一致性 | 适用场景 |
|---|---|---|---|---|
| Cache Aside | 先查缓存,miss 查 DB 回填 | 先更新 DB,再删缓存 | 最终一致 | 通用方案(推荐) |
| Read/Write Through | 缓存代理读写 DB | 缓存代理写 DB | 强一致 | 缓存中间件支持 |
| Write Behind | 同上 | 异步批量写 DB | 弱一致 | 写多读少 |
Cache Aside 模式
public User getUser(long userId) {
String key = "user:" + userId;
// 1. 查缓存
User user = (User) redisTemplate.opsForValue().get(key);
if (user != null) return user;
// 2. 查数据库
user = userMapper.findById(userId);
if (user != null) {
// 3. 回填缓存(设置过期时间 + 随机偏移防雪崩)
int ttl = 3600 + new Random().nextInt(600);
redisTemplate.opsForValue().set(key, user, ttl, TimeUnit.SECONDS);
}
return user;
}
public void updateUser(User user) {
// 1. 先更新数据库
userMapper.update(user);
// 2. 再删除缓存
redisTemplate.delete("user:" + user.getId());
}
缓存三大问题
| 问题 | 现象 | 解决方案 |
|---|---|---|
| 穿透 | 查不存在的数据,每次打 DB | 缓存空值 / 布隆过滤器 |
| 击穿 | 热点 key 过期,大量请求打 DB | 互斥锁重建 / 永不过期 |
| 雪崩 | 大批 key 同时过期 | 过期时间加随机偏移 / 多级缓存 |
互斥锁防击穿
public User getUserWithLock(long userId) {
String key = "user:" + userId;
User user = (User) redisTemplate.opsForValue().get(key);
if (user != null) return user;
String lockKey = "lock:" + key;
try {
// 只有一个线程能获取锁去查 DB
Boolean locked = redisTemplate.opsForValue()
.setIfAbsent(lockKey, "1", 10, TimeUnit.SECONDS);
if (Boolean.TRUE.equals(locked)) {
user = userMapper.findById(userId);
redisTemplate.opsForValue().set(key, user, 3600, TimeUnit.SECONDS);
} else {
Thread.sleep(50);
return getUserWithLock(userId); // 重试
}
} finally {
redisTemplate.delete(lockKey);
}
return user;
}
本地缓存 + Redis 二级缓存
Caffeine 本地缓存 + Redis
@Component
public class TwoLevelCache {
private final Cache<String, Object> localCache = Caffeine.newBuilder()
.maximumSize(10_000)
.expireAfterWrite(5, TimeUnit.MINUTES)
.build();
@Autowired
private RedisTemplate<String, Object> redisTemplate;
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;
}
}
常见面试问题
Q1: 为什么是删除缓存而不是更新缓存?
答案:
- 更新缓存:并发写时可能导致脏数据(A 先更新 DB 但 B 后更新缓存覆盖了 A 的最新值)
- 删除缓存:下次读取时自然重建,保证最终一致
详见 缓存与数据库一致性。
Q2: 先删缓存还是先更新 DB?
答案:
先更新 DB,再删缓存。先删缓存会导致:删缓存后、更新 DB 前有请求读到旧数据并回填缓存,导致缓存和 DB 不一致。
Q3: 多级缓存的一致性怎么保证?
答案:
- Redis 缓存更新时通过 MQ/Redis Pub-Sub 通知所有实例清除本地缓存
- 本地缓存设置较短的 TTL(如 5 分钟)作为兜底