2023 年我们一个商品详情服务,日常 QPS 5w,缓存命中率 99%,DB 压力小。某天爆款上线,QPS 飙到 30w,缓存命中率跌到 60%,DB 直接被打挂,雪崩拖垮整个商品域。复盘后系统性补了:缓存预热、布隆过滤器、互斥锁回源、二级缓存、随机过期。爆款再上不会挂了,DB QPS 始终 < 1k。本文复盘缓存三大问题(穿透 / 击穿 / 雪崩)的工程修法。
事故现场
服务:商品详情(Spring Boot + Redis + MySQL)
日常:5w QPS,Redis 命中 99%,MySQL 500 QPS
爆款上线:30w QPS
时间线:
20:00 爆款活动开始
20:01 QPS 飙到 30w
20:02 Redis 命中率从 99% 跌到 60%
20:03 MySQL QPS 飙到 12w,慢查询激增
20:05 MySQL 慢查询日志爆掉磁盘
20:06 应用线程池打满,P99 30s
20:08 整个商品域 503,雪崩
20:15 紧急扩容 Redis,降级商品详情
21:00 恢复
复盘:三大问题同时爆发
1. 缓存穿透:有人爬不存在的商品 ID
2. 缓存击穿:爆款 key 突然过期,大量请求穿透到 DB
3. 缓存雪崩:大量 key 同时过期(批量预热的)
问题 1:缓存穿透
现象:查询不存在的数据,缓存 miss 后每次都打 DB
攻击:有人故意拿不存在的 ID 刷,DB 被打爆
修法:
A. 缓存空值(短 TTL)
B. 布隆过滤器(前置过滤)
C. 接口层校验(ID 合法性)
// 方案 A:缓存空值
public Product getById(Long id) {
String key = "product:" + id;
String cached = redis.get(key);
if (cached != null) {
if ("NULL".equals(cached)) {
return null; // 空值标记
}
return JSON.parseObject(cached, Product.class);
}
Product product = productMapper.selectById(id);
if (product == null) {
redis.setex(key, 60, "NULL"); // 空值缓存 60s
return null;
}
redis.setex(key, 3600, JSON.toJSONString(product));
return product;
}
// 方案 B:布隆过滤器
@Component
public class ProductBloomFilter {
private final BloomFilter filter;
public ProductBloomFilter() {
// 预期 1000w 商品,误判率 0.01%
filter = BloomFilter.create(Funnels.longFunnel(), 10_000_000, 0.0001);
// 启动时加载所有有效商品 ID
productMapper.selectAllIds().forEach(filter::put);
}
public boolean mightExist(Long id) {
return filter.mightContain(id);
}
public void add(Long id) {
filter.put(id);
}
}
// 使用
public Product getById(Long id) {
if (!bloomFilter.mightExist(id)) {
return null; // 直接返回,不查 DB
}
// ... 正常缓存查询
}
// Redis Bloom 模块(分布式场景)
// 不依赖单机内存
$ redis-cli BF.RESERVE products 0.0001 10000000
$ redis-cli BF.ADD products 12345
$ redis-cli BF.EXISTS products 99999 # 0 (不存在)
问题 2:缓存击穿
现象:某个 hot key 突然过期,大量请求同时打 DB
典型:爆款商品、首页 banner
修法:
A. 互斥锁回源(只一个请求查 DB)
B. 永不过期 + 异步更新
C. 多级缓存
// 方案 A:互斥锁
public Product getByIdWithLock(Long id) {
String key = "product:" + id;
String cached = redis.get(key);
if (cached != null) {
return JSON.parseObject(cached, Product.class);
}
String lockKey = "lock:product:" + id;
String lockId = UUID.randomUUID().toString();
try {
// SET NX EX 加锁(10 秒锁)
boolean locked = redis.set(lockKey, lockId, "NX", "EX", 10);
if (!locked) {
// 没拿到锁,等一会再查缓存
Thread.sleep(50);
return getByIdWithLock(id); // 递归重试
}
// 双重检查
cached = redis.get(key);
if (cached != null) {
return JSON.parseObject(cached, Product.class);
}
// 查 DB
Product product = productMapper.selectById(id);
if (product != null) {
redis.setex(key, 3600, JSON.toJSONString(product));
}
return product;
} finally {
// 释放锁(只删自己的)
String script = "if redis.call('get', KEYS[1]) == ARGV[1] " +
"then return redis.call('del', KEYS[1]) else return 0 end";
redis.eval(script, Arrays.asList(lockKey), Arrays.asList(lockId));
}
}
// 方案 B:逻辑过期(永不真过期)
@Data
public class CacheEntry {
private T data;
private long expireAt; // 业务上的过期时间
}
public Product getByIdLogicalExpire(Long id) {
String key = "product:" + id;
String cached = redis.get(key);
if (cached == null) {
return null; // 数据本身不存在(布隆已过)
}
CacheEntry entry = JSON.parseObject(cached,
new TypeReference>(){});
if (entry.getExpireAt() > System.currentTimeMillis()) {
return entry.getData(); // 没过期,直接返回
}
// 过期了,异步更新(只一个线程更新)
if (redis.setnx("update_lock:" + id, "1") == 1) {
redis.expire("update_lock:" + id, 30);
CompletableFuture.runAsync(() -> {
try {
Product fresh = productMapper.selectById(id);
CacheEntry newEntry = new CacheEntry<>();
newEntry.setData(fresh);
newEntry.setExpireAt(System.currentTimeMillis() + 3600000);
redis.set(key, JSON.toJSONString(newEntry)); // 永不过期
} finally {
redis.del("update_lock:" + id);
}
});
}
return entry.getData(); // 返回旧数据(stale-while-revalidate)
}
问题 3:缓存雪崩
现象:大量 key 同时过期,瞬间 DB 压力暴增
典型:批量预热设了同一 TTL,凌晨同时过期
修法:
A. 随机过期时间(打散)
B. 多级缓存(本地 + Redis)
C. 限流降级
D. Redis 集群高可用
// 方案 A:随机过期
public void setProductCache(Long id, Product product) {
int baseTtl = 3600;
int randomOffset = ThreadLocalRandom.current().nextInt(600); // 0-10min 随机
redis.setex("product:" + id, baseTtl + randomOffset, JSON.toJSONString(product));
}
// 方案 B:本地缓存(Caffeine)
@Configuration
public class CaffeineConfig {
@Bean
public Cache productCache() {
return Caffeine.newBuilder()
.maximumSize(10000) // 热门 1w 个本地缓存
.expireAfterWrite(Duration.ofMinutes(5))
.refreshAfterWrite(Duration.ofMinutes(1)) // 1min 后异步刷新
.recordStats()
.build();
}
}
@Service
public class ProductService {
@Autowired private Cache localCache;
@Autowired private StringRedisTemplate redis;
public Product getById(Long id) {
// L1:本地缓存
Product product = localCache.getIfPresent(id);
if (product != null) {
return product;
}
// L2:Redis
String cached = redis.opsForValue().get("product:" + id);
if (cached != null) {
product = JSON.parseObject(cached, Product.class);
localCache.put(id, product);
return product;
}
// L3:DB(加互斥锁)
product = loadFromDB(id);
if (product != null) {
redis.opsForValue().set("product:" + id, JSON.toJSONString(product),
Duration.ofSeconds(3600 + ThreadLocalRandom.current().nextInt(600)));
localCache.put(id, product);
}
return product;
}
}
// 方案 C:限流降级(Sentinel)
@SentinelResource(
value = "getProduct",
blockHandler = "blockedHandler",
fallback = "fallbackHandler"
)
public Product getById(Long id) {
return productService.getById(id);
}
public Product blockedHandler(Long id, BlockException ex) {
return Product.builder().id(id).name("加载中,请稍后").build();
}
public Product fallbackHandler(Long id, Throwable ex) {
// 降级:返回默认值或脏读
return localCache.getIfPresent(id);
}
预热策略
// 启动预热(热点商品)
@Component
public class CacheWarmer {
@PostConstruct
public void warmup() {
// 异步预热,不阻塞启动
CompletableFuture.runAsync(() -> {
List hotIds = productMapper.selectHotIds(10000); // 1w 个热门
for (Long id : hotIds) {
try {
Product p = productMapper.selectById(id);
if (p != null) {
int ttl = 3600 + ThreadLocalRandom.current().nextInt(600);
redis.setex("product:" + id, ttl, JSON.toJSONString(p));
}
Thread.sleep(2); // 不打爆 Redis
} catch (Exception e) {
log.error("warmup failed for {}", id, e);
}
}
log.info("cache warmup completed: {}", hotIds.size());
});
}
}
// 大促预热(运营触发)
@RestController
public class AdminController {
@PostMapping("/admin/cache/warmup")
public String warmup(@RequestBody WarmupRequest req) {
// 异步执行
warmupExecutor.submit(() -> {
for (Long id : req.getProductIds()) {
Product p = productMapper.selectById(id);
if (p != null) {
int ttl = 7200 + ThreadLocalRandom.current().nextInt(1200);
redis.setex("product:" + id, ttl, JSON.toJSONString(p));
}
}
});
return "OK";
}
}
监控指标
# Prometheus 指标
- cache_hit_ratio{layer="local"} # 本地命中率
- cache_hit_ratio{layer="redis"} # Redis 命中率
- cache_miss_to_db_qps # 穿透到 DB 的 QPS
- bloom_filter_false_positive # 布隆误判率
- cache_lock_contention # 互斥锁竞争次数
# 告警
- alert: CacheHitRatioLow
expr: cache_hit_ratio{layer="redis"} < 0.95
for: 5m
annotations:
summary: "Redis 命中率 < 95%,可能击穿"
- alert: CacheMissToDbHigh
expr: cache_miss_to_db_qps > 5000
for: 2m
annotations:
summary: "穿透 QPS 异常,可能缓存雪崩"
- alert: RedisKeyExpireSpike
expr: rate(redis_expired_keys_total[1m]) > 10000
for: 1m
annotations:
summary: "Redis key 大量过期"
实战效果
压测:模拟爆款 30w QPS,持续 30 分钟
指标 优化前 优化后 变化
=========================================================
Redis 命中率 60%(雪崩) 99.5% +66%
本地缓存命中率 0 80% --
穿透 DB QPS 12w(挂) < 1k -99%
P50 延迟 500ms 2ms -99%
P99 延迟 30s(超时) 15ms -99%
DB QPS 12w(挂) < 1k -99%
DB CPU 100% 15% -85%
业务影响:
- 爆款活动再上无 DB 压力
- 商品详情 SLA 99.99% 达成
- DB 节省两台机器(8w/月)
避坑清单
- 缓存空值兜底,布隆过滤器前置,双保险防穿透
- 热 key 用互斥锁回源 / 逻辑过期,防击穿
- TTL 必须随机化(±10%),防雪崩
- 多级缓存:Caffeine 本地 + Redis 分布式
- 限流降级是最后兜底,Sentinel / Resilience4j
- 预热放在启动 / 大促前,不要等流量来才预热
- 布隆过滤器误判率 0.01% 够用,内存可控
- 缓存监控全打通,命中率 + 穿透 + 锁竞争
- Redis 集群高可用(主从 + 哨兵 / Cluster)
- 缓存大 key 拆分(> 10KB 拆 hash 或 zset)
总结
缓存三大问题(穿透/击穿/雪崩)是分布式系统的基本功,但真到出事故才发现自己漏了哪些。这次系统性补完所有手段,爆款活动再上 30w QPS 也稳如老狗。最大的认知改变:缓存不是"加一层就行"的简单优化,是个完整的工程体系 — 布隆 + 多级 + 互斥锁 + 随机 TTL + 预热 + 监控,缺一不可。最容易被忽视的是"随机过期",批量预热设同一 TTL,几个小时后准时雪崩,这是教训。最后,本地缓存的价值被严重低估,Caffeine + Redis 二级缓存能把 Redis 压力降一个数量级,2024 年还在只用 Redis 单层缓存的项目都该升级。
—— 别看了 · 2026