第 61 章:缓存三大问题
学习目标
- 识别缓存穿透、雪崩、击穿三种典型故障
- 掌握每种问题的解决方案
- 学会用布隆过滤器、互斥锁、随机 TTL 等工具
一、问题总览
| 问题 | 现象 | 危害 | 本质 |
|---|---|---|---|
| 穿透 | 查不存在的 key | 每次都打到 DB | 绕过缓存 |
| 击穿 | 热点 key 过期 | 大量请求打到 DB | 单点失效 |
| 雪崩 | 大量 key 同时过期 | DB 瞬时压力 | 批量失效 |
二、缓存穿透:查不存在的数据
场景
java
// 攻击者请求 id = -1, -2, -3, ...
userService.getById(-1L); // 每次都查 DB,缓存里没有解决方案
方案 1:缓存空值
java
@Cacheable(value = "user", key = "#id", unless = "#result == null")
public User getById(Long id) {
return userMapper.selectById(id); // 返回 null
}
// ✅ 改:把 null 也缓存(短期)
@Cacheable(value = "user", key = "#id")
public User getById(Long id) {
User user = userMapper.selectById(id);
if (user == null) {
// 缓存一个空标记(5 分钟过期),短 TTL 防止占内存
cacheManager.getCache("user").put(id, new NullUser());
return null;
}
return user;
}方案 2:布隆过滤器(更专业)
xml
<dependency>
<groupId>com.google.guava</groupId>
<artifactId>guava</artifactId>
<version>33.0.0-jre</version>
</dependency>java
@Component
public class BloomFilterHelper {
private final BloomFilter<Long> filter;
public BloomFilterHelper(List<Long> existingIds) {
// ① 初始化:把已有 ID 加进去(启动时执行)
this.filter = BloomFilter.create(
Funnels.longFunnel(),
10_000_000L, // 预计元素数
0.001 // 误判率 0.1%
);
existingIds.forEach(filter::put);
}
public boolean mightContain(Long id) {
return filter.mightContain(id);
}
}
@Service
@RequiredArgsConstructor
public class UserServiceImpl {
private final BloomFilterHelper bloomFilter;
public User getById(Long id) {
// ② 布隆过滤器说不存在,就一定不存在
if (!bloomFilter.mightContain(id)) {
return null; // 直接返回,不查 Redis 也不查 DB
}
return userMapper.selectById(id);
}
}布隆过滤器的特性:
- 可能存在 → 不一定真存在(误判),需要二次确认
- 一定不存在 → 一定不存在(没错)
- 不支持删除(标准布隆过滤器),可以用 Counting Bloom Filter 变体
Redis 版布隆过滤器(生产推荐):
bash
# Redis 4.0+ 自带 Bloom Filter 模块
BF.RESERVE user_filter 0.001 10000000
BF.ADD user_filter 1001
BF.EXISTS user_filter 1001java
@Component
@RequiredArgsConstructor
public class RedisBloomFilter {
private final RedisTemplate<String, Object> redis;
public void add(String key, Long id) {
redis.opsForValue().setBit(key, id, true); // 简化版,实际用 RedisBloom
}
public boolean mightContain(String key, Long id) {
Boolean bit = redis.opsForValue().getBit(key, id);
return Boolean.TRUE.equals(bit);
}
}三、缓存击穿:热点 key 过期瞬间
场景
java
// 秒杀商品详情,缓存设了 1 小时
@Cacheable(value = "seckill", key = "#skuId")
public SeckillProduct getSeckillProduct(Long skuId) {
return productMapper.selectSeckill(skuId);
}
// 缓存刚好过期 → 1 万个请求同时涌入 → DB 被打爆解决方案
方案 1:互斥锁(分布式锁)
java
@Cacheable(value = "seckill", key = "#skuId")
public SeckillProduct getSeckillProduct(Long skuId) {
return productMapper.selectSeckill(skuId);
}
// ✅ 自定义 CacheManager 加锁逻辑
@Component
public class SeckillCacheManager implements CacheManager {
private final RedisTemplate<String, Object> redis;
private final RedisLock lock;
@Override
public Cache getCache(String name) {
return new SeckillCache(name, redis, lock);
}
}
class SeckillCache implements Cache {
private static final String LOCK_PREFIX = "lock:";
public ValueWrapper get(Object key) {
String redisKey = name + ":" + key;
ValueWrapper value = redis.get(redisKey);
if (value != null) return value;
// ① 没拿到值 → 抢锁
String lockKey = LOCK_PREFIX + redisKey;
if (lock.tryLock(lockKey, 10, TimeUnit.SECONDS)) { // 10 秒超时
try {
// ② 抢到锁 → 查 DB,回填缓存
value = loadFromDb(key);
redis.set(redisKey, value, 60, TimeUnit.SECONDS);
return value;
} finally {
lock.unlock(lockKey);
}
} else {
// ③ 没抢到锁 → 短暂睡眠后重试
Thread.sleep(50);
return redis.get(redisKey);
}
}
}方案 2:逻辑过期(不真正过期)
java
// 缓存 value 里带一个 expires 字段
@Data
public class CacheData<T> {
private T data;
private Long expires; // 逻辑过期时间
}
@Cacheable(value = "seckill", key = "#skuId")
public SeckillProduct getSeckillProduct(Long skuId) {
// ① 从 DB 加载
SeckillProduct product = productMapper.selectSeckill(skuId);
// ② 封装成带过期时间的数据
CacheData<SeckillProduct> data = new CacheData<>();
data.setData(product);
data.setExpires(System.currentTimeMillis() + 60_000); // 逻辑 1 分钟
return data; // ⚠️ 注意:这里返回的是包装对象
}
// 实际读时:
public SeckillProduct readSeckill(Long skuId) {
CacheData<SeckillProduct> data = redis.get("seckill:" + skuId);
if (data == null) {
// 缓存里没 → 同步查 DB 并回填
return getSeckillProduct(skuId).getData();
}
if (System.currentTimeMillis() < data.getExpires()) {
return data.getData(); // 没过期,直接返回
}
// 过期了 → 异步重建(不阻塞当前请求)
threadPool.submit(() -> refreshCache(skuId));
return data.getData(); // 返回旧数据
}逻辑过期的精髓:宁可短暂返回旧数据,也不要让请求阻塞等待重建。
方案 3:永不过期 + 后台刷新
java
// 缓存设成逻辑过期(看上面)
// 或者:设置一个非常长的 TTL(比如 24 小时),后台定时刷新
@Scheduled(fixedDelay = 60000)
public void refreshHotCache() {
List<Long> hotSkuIds = getHotSkuIds();
for (Long skuId : hotSkuIds) {
SeckillProduct p = productMapper.selectSeckill(skuId);
redis.set("seckill:" + skuId, p, 24, TimeUnit.HOURS);
}
}方案 4:@Cacheable(sync = true)(最简单)
java
@Cacheable(value = "seckill", key = "#skuId", sync = true)
public SeckillProduct getSeckillProduct(Long skuId) {
return productMapper.selectSeckill(skuId);
}
sync = true让 Spring 用本地锁保证只有一个线程查 DB。但只对单机有效,集群下还是要用分布式锁。
四、缓存雪崩:大量 key 同时过期
场景
java
// 凌晨 3 点所有缓存同时过期(设了相同 TTL)
// → 1 万个请求同时查 DB → DB 挂了解决方案
方案 1:随机过期时间
java
// ❌ 固定 1 小时
redis.set(key, value, 3600, TimeUnit.SECONDS);
// ✅ 加随机扰动
int base = 3600;
int random = new Random().nextInt(600); // 0-10 分钟随机
redis.set(key, value, base + random, TimeUnit.SECONDS);方案 2:分散缓存重建时间
java
// 不同 key 用不同的过期时间
if ("hot".equals(type)) {
redis.set(key, value, 30 + random(10), TimeUnit.MINUTES);
} else if ("normal".equals(type)) {
redis.set(key, value, 120 + random(30), TimeUnit.MINUTES);
}方案 3:高可用 + 熔断降级
yaml
resilience4j:
circuitbreaker:
instances:
cache:
failureRateThreshold: 50 # 失败率超 50% 熔断
waitDurationInOpenState: 30s # 熔断 30 秒
slidingWindowSize: 100java
@CircuitBreaker(name = "cache", fallbackMethod = "fallback")
public User getById(Long id) {
return userMapper.selectById(id);
}
// 熔断后调用此方法
public User fallback(Long id, Throwable t) {
log.warn("DB 故障,返回降级数据 id={}", id);
return new User("系统繁忙,请稍后再试");
}方案 4:缓存预热
java
// 启动时就把热点数据加载进缓存,避免冷启动
@Component
public class CacheWarmer implements ApplicationRunner {
@Override
public void run(ApplicationArguments args) {
// 把热门商品、配置、字典全部加载
}
}五、数据不一致问题
场景
java
@Transactional
public void updateUser(User user) {
userMapper.updateById(user);
redis.del("user:" + user.getId()); // 删缓存
}
// 问题:先更 DB 再删缓存中间,有个窗口期可能不一致解决方案
方案 1:延迟双删
java
@Transactional
public void updateUser(User user) {
userMapper.updateById(user);
// ① 先删一次
redis.del("user:" + user.getId());
// ② 延迟再删一次(清除回填的脏数据)
executor.schedule(() -> {
redis.del("user:" + user.getId());
}, 500, TimeUnit.MILLISECONDS); // 500ms 后再删
}方案 2:基于消息队列的最终一致
java
@Transactional
public void updateUser(User user) {
userMapper.updateById(user);
// 发送消息给消费者去删缓存(消费者保证一定能删掉)
mqClient.send("user.update", user.getId());
}
@RocketMQMessageListener(topic = "user.update")
public void onMessage(Long userId) {
redis.del("user:" + userId);
}方案 3:使用 Canal 订阅 binlog
业务代码不改,缓存同步交给 Canal 监听 MySQL binlog 自动完成。详见后续 Canal 章节。
六、热点 Key 发现
bash
# ① redis-cli 命令(Redis 4.0+)
redis-cli --hotkeys
# ② redis-cli 监控命令
redis-cli --latency-history
# ③ 大 key 查找
redis-cli --bigkeys
# ④ 用 MONITOR 抓命令统计(注意性能影响)
redis-cli MONITOR | head -n 10000 | awk '{print $4}' | sort | uniq -c | sort -rn | headjava
// Java 端:用 redisTemplate 统计访问频次
@Component
public class HotKeyDetector {
private final LoadingCache<String, AtomicLong> counter = Caffeine.newBuilder()
.expireAfterWrite(Duration.ofSeconds(10))
.build(CaffeineCacheLoader.create(k -> new AtomicLong(0)));
public void recordAccess(String key) {
counter.get(key).incrementAndGet();
}
@Scheduled(fixedDelay = 10000)
public void report() {
Map<String, Long> snapshot = counter.asMap().entrySet().stream()
.collect(Collectors.toMap(Map.Entry::getKey, e -> e.getValue().get()));
log.info("热点 key TOP10: {}",
snapshot.entrySet().stream()
.sorted(Map.Entry.<String, Long>comparingByValue().reversed())
.limit(10)
.toList());
}
}七、本章小结
| 问题 | 解决方案 |
|---|---|
| 穿透 | 缓存空值 / 布隆过滤器 / 参数校验 |
| 击穿 | 互斥锁 / 逻辑过期 / sync = true / 永不过期+后台刷新 |
| 雪崩 | 随机 TTL / 熔断降级 / 缓存预热 / 多级缓存 |
| 不一致 | 延迟双删 / MQ 异步删 / Canal 订阅 binlog |
| 热点发现 | redis-cli --hotkeys / MONITOR / 应用层计数 |
动手练习
练习 1:基础题
实现空值缓存 + 布隆过滤器组合方案:先用布隆过滤器拦截,再用空值缓存兜底。
练习 2:进阶题
为热点商品详情实现"逻辑过期 + 异步重建"模式,验证:
- 缓存过期瞬间请求仍能返回(用旧数据)
- 只有一个后台线程在重建缓存
练习 3:思考题
你的系统里发生了一次缓存雪崩,请写一份故障复盘文档:
- 现象(监控截图、QPS 曲线)
- 根因分析(时间线)
- 解决方案(短期止血 + 长期改进)
下一章:第 62 章:消息队列与 RabbitMQ →