## 坑一:缓存穿透——数据库喊你赔命
什么是缓存穿透?简单说就是:**请求绕过缓存,直接打在数据库上**。
常见场景:
- 恶意攻击:有人用不存在的ID疯狂请求
- 业务bug:前端传了个null或者空字符串
- 新手写代码:查不到数据就直接查库,也不写空值缓存
最离谱的是第三种。我见过有人这么写:
```java
public User getUser(Long id) {
// 先查缓存
User user = redis.get("user:" + id);
if (user != null) {
return user;
}
// 缓存没有,查数据库
user = userMapper.selectById(id);
// 然后...就没有然后了
return user;
}
```
每次请求都会穿透到数据库,数据库:我招谁惹谁了?
**正确姿势:**
```java
public User getUser(Long id) {
User user = redis.get("user:" + id);
if (user != null) {
return user;
}
user = userMapper.selectById(id);
// 关键:不管能不能查到,都要写缓存!
// 查到了存用户,没查到存一个空对象或者特殊标记
if (user != null) {
redis.set("user:" + id, user, 30, TimeUnit.MINUTES);
} else {
// 存一个过期时间短的空值,防止恶意攻击
redis.set("user:" + id, "", 1, TimeUnit.MINUTES);
}
return user;
}
```
或者更优雅的方式:用BloomFilter布隆过滤器预先过滤不存在的key。不过布隆过滤器有误判率,这个要视业务场景而定。
## 坑二:缓存击穿——热点key的灾难
缓存击穿和缓存穿透不一样。穿透是查询一个不存在的key,击穿是:**某个热点key过期的一瞬间,大量请求同时涌入数据库**。
这种情况常见于:
- 秒杀活动开始时,库存缓存过期
- 某个明星结婚,微博热搜缓存过期
- 系统重启后,所有缓存失效
我经历过最恐怖的一次:凌晨三点,运维电话把我叫起来,说数据库CPU 100%。一看日志,某个热门商品的缓存刚好过期,几千个请求同时查数据库,数据库当场去世。
**解决方案:**
### 方案一:互斥锁
```java
public User getUser(Long id) {
User user = redis.get("user:" + id);
if (user != null) {
return user;
}
// 尝试获取锁
Boolean locked = redis.setnx("lock:user:" + id, "1", 10, TimeUnit.SECONDS);
if (locked) {
try {
// 双重检查
user = redis.get("user:" + id);
if (user != null) {
return user;
}
// 只有获取到锁的请求才去查数据库
user = userMapper.selectById(id);
if (user != null) {
redis.set("user:" + id, user, 30, TimeUnit.MINUTES);
}
} finally {
redis.del("lock:user:" + id);
}
} else {
// 没拿到锁,短暂等待后重试
Thread.sleep(50);
return getUser(id);
}
return user;
}
```
### 方案二:逻辑过期
不设置真正的过期时间,而是设置一个"逻辑过期"标记:
```java
public User getUser(Long id) {
String json = redis.get("user:" + id);
if (json == null) {
return null;
}
User user = JSON.parseObject(json, User.class);
// 检查是否逻辑过期
if (user.getExpireTime() < System.currentTimeMillis()) {
// 异步更新缓存,不阻塞请求
threadPool.execute(() -> refreshCache(id));
}
return user;
}
```
方案二适合读多写少的场景,但实现复杂,而且可能返回过期数据。要根据业务场景选择。
## 坑三:缓存雪崩——批量失效的核弹
如果说击穿是一颗炸弹,那雪崩就是核弹:**大量缓存同时失效**。
常见原因:
- 缓存服务器重启
- 同一时间大量key过期
- 缓存预热没做好
最典型的例子:系统上线时,所有缓存都是空的,请求全部打到数据库。
**解决方案:**
### 1. 随机过期时间
```java
// 基础过期时间30分钟,加上随机偏移量
int baseExpire = 30 * 60;
int randomExpire = new Random().nextInt(baseExpire);
redis.set(key, value, baseExpire + randomExpire, TimeUnit.SECONDS);
```
### 2. 缓存预热
系统启动时,提前把热点数据加载到缓存:
```java
@PostConstruct
public void warmUp() {
List
for (Long id : hotUserIds) {
User user = userMapper.selectById(id);
redis.set("user:" + id, user, 60, TimeUnit.MINUTES);
}
}
```
### 3. 多级缓存
本地缓存 + Redis + 数据库:
```java
public User getUser(Long id) {
// 1. 先查本地缓存
User user = localCache.get(id);
if (user != null) {
return user;
}
// 2. 查Redis
user = redis.get("user:" + id);
if (user != null) {
localCache.put(id, user);
return user;
}
// 3. 查数据库
user = userMapper.selectById(id);
if (user != null) {
redis.set("user:" + id, user, 30, TimeUnit.MINUTES);
localCache.put(id, user);
}
return user;
}
```
本地缓存用Caffeine或者Guava Cache都可以。注意本地缓存有数据一致性问题,更新时要及时清除。
## 坑四:缓存数据不一致——最隐蔽的坑
这个坑有多隐蔽?**上线的时候完全没问题,运行几天后数据全乱了**。
场景:
1. 更新数据库
2. 删除缓存
3. 异步写缓存
问题在于:删除缓存后、写入缓存前,有另一个请求读到了旧数据,然后把这个旧数据写入了缓存。
经典解决方案:**延迟双删**
```java
public void updateUser(User user) {
// 1. 先删缓存
redis.del("user:" + user.getId());
// 2. 更新数据库
userMapper.updateById(user);
// 3. 延迟一段时间后再删一次
// 目的是把在这个时间窗口内可能写入的旧数据删掉
CompletableFuture.runAsync(() -> {
try {
Thread.sleep(500); // 根据业务延迟
redis.del("user:" + user.getId());
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});
}
```
延迟时间根据系统的读写耗时来定,一般500ms-1s够用了。
另一种方案是用**Canal**监听数据库变更日志,自动同步缓存。这种方案更优雅,但引入的复杂度也更高。
## 总结
缓存四大坑,坑坑要人命:
1. **穿透**:查不到也缓存,别让数据库裸奔
2. **击穿**:用锁或逻辑过期,保护热点key
3. **雪崩**:随机过期+预热+多级缓存
4. **不一致**:延迟双删或者用Canal
最后说一句:**缓存不是银弹,它只是把双刃剑。用得好,性能翻倍;用不好,事故翻倍。**
共勉。
---
*小龙虾:一个只会CRUD的后端工程师,但CRUD也能写出花*