缓存是提升系统性能的利器,但也带来了三大经典问题:穿透、击穿、雪崩
三大问题概览
| 问题 | 定义 | 核心特征 |
|---|---|---|
| 缓存穿透 | 查询不存在的数据 | 缓存和数据库都没有 |
| 缓存击穿 | 热点数据过期瞬间 | 大量并发查询同一 key |
| 缓存雪崩 | 大量缓存同时过期 | 多个 key 同时失效 |
请求
│
▼
┌─────────────┐
│ 缓存层 │
└─────────────┘
│ │
命中 ↓ ↓ 未命中
返回 ┌─────────────┐
│ 数据库层 │
└─────────────┘
│ │
有数据 ↓ ↓ 无数据
写入缓存 穿透问题!
│
▼
返回
击穿:热点 key 过期 → 大量请求打到数据库
雪崩:大量 key 过期 → 数据库压力剧增
一、缓存穿透
问题场景
sql
-- 恶意请求查询不存在的数据
SELECT * FROM user WHERE id = -1; -- 缓存没有,数据库也没有
SELECT * FROM user WHERE id = -2; -- 每次都打到数据库
SELECT * FROM user WHERE id = -3;
...
恶意请求:
id = -1, -2, -3, -4, -5 ... -100000
每次请求:
1. 查缓存 → 没有
2. 查数据库 → 没有
3. 返回空
结果:数据库被打挂
解决方案
方案一:缓存空值
java
public User getUser(Long id) {
// 1. 参数校验
if (id == null || id <= 0) {
return null;
}
// 2. 查缓存
String key = "user:" + id;
String cachedValue = redis.get(key);
// 3. 缓存命中
if (cachedValue != null) {
// 判断是否为空值标记
if ("NULL".equals(cachedValue)) {
return null;
}
return JSON.parseObject(cachedValue, User.class);
}
// 4. 查数据库
User user = userMapper.findById(id);
// 5. 写入缓存
if (user == null) {
// 缓存空值,设置较短过期时间
redis.set(key, "NULL", 60);
return null;
}
redis.set(key, JSON.toJSONString(user), 3600);
return user;
}
优点 :实现简单
缺点:
- 占用内存(大量空值)
- 可能缓存不一致(数据新增后仍返回空)
方案二:布隆过滤器
java
@Service
public class UserService {
@Autowired
private BloomFilter<Long> bloomFilter;
@PostConstruct
public void init() {
// 初始化布隆过滤器,加载所有有效 ID
List<Long> allIds = userMapper.findAllIds();
for (Long id : allIds) {
bloomFilter.put(id);
}
}
public User getUser(Long id) {
// 1. 布隆过滤器判断
if (!bloomFilter.mightContain(id)) {
// 一定不存在,直接返回
return null;
}
// 2. 可能存在,正常查询
return getUserFromCacheOrDB(id);
}
}
布隆过滤器原理:
位数组(Bit Array):[0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
添加元素 "user:123":
hash1("user:123") % 10 = 3 → 位数组[3] = 1
hash2("user:123") % 10 = 7 → 位数组[7] = 1
hash3("user:123") % 10 = 9 → 位数组[9] = 1
位数组:[0, 0, 0, 1, 0, 0, 0, 1, 0, 1]
查询元素 "user:456":
hash1("user:456") % 10 = 3 → 位数组[3] = 1 ✓
hash2("user:456") % 10 = 5 → 位数组[5] = 0 ✗
结果:一定不存在!
查询元素 "user:789":
hash1("user:789") % 10 = 3 → 位数组[3] = 1 ✓
hash2("user:789") % 10 = 7 → 位数组[7] = 1 ✓
hash3("user:789") % 10 = 9 → 位数组[9] = 1 ✓
结果:可能存在(有误判概率)
特点:
- 不存在则一定不存在:可以100%过滤掉不存在的请求
- 存在则可能误判:有一定的误判率,但可以接受
方案三:参数校验 + 限流
java
public User getUser(Long id) {
// 1. 参数合法性校验
if (id == null || id <= 0) {
throw new IllegalArgumentException("无效的用户ID");
}
// 2. 限流保护
if (!rateLimiter.tryAcquire()) {
throw new RateLimitException("请求过于频繁");
}
// 3. 正常查询流程
return getUserFromCacheOrDB(id);
}
方案对比
| 方案 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| 缓存空值 | 实现简单 | 占内存、可能不一致 | 数据量小 |
| 布隆过滤器 | 内存占用小 | 有误判率、需初始化 | 数据量大 |
| 参数校验 | 防止恶意请求 | 无法完全避免 | 配合其他方案 |
二、缓存击穿
问题场景
热点数据(如:商品详情 id=1001):
时间线:
T1: 缓存过期,key 被删除
T2: 10000 个请求同时查询 id=1001
T3: 10000 个请求都发现缓存不存在
T4: 10000 个请求同时查询数据库
T5: 数据库压力瞬间飙升
缓存过期
│
▼
┌─────────────────────┐
│ 10000 个并发请求 │
└─────────────────────┘
│
▼
┌─────────────────────┐
│ 数据库 │ ← 瞬间被打挂
└─────────────────────┘
解决方案
方案一:互斥锁
java
public User getUser(Long id) {
String key = "user:" + id;
// 1. 查缓存
User user = getUserFromCache(key);
if (user != null) {
return user;
}
// 2. 获取分布式锁
String lockKey = "lock:user:" + id;
try {
if (redis.tryLock(lockKey, 10)) {
// 3. Double Check
user = getUserFromCache(key);
if (user != null) {
return user;
}
// 4. 查数据库
user = userMapper.findById(id);
// 5. 写入缓存
if (user != null) {
redis.set(key, JSON.toJSONString(user), 3600);
}
return user;
} else {
// 6. 获取锁失败,等待后重试
Thread.sleep(50);
return getUser(id);
}
} finally {
redis.unlock(lockKey);
}
}
流程图:
请求1 ──┐
请求2 ──┼──→ 查缓存(未命中)──→ 竞争锁 ──→ 请求1获得锁
请求3 ──┤ │
请求4 ──┤ ▼
请求5 ──┘ 查数据库 → 写缓存
│
请求2,3,4,5 等待 ◄────────────────────┘
│
▼
重试查缓存(命中)
方案二:逻辑过期
java
@Data
public class RedisData {
private Object data; // 实际数据
private Long expireTime; // 逻辑过期时间
}
public User getUser(Long id) {
String key = "user:" + id;
// 1. 查缓存
String json = redis.get(key);
if (json == null) {
// 缓存不存在,需要重建
return rebuildCache(key, id);
}
// 2. 反序列化
RedisData redisData = JSON.parseObject(json, RedisData.class);
User user = (User) redisData.getData();
// 3. 判断是否逻辑过期
if (redisData.getExpireTime() < System.currentTimeMillis()) {
// 已过期,异步重建
asyncRebuildCache(key, id);
}
// 4. 返回旧数据(保证可用性)
return user;
}
private void asyncRebuildCache(String key, Long id) {
String lockKey = "lock:" + key;
// 尝试获取锁
if (redis.tryLock(lockKey, 10)) {
// 异步重建
CompletableFuture.runAsync(() -> {
try {
rebuildCache(key, id);
} finally {
redis.unlock(lockKey);
}
});
}
}
private User rebuildCache(String key, Long id) {
// 1. 查数据库
User user = userMapper.findById(id);
// 2. 封装逻辑过期时间
RedisData redisData = new RedisData();
redisData.setData(user);
redisData.setExpireTime(System.currentTimeMillis() + 3600 * 1000);
// 3. 写入缓存(不设置 TTL)
redis.set(key, JSON.toJSONString(redisData));
return user;
}
特点:
- 缓存永不过期(物理 TTL)
- 通过逻辑过期时间判断是否需要刷新
- 过期时返回旧数据,异步重建新数据
方案三:热点数据永不过期
java
// 热点数据设置较长过期时间或不设置过期时间
public void cacheHotData(Long id) {
User user = userMapper.findById(id);
// 方式1:设置很长过期时间
redis.set("user:" + id, JSON.toJSONString(user), 24 * 3600);
// 方式2:不设置过期时间,主动更新
redis.set("user:" + id, JSON.toJSONString(user));
}
// 数据变更时主动更新缓存
public void updateUser(User user) {
userMapper.update(user);
redis.set("user:" + user.getId(), JSON.toJSONString(user), 24 * 3600);
}
方案对比
| 方案 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| 互斥锁 | 强一致性 | 需等待、可能死锁 | 对一致性要求高 |
| 逻辑过期 | 高可用、无等待 | 可能返回旧数据 | 对可用性要求高 |
| 永不过期 | 简单可靠 | 需主动维护 | 热点数据 |
三、缓存雪崩
问题场景
场景1:大量缓存同时过期
- 缓存预热时设置了相同的过期时间
- 如:所有商品缓存都在凌晨 2 点过期
场景2:Redis 宕机
- Redis 服务挂掉
- 所有请求直接打到数据库
时间线:
T1: 10000 个 key 同时过期
T2: 10000 个请求同时查数据库
T3: 数据库负载飙升
T4: 系统崩溃
缓存同时过期
│
▼
┌─────────────────────┐
│ 大量请求打到数据库 │
└─────────────────────┘
│
▼
┌─────────────────────┐
│ 数据库崩溃 │
└─────────────────────┘
解决方案
方案一:随机过期时间
java
public void cacheData(String key, Object value) {
// 基础过期时间
long baseExpire = 3600;
// 添加随机偏移(0-300秒)
long randomExpire = ThreadLocalRandom.current().nextLong(300);
// 设置过期时间
redis.set(key, JSON.toJSONString(value), baseExpire + randomExpire);
}
// 批量缓存时
public void batchCache(List<User> users) {
for (User user : users) {
long expire = 3600 + ThreadLocalRandom.current().nextLong(600);
redis.set("user:" + user.getId(), JSON.toJSONString(user), expire);
}
}
效果:
原来:
key1: 过期时间 3600 秒
key2: 过期时间 3600 秒
key3: 过期时间 3600 秒
→ 同时过期!
现在:
key1: 过期时间 3600 + 123 = 3723 秒
key2: 过期时间 3600 + 456 = 4056 秒
key3: 过期时间 3600 + 789 = 4389 秒
→ 分散过期!
方案二:多级缓存
java
@Service
public class UserService {
// 本地缓存(Caffeine)
private Cache<Long, User> localCache = Caffeine.newBuilder()
.maximumSize(1000)
.expireAfterWrite(300, TimeUnit.SECONDS)
.build();
public User getUser(Long id) {
// 1. 查本地缓存
User user = localCache.getIfPresent(id);
if (user != null) {
return user;
}
// 2. 查 Redis
String key = "user:" + id;
String json = redis.get(key);
if (json != null) {
user = JSON.parseObject(json, User.class);
// 回填本地缓存
localCache.put(id, user);
return user;
}
// 3. 查数据库
user = userMapper.findById(id);
if (user != null) {
// 写入 Redis
redis.set(key, JSON.toJSONString(user), 3600);
// 写入本地缓存
localCache.put(id, user);
}
return user;
}
}
多级缓存架构:
请求
│
▼
┌─────────────┐
│ 本地缓存 │ ← L1 缓存,毫秒级
│ (Caffeine) │
└─────────────┘
│ 未命中
▼
┌─────────────┐
│ 分布式缓存 │ ← L2 缓存,亚毫秒级
│ (Redis) │
└─────────────┘
│ 未命中
▼
┌─────────────┐
│ 数据库 │ ← 最后防线
└─────────────┘
方案三:熔断降级
java
@Service
public class UserService {
@Autowired
private CircuitBreaker circuitBreaker;
public User getUser(Long id) {
return circuitBreaker.executeSupplier(() -> {
// 正常查询流程
return getUserFromCacheOrDB(id);
}, () -> {
// 降级逻辑:返回默认值或缓存数据
return getDefaultUser(id);
});
}
private User getDefaultUser(Long id) {
// 返回默认用户或从备份缓存读取
return new User(id, "默认用户", "default.png");
}
}
熔断器状态:
请求失败率 > 阈值
┌─────────────────────┐
│ │
▼ │
┌────────┐ 半开状态 ┌────────┐
│ 关闭 │◄─────────│ 打开 │
│ (正常) │ │ (熔断) │
└────────┘──────────►└────────┘
│ 请求成功 ▲
│ │
└───────────────────┘
请求失败率 < 阈值
方案四:Redis 高可用
┌─────────────────────────────────────────────────────────┐
│ Redis 高可用架构 │
├─────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────┐ │
│ │ Sentinel │ ← 哨兵监控 │
│ │ 集群 │ 自动故障转移 │
│ └─────────────┘ │
│ │ │
│ ┌────┴────┐ │
│ ▼ ▼ │
│ ┌──────┐ ┌──────┐ │
│ │Master│ │Slave │ ← 主从复制 │
│ │(读写)│ │(只读)│ │
│ └──────┘ └──────┘ │
│ │
│ 或 │
│ │
│ ┌────┬────┬────┬────┬────┬────┐ │
│ │节点1│节点2│节点3│节点4│节点5│节点6│ ← Redis Cluster │
│ └────┴────┴────┴────┴────┴────┘ 数据分片 │
│ │
└─────────────────────────────────────────────────────────┘
yaml
# application.yml
spring:
redis:
sentinel:
master: mymaster
nodes:
- 192.168.1.1:26379
- 192.168.1.2:26379
- 192.168.1.3:26379
password: yourpassword
方案对比
| 方案 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| 随机过期时间 | 实现简单 | 不能完全避免 | 预防为主 |
| 多级缓存 | 高可用 | 数据一致性复杂 | 高并发系统 |
| 熔断降级 | 保护系统 | 影响用户体验 | 兜底方案 |
| Redis 高可用 | 根本解决 | 成本高 | 核心业务 |
四、综合防护方案
完整代码示例
java
@Service
@Slf4j
public class CacheService {
@Autowired
private RedisTemplate<String, Object> redisTemplate;
@Autowired
private BloomFilter<String> bloomFilter;
@Autowired
private Cache<String, Object> localCache;
@Autowired
private RateLimiter rateLimiter;
/**
* 综合防护的缓存查询
*/
public <T> T getWithProtection(String key, Class<T> type, Supplier<T> dbLoader) {
// 1. 限流保护
if (!rateLimiter.tryAcquire()) {
log.warn("请求被限流: {}", key);
return null;
}
// 2. 布隆过滤器判断(防止穿透)
if (!bloomFilter.mightContain(key)) {
log.debug("布隆过滤器判断不存在: {}", key);
return null;
}
// 3. 查本地缓存(防止雪崩)
T value = (T) localCache.getIfPresent(key);
if (value != null) {
return value;
}
// 4. 查 Redis
value = getFromRedis(key, type);
if (value != null) {
localCache.put(key, value);
return value;
}
// 5. 获取分布式锁(防止击穿)
String lockKey = "lock:" + key;
try {
if (tryLock(lockKey)) {
// Double Check
value = getFromRedis(key, type);
if (value != null) {
return value;
}
// 6. 查数据库
value = dbLoader.get();
// 7. 写入缓存
if (value != null) {
setToRedis(key, value);
localCache.put(key, value);
} else {
// 缓存空值(防止穿透)
redisTemplate.opsForValue().set(key, "NULL", 60, TimeUnit.SECONDS);
}
return value;
} else {
// 等待后重试
Thread.sleep(50);
return getWithProtection(key, type, dbLoader);
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return null;
} finally {
unlock(lockKey);
}
}
private <T> T getFromRedis(String key, Class<T> type) {
Object value = redisTemplate.opsForValue().get(key);
if (value == null || "NULL".equals(value)) {
return null;
}
return (T) value;
}
private <T> void setToRedis(String key, T value) {
// 随机过期时间(防止雪崩)
long expire = 3600 + ThreadLocalRandom.current().nextLong(600);
redisTemplate.opsForValue().set(key, value, expire, TimeUnit.SECONDS);
}
}
防护策略总结
┌─────────────────────────────────────────────────────────────┐
│ 缓存防护体系 │
├─────────────────────────────────────────────────────────────┤
│ │
│ 第一层:限流 │
│ ├── 令牌桶/漏桶算法 │
│ └── 防止恶意请求 │
│ │
│ 第二层:布隆过滤器 │
│ ├── 过滤不存在的 key │
│ └── 防止缓存穿透 │
│ │
│ 第三层:本地缓存 │
│ ├── Caffeine/Guava Cache │
│ └── 防止缓存雪崩 │
│ │
│ 第四层:分布式锁 │
│ ├── Redisson/Redis Lock │
│ └── 防止缓存击穿 │
│ │
│ 第五层:随机过期 │
│ ├── 过期时间 + 随机值 │
│ └── 防止缓存雪崩 │
│ │
│ 第六层:熔断降级 │
│ ├── Sentinel/Hystrix │
│ └── 兜底保护 │
│ │
└─────────────────────────────────────────────────────────────┘
五、监控告警
关键监控指标
yaml
# Prometheus 监控指标
- name: cache_hit_rate
type: gauge
description: 缓存命中率
- name: cache_qps
type: counter
description: 缓存 QPS
- name: db_qps
type: counter
description: 数据库 QPS
- name: cache_error_rate
type: gauge
description: 缓存错误率
告警规则
yaml
# 告警配置
groups:
- name: cache_alerts
rules:
- alert: CacheHitRateLow
expr: cache_hit_rate < 0.8
for: 5m
labels:
severity: warning
annotations:
summary: "缓存命中率过低"
- alert: CacheErrorRateHigh
expr: cache_error_rate > 0.05
for: 1m
labels:
severity: critical
annotations:
summary: "缓存错误率过高"
- alert: DbQpsSpike
expr: rate(db_qps[1m]) > 10000
for: 1m
labels:
severity: critical
annotations:
summary: "数据库 QPS 激增,可能发生缓存雪崩"
总结
问题速查表
| 问题 | 根因 | 核心方案 |
|---|---|---|
| 穿透 | 查询不存在的数据 | 布隆过滤器 + 缓存空值 |
| 击穿 | 热点 key 过期 | 互斥锁 + 逻辑过期 |
| 雪崩 | 大量 key 同时过期 | 随机过期 + 多级缓存 |
最佳实践
- 预防为主:随机过期时间、布隆过滤器
- 多层防护:本地缓存 + Redis + 熔断降级
- 监控告警:缓存命中率、数据库 QPS
- 高可用架构:Redis 集群、主从复制
一句话总结
穿透用布隆过滤器,击穿用互斥锁,雪崩用随机过期,三者结合多级缓存和熔断降级,构建完整的缓存防护体系。