上个月公司秒杀活动,Redis 被打挂了两次。第一次是缓存穿透,恶意请求直接打到 MySQL,数据库连接池满了;第二次是缓存雪崩,一批 key 同时过期,流量瞬间压垮后端。事后复盘,把这三个经典问题从头到尾捋了一遍,配上可运行的代码和压测数据,记录一下。
一、先看场景:一个秒杀接口
简化后的业务逻辑:
java
public Product getProduct(Long id) {
String key = "product:" + id;
// 1. 查缓存
String json = redis.get(key);
if (json != null) {
return JSON.parseObject(json, Product.class);
}
// 2. 查数据库
Product product = productMapper.selectById(id);
// 3. 回写缓存
if (product != null) {
redis.set(key, JSON.toJSONString(product), 3600);
}
return product;
}
看着没问题,但线上跑起来,三种翻车方式轮番上演。
二、缓存穿透:查一个不存在的数据
现象
活动开始后,监控发现 MySQL QPS 飙到 8000,Redis 命中率只有 40%。查日志发现大量请求的 id 是负数或者不存在的 ID,比如 -1、999999999。
原因
请求查询一个数据库里根本不存在的数据。缓存永远不命中,每次都打到 DB。如果是恶意攻击,可以轻松打穿数据库。
解决方案一:缓存空值
java
public Product getProduct(Long id) {
String key = "product:" + id;
String json = redis.get(key);
if (json != null) {
// 空值标记,直接返回 null
if ("NULL".equals(json)) {
return null;
}
return JSON.parseObject(json, Product.class);
}
Product product = productMapper.selectById(id);
if (product != null) {
redis.set(key, JSON.toJSONString(product), 3600);
} else {
// 缓存空值,过期时间短一点,避免脏数据长期存在
redis.set(key, "NULL", 60);
}
return product;
}
优点 :实现简单,立竿见影。
缺点:如果攻击者每次用不同的随机 ID,会缓存大量空值,浪费内存。需要配合限流或参数校验。
解决方案二:布隆过滤器
在缓存之前加一层布隆过滤器,把所有存在的 ID 提前放进去。请求来了先过布隆过滤器,不存在直接返回。
java
@Component
public class BloomFilterConfig {
private BloomFilter<Long> bloomFilter;
@PostConstruct
public void init() {
// 预估 100 万数据,误判率 1%
bloomFilter = BloomFilter.create(
Funnels.longFunnel(), 1_000_000, 0.01);
// 启动时加载所有商品 ID(实际项目可分页加载)
List<Long> ids = productMapper.selectAllIds();
ids.forEach(bloomFilter::put);
}
public boolean mightExist(Long id) {
return bloomFilter.mightContain(id);
}
}
java
public Product getProduct(Long id) {
// 布隆过滤器拦截
if (!bloomFilterConfig.mightExist(id)) {
return null;
}
// ... 原有缓存逻辑
}
注意:布隆过滤器有误判率(说存在但实际不存在),所以不能完全替代空值缓存,两者配合使用效果最好。另外新增商品时要同步写入布隆过滤器。
三、缓存击穿:热点 key 过期瞬间
现象
某个爆款商品 ID 的缓存刚好在活动高峰期过期,一瞬间几千个请求同时打到 DB,数据库 CPU 直接 100%。
原因
单个热点 key 过期 ,大量并发请求同时穿透到数据库。注意和穿透的区别:击穿是数据存在 但缓存失效了,穿透是数据不存在。
解决方案一:互斥锁
只让一个线程去查 DB 重建缓存,其他线程等待。
java
public Product getProductWithLock(Long id) {
String key = "product:" + id;
String json = redis.get(key);
if (json != null) {
return JSON.parseObject(json, Product.class);
}
String lockKey = "lock:product:" + id;
// SETNX 加锁,过期时间 10 秒防止死锁
Boolean locked = redis.setIfAbsent(lockKey, "1", 10, TimeUnit.SECONDS);
if (Boolean.TRUE.equals(locked)) {
try {
// 双重检查,防止重复查库
json = redis.get(key);
if (json != null) {
return JSON.parseObject(json, Product.class);
}
Product product = productMapper.selectById(id);
if (product != null) {
redis.set(key, JSON.toJSONString(product), 3600);
}
return product;
} finally {
redis.delete(lockKey);
}
} else {
// 没抢到锁,短暂休眠后重试
try {
Thread.sleep(50);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
return getProductWithLock(id);
}
}
优点 :保证一致性,DB 压力可控。
缺点:吞吐量下降,等待的线程有阻塞风险。适合强一致性场景。
解决方案二:逻辑过期
缓存永不过期,但在 value 里存一个逻辑过期时间。发现逻辑过期后,异步重建缓存,当前请求先返回旧数据。
java
public Product getProductWithLogicalExpire(Long id) {
String key = "product:" + id;
String json = redis.get(key);
if (json == null) {
return null;
}
CacheData cacheData = JSON.parseObject(json, CacheData.class);
if (cacheData.getExpireAt() > System.currentTimeMillis()) {
return cacheData.getData();
}
// 逻辑过期,异步重建
String lockKey = "lock:product:" + id;
if (Boolean.TRUE.equals(redis.setIfAbsent(lockKey, "1", 10, TimeUnit.SECONDS))) {
threadPool.execute(() -> {
try {
Product product = productMapper.selectById(id);
CacheData newData = new CacheData(
product, System.currentTimeMillis() + 3600_000);
redis.set(key, JSON.toJSONString(newData));
} finally {
redis.delete(lockKey);
}
});
}
// 先返回旧数据
return cacheData.getData();
}
优点 :高可用,不阻塞请求。
缺点:短时间数据不一致。适合秒杀商品详情这类能容忍短暂脏数据的场景。
四、缓存雪崩:大批 key 同时过期
现象
凌晨批量导入商品时,给所有 key 设置了统一的 3600 秒过期时间。一小时后,几千个 key 同时失效,DB QPS 从 200 瞬间冲到 6000,连接池被打满。
原因
大量 key 在同一时间过期,或者 Redis 实例宕机,所有流量直接打到 DB。
解决方案
1. 过期时间打散
java
// 基础过期时间 + 随机偏移,避免同时失效
int expire = 3600 + ThreadLocalRandom.current().nextInt(600);
redis.set(key, json, expire);
2. 多级缓存
本地缓存(Caffeine)+ Redis,Redis 挂了还有本地缓存兜底。
java
LoadingCache<Long, Product> localCache = Caffeine.newBuilder()
.maximumSize(10_000)
.expireAfterWrite(5, TimeUnit.MINUTES)
.build(id -> getProductFromRedisOrDb(id));
3. 熔断降级
用 Sentinel 或 Hystrix 给 DB 调用加熔断,超过阈值直接返回兜底数据,保护数据库。
java
@SentinelResource(value = "queryProduct",
blockHandler = "handleBlock",
fallback = "handleFallback")
public Product queryProduct(Long id) {
return getProduct(id);
}
public Product handleBlock(Long id, BlockException e) {
// 限流后的兜底:返回默认商品或缓存中的旧数据
return new Product(id, "商品暂时火爆,请稍后重试");
}
4. Redis 高可用
主从 + 哨兵或 Cluster 集群,避免单点故障导致的全量雪崩。
五、三种问题对比总结
| 问题 | 触发条件 | 数据是否存在 | 核心危害 | 首选方案 |
|---|---|---|---|---|
| 穿透 | 查询不存在的数据 | 否 | 恶意流量打穿 DB | 布隆过滤器 + 空值缓存 |
| 击穿 | 单个热点 key 过期 | 是 | 瞬时并发压垮 DB | 互斥锁 / 逻辑过期 |
| 雪崩 | 大量 key 同时过期或 Redis 宕机 | 是 | 整体流量打垮 DB | 过期打散 + 多级缓存 + 熔断 |
六、压测数据对比
用 JMeter 模拟 5000 并发查询同一个热点 key,缓存过期后的表现:
| 方案 | 平均 RT | DB QPS 峰值 | 错误率 |
|---|---|---|---|
| 无保护 | 1200ms | 4800 | 12% |
| 互斥锁 | 85ms | 180 | 0% |
| 逻辑过期 | 12ms | 15(异步) | 0% |
逻辑过期在可用性上明显更优,互斥锁在一致性上更稳。实际项目里,商品详情用逻辑过期,库存扣减用互斥锁,按场景选。
七、最后的建议
- 空值缓存的过期时间别设太长,60 秒左右够了,否则新增数据后会有延迟。
- 布隆过滤器要留扩容方案,数据量增长后误判率会上升,可以考虑 RedisBloom 模块。
- 互斥锁的过期时间要大于业务执行时间,否则锁提前释放会出问题,可以用 Redisson 的看门狗机制。
- 逻辑过期的异步重建要用独立线程池,别跟业务线程池混用。
- 监控一定要加:缓存命中率、DB QPS、Redis 连接数、慢查询,这三个问题都能从监控里提前发现苗头。
缓存三大问题说起来是老生常谈,但真到了高并发场景,细节决定成败。希望这篇踩坑记录能帮你少挂一次线上。
参考:
- 《Redis 设计与实现》黄健宏
- Redisson 官方文档:https://redisson.org
- 布隆过滤器原理:Bloom Filter 论文(Burton H. Bloom, 1970)