2023 年双十一前压测,产品库接口在 5000 QPS 下崩了。Redis 命中率从 99% 掉到 30%,DB CPU 100%,后端连接池打满。事后复盘发现三个问题同时出现:缓存穿透、击穿、雪崩。本文把这三个经典缓存问题彻底讲清楚,带可直接抄走的工程实现。
三种问题的本质区别
缓存穿透 (Cache Penetration)
现象:大量请求查询根本不存在的 key,缓存里没有,每次都打 DB
案例:恶意脚本扫描 /api/user/-1, /api/user/-2 ...
DB 压力:持续高位
特点:被刷的 key 是无效的
缓存击穿 (Cache Breakdown / Hotspot Invalid)
现象:某个超热 key 过期的瞬间,所有请求同时打 DB
案例:首页 banner 缓存 10 分钟过期,过期瞬间 5000 QPS 全打 DB
DB 压力:瞬间峰值,然后恢复
特点:被打的 key 是真实热点
缓存雪崩 (Cache Avalanche)
现象:大量 key 同时过期,或 Redis 整个挂了
案例:启动时批量预热缓存,设了一样的 30 分钟过期,30 分钟后一起死
DB 压力:全量流量打到 DB,系统级崩溃
特点:范围性 / 全局性失效
解决方案 1:缓存穿透 — 布隆过滤器 + 空值缓存
// 方案 A:布隆过滤器,启动时预热所有有效 ID
@Service
public class ProductService {
private BloomFilter<Long> productIdFilter;
@PostConstruct
public void initBloomFilter() {
// 预期 1000 万,误判率 0.001
productIdFilter = BloomFilter.create(
Funnels.longFunnel(),
10_000_000L,
0.001
);
// 把所有产品 ID 灌进去
List<Long> allIds = productMapper.selectAllIds();
allIds.forEach(productIdFilter::put);
log.info("bloom filter primed: size={} fpp={}", allIds.size(), 0.001);
}
public Product getById(Long id) {
// 第一道防线:布隆过滤器
if (!productIdFilter.mightContain(id)) {
return null; // 一定不存在,不用查 Redis 也不用查 DB
}
// 后续走正常缓存查询
return loadWithCache(id);
}
}
布隆过滤器优点:空间小(1000 万 ID 只占 ~17MB)、查询 O(1)。缺点:有误判率(本来不存在也可能"mightContain"返回 true,但反过来 mightContain 返回 false 一定不存在)。误判进入下游,只是失去了第一道防线,不会出业务错误。
// 方案 B:空值缓存(简单粗暴)
public Product getById(Long id) {
String key = "product:" + id;
String cached = redis.opsForValue().get(key);
if (cached != null) {
if ("__NULL__".equals(cached)) return null;
return JSON.parseObject(cached, Product.class);
}
Product p = productMapper.findById(id);
if (p == null) {
// 不存在也缓存,但 TTL 短一些(5 分钟)
redis.opsForValue().set(key, "__NULL__", 5, TimeUnit.MINUTES);
return null;
}
redis.opsForValue().set(key, JSON.toJSONString(p), 30, TimeUnit.MINUTES);
return p;
}
实际生产建议两层都用:布隆过滤器拦掉 99% 的恶意穿透,空值缓存兜底剩下的。
解决方案 2:缓存击穿 — 互斥锁 / SingleFlight
// 方案 A:Redis 分布式锁(适合分布式场景)
public Product getByIdWithLock(Long id) {
String key = "product:" + id;
String cached = redis.opsForValue().get(key);
if (cached != null) return JSON.parseObject(cached, Product.class);
// 缓存 miss,尝试拿锁
String lockKey = "lock:product:" + id;
String lockValue = UUID.randomUUID().toString();
Boolean locked = redis.opsForValue().setIfAbsent(lockKey, lockValue, 10, TimeUnit.SECONDS);
if (Boolean.TRUE.equals(locked)) {
try {
// 双重检查
cached = redis.opsForValue().get(key);
if (cached != null) return JSON.parseObject(cached, Product.class);
Product p = productMapper.findById(id);
if (p != null) {
redis.opsForValue().set(key, JSON.toJSONString(p), 30, TimeUnit.MINUTES);
}
return p;
} finally {
// Lua 安全释放
String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
redis.execute(new DefaultRedisScript<>(script, Long.class), List.of(lockKey), lockValue);
}
} else {
// 没拿到锁,等 50ms 再查缓存(应该已经被刚拿锁的线程填好了)
try {
Thread.sleep(50);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
cached = redis.opsForValue().get(key);
if (cached != null) return JSON.parseObject(cached, Product.class);
// 极端情况:还没填好,直接降级查 DB
return productMapper.findById(id);
}
}
// 方案 B:本地 SingleFlight(JVM 内去重,适合单机或读多写少)
public class SingleFlight<K, V> {
private final ConcurrentHashMap<K, CompletableFuture<V>> inFlight = new ConcurrentHashMap<>();
public V get(K key, Supplier<V> loader) {
CompletableFuture<V> fut = inFlight.get(key);
if (fut == null) {
CompletableFuture<V> newFut = new CompletableFuture<>();
fut = inFlight.putIfAbsent(key, newFut);
if (fut == null) {
fut = newFut;
try {
fut.complete(loader.get());
} catch (Throwable e) {
fut.completeExceptionally(e);
} finally {
inFlight.remove(key);
}
}
}
try {
return fut.get(3, TimeUnit.SECONDS);
} catch (Exception e) {
throw new RuntimeException(e);
}
}
}
// 用法:JVM 内 100 个线程同时 miss,只有 1 个真的查 DB
public Product getByIdSingleFlight(Long id) {
String key = "product:" + id;
String cached = redis.opsForValue().get(key);
if (cached != null) return JSON.parseObject(cached, Product.class);
return singleFlight.get(id, () -> {
Product p = productMapper.findById(id);
if (p != null) redis.opsForValue().set(key, JSON.toJSONString(p), 30, TimeUnit.MINUTES);
return p;
});
}
实际生产推荐本地 SingleFlight + 分布式锁兜底。本地能拦掉 99% 单机重复,分布式锁防止多个机器同时打 DB。
解决方案 3:缓存雪崩
策略 A:过期时间加随机抖动
// 错:全部 30 分钟过期 → 30 分钟后一起死
redis.opsForValue().set(key, value, 30, TimeUnit.MINUTES);
// 对:基础 30 分钟 + 0~5 分钟随机抖动
public void setWithJitter(String key, String value, int baseSec, int jitterSec) {
int ttl = baseSec + ThreadLocalRandom.current().nextInt(jitterSec);
redis.opsForValue().set(key, value, ttl, TimeUnit.SECONDS);
}
// 应用
setWithJitter(key, value, 1800, 300); // 30 - 35 分钟随机
策略 B:逻辑过期(Logical Expiration)
// 在 value 里存"逻辑过期时间",Redis key 不设 TTL 或设很长
public class LogicalCache<T> {
@JsonProperty("data") private T data;
@JsonProperty("expireAt") private long expireAt; // 业务上认为的过期时间
}
public Product getProductLogical(Long id) {
String key = "product:logical:" + id;
String cached = redis.opsForValue().get(key);
if (cached == null) {
return loadAndCacheLogical(id);
}
LogicalCache<Product> lc = JSON.parseObject(cached, new TypeReference<LogicalCache<Product>>(){});
if (lc.expireAt > System.currentTimeMillis()) {
return lc.data; // 没逻辑过期,直接用
}
// 逻辑过期,异步刷新,返回旧数据(避免击穿)
asyncExecutor.submit(() -> refreshLogical(id));
return lc.data;
}
private void refreshLogical(Long id) {
String lockKey = "lock:refresh:" + id;
if (!Boolean.TRUE.equals(redis.opsForValue().setIfAbsent(lockKey, "1", 10, TimeUnit.SECONDS))) {
return; // 别的线程在刷
}
try {
Product fresh = productMapper.findById(id);
LogicalCache<Product> lc = new LogicalCache<>(fresh, System.currentTimeMillis() + 1800_000);
redis.opsForValue().set("product:logical:" + id, JSON.toJSONString(lc));
} finally {
redis.delete(lockKey);
}
}
逻辑过期的好处:缓存永远命中,大不了是稍微旧一点的数据。代价:实现复杂,数据一致性 SLA 变宽松。
策略 C:多级缓存
@Service
public class MultiLayerCache {
private final Cache<Long, Product> l1 = Caffeine.newBuilder()
.maximumSize(10_000)
.expireAfterWrite(Duration.ofMinutes(5))
.build();
@Autowired private RedisTemplate<String, String> redis;
@Autowired private ProductMapper mapper;
public Product get(Long id) {
// L1: 本地
Product p = l1.getIfPresent(id);
if (p != null) return p;
// L2: Redis
String cached = redis.opsForValue().get("product:" + id);
if (cached != null) {
p = JSON.parseObject(cached, Product.class);
l1.put(id, p);
return p;
}
// L3: DB(用 SingleFlight 防击穿)
return singleFlight.get(id, () -> {
Product fromDB = mapper.findById(id);
if (fromDB != null) {
redis.opsForValue().set("product:" + id, JSON.toJSONString(fromDB), 30, TimeUnit.MINUTES);
l1.put(id, fromDB);
}
return fromDB;
});
}
}
策略 D:熔断 / 降级
// Redis 整个挂了的极端情况:直接降级
@HystrixCommand(fallbackMethod = "getFromDBDirectly",
commandProperties = {
@HystrixProperty(name = "execution.isolation.thread.timeoutInMilliseconds", value = "1000"),
@HystrixProperty(name = "circuitBreaker.requestVolumeThreshold", value = "20"),
@HystrixProperty(name = "circuitBreaker.errorThresholdPercentage", value = "50")
})
public Product getWithCircuitBreaker(Long id) {
return cache.get(id); // 走正常缓存逻辑
}
public Product getFromDBDirectly(Long id) {
// 降级:跳过缓存,但限流到 DB(避免雪崩 DB)
if (!rateLimiter.tryAcquire()) {
throw new ServiceUnavailableException("rate_limited");
}
return mapper.findById(id);
}
事故复盘中我们做的具体改造
改造前 改造后
========================================
直接 Redis get + miss 查 DB SingleFlight + 分布式锁
所有 key 30min TTL 30min ± 5min 随机
无空值缓存 负值缓存 5min
无布隆过滤器 启动预热全部商品 ID
单层 Redis Caffeine + Redis 两层
无 Redis 降级 Hystrix + rate limit 兜底
监控指标:命中率 + miss 率 + miss 耗时 + 分布式锁等待时间
压测对比
场景:模拟 5000 QPS,缓存命中率 95%,命中率瞬间掉到 30%(雪崩模拟)
改造前:
- DB QPS 峰值: 7000(全部 miss 打到 DB)
- DB CPU: 100%
- 接口 p99: 2000ms
- 错误率: 12%
改造后:
- DB QPS 峰值: 800(SingleFlight 合并)
- DB CPU: 45%
- 接口 p99: 180ms
- 错误率: 0.1%
监控指标
- alert: CacheHitRateLow
expr: rate(cache_hit_total[5m]) / rate(cache_request_total[5m]) < 0.85
for: 2m
annotations:
summary: '缓存命中率 < 85%'
- alert: CacheBreakdownRisk
expr: rate(cache_loader_concurrent_calls[1m]) > 10
annotations:
summary: '检测到缓存击穿,SingleFlight 并发等待 > 10'
- alert: BloomFilterFppHigh
expr: bloom_filter_false_positive_rate > 0.01
annotations:
summary: '布隆过滤器误判率 > 1%,可能需要扩容'
核对清单
- 每个缓存写入是否带 jitter
- 每个 loader 是否套了 SingleFlight 或分布式锁
- 是否预热布隆过滤器
- 是否缓存 null / 空对象
- 是否有 L1 本地缓存
- 是否有 Redis 全挂的降级方案
- 缓存命中率监控有没有报警
- 热点 key 是否单独探测 + 提前续期
缓存三件套是面试常考题,但实际生产上能把这 8 条全做到的项目不多。每条都不难,叠加起来才是工程实力。事故让我们一次性把这套基础设施做扎实,后面再压测各种"刻意制造的雪崩"都扛住了,值得。
—— 别看了 · 2026