Redis 缓存用得好,数据库压力能降一个数量级;用得不好,缓存本身就是故障放大器。生产上最常见的三类事故——缓存穿透、缓存击穿、缓存雪崩,表现都是”数据库突然被打满、接口大面积超时”,但成因和解法完全不同。本文把这三个问题的边界讲清楚,并给出可以直接落地的防护代码:空值缓存、布隆过滤器、分布式锁重建、逻辑过期与 TTL 打散。
一、先分清三个概念:不要混着治
很多团队排查时把三者混为一谈,结果药不对症。它们的区别只在于三个维度:请求的数据在数据库里存不存在、失效的 key 是一个还是一批、压力落到哪一层。
| 问题 | 触发条件 | 数据是否存在 | 影响范围 | 核心解法 |
|---|---|---|---|---|
| 缓存穿透 | 大量查询不存在的 key | DB 中也不存在 | 每次都打到 DB | 空值缓存 + 布隆过滤器 |
| 缓存击穿 | 单个热点 key 过期瞬间 | DB 中存在 | 并发线程同时重建 | 互斥锁 / 逻辑过期 |
| 缓存雪崩 | 大批 key 同时过期或 Redis 宕机 | DB 中存在 | 整层缓存失效 | TTL 打散 + 高可用 + 降级 |
记住一句判断口诀:查不到的是穿透,热点掉的是击穿,一起掉的是雪崩。如果你还不熟悉 Redis 的基础数据结构与持久化配置,建议先看这篇 Redis 入门与常用数据结构实战 打好底子。
二、缓存穿透:拦住”根本不存在”的查询
典型场景是被恶意刷接口:攻击者用自增或随机 ID 请求 /api/user/999999999,这些 ID 在缓存和数据库都不存在,于是每一个请求都完整走一遍”查缓存未命中 → 查数据库无结果 → 不写缓存”的流程。数据库连接池很快被打满。
方案一:空值缓存(最快见效)
数据库查不到时,也往 Redis 写一个短 TTL 的空标记。实现简单,5 分钟就能上线,代价是占用少量内存,且要处理”数据后来真的被创建了”的一致性问题——所以空值 TTL 必须短(30 秒到 2 分钟),并在写入业务数据时主动删除空标记。
private static final String NULL_HOLDER = "__NULL__";
public User getUser(Long id) {
String key = "user:" + id;
String cached = redis.opsForValue().get(key);
// 1. 命中空标记,直接返回,不再打 DB
if (NULL_HOLDER.equals(cached)) {
return null;
}
if (cached != null) {
return JSON.parseObject(cached, User.class);
}
// 2. 未命中,查库
User user = userMapper.selectById(id);
if (user == null) {
// 关键:空结果也写缓存,TTL 要短
redis.opsForValue().set(key, NULL_HOLDER, 60, TimeUnit.SECONDS);
return null;
}
redis.opsForValue().set(key, JSON.toJSONString(user), 30, TimeUnit.MINUTES);
return user;
}
// 新增用户时必须清掉空标记,否则 60 秒内查不到新数据
public void createUser(User user) {
userMapper.insert(user);
redis.delete("user:" + user.getId());
}
方案二:布隆过滤器(应对大规模刷量)
当攻击 ID 空间极大(比如随机 64 位数字),空值缓存会把内存撑爆。此时用布隆过滤器在入口拦截:它以极小的内存判断”某个 key 一定不存在”或”可能存在”,存在假阳性但没有假阴性——这正好符合我们的需求,被误判为”可能存在”的少量请求放过去走一次 DB 是可接受的。
# 方式 A:Redis 官方 RedisBloom 模块(推荐,无需应用侧维护)
# 预期元素 100 万,误判率 0.1%,实际占用约 1.7MB
127.0.0.1:6379> BF.RESERVE user:bloom 0.001 1000000
OK
127.0.0.1:6379> BF.ADD user:bloom 10086
(integer) 1
127.0.0.1:6379> BF.EXISTS user:bloom 10086
(integer) 1
127.0.0.1:6379> BF.EXISTS user:bloom 999999999
(integer) 0 # 明确不存在,接口可直接返回 404
// 方式 B:Redisson 实现(不依赖 RedisBloom 模块)
@Bean
public RBloomFilter<Long> userBloomFilter(RedissonClient redisson) {
RBloomFilter<Long> filter = redisson.getBloomFilter("user:bloom");
// 预期插入量 100 万,误判率 0.1%
filter.tryInit(1_000_000L, 0.001);
return filter;
}
public User getUserSafely(Long id) {
// 布隆过滤器说不存在,就是真不存在,直接短路
if (!userBloomFilter.contains(id)) {
return null;
}
return getUser(id); // 再走缓存 + DB 流程
}
// 新增数据时同步写入过滤器,否则新用户会被误杀
public void createUser(User user) {
userMapper.insert(user);
userBloomFilter.add(user.getId());
redis.delete("user:" + user.getId());
}
⚠️ 布隆过滤器最大的坑是不支持删除。用户被物理删除后,过滤器仍认为它”可能存在”,请求会继续落到 DB——好在此时空值缓存会兜住。生产上常见做法是逻辑删除 + 定期全量重建过滤器(凌晨低峰跑一次),或改用 Cuckoo Filter(CF.DEL 支持删除)。
三、缓存击穿:热点 key 过期瞬间的并发重建
数据是存在的,问题出在时间点上。假设一个秒杀商品详情页 QPS 5000,缓存 TTL 到期的那一毫秒,5000 个线程同时发现未命中,同时去查数据库、同时写缓存。数据库瞬间承受 5000 次相同查询,慢查询堆积、连接耗尽。这种”缓存重建风暴”是压测能过、线上却挂的典型原因。
方案一:分布式锁串行重建
只让一个线程去重建缓存,其余线程短暂等待后重试读缓存。强一致性好,代价是等待线程会阻塞,极端情况下可能拖长响应时间。
public Product getProduct(Long id) {
String key = "product:" + id;
String cached = redis.opsForValue().get(key);
if (cached != null) {
return JSON.parseObject(cached, Product.class);
}
String lockKey = "lock:product:" + id;
// SET key val NX EX 10:只有第一个线程能拿到锁
Boolean locked = redis.opsForValue()
.setIfAbsent(lockKey, "1", 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.selectById(id);
if (p != null) {
redis.opsForValue().set(key, JSON.toJSONString(p),
randomTtl(30), TimeUnit.MINUTES);
}
return p;
} finally {
redis.delete(lockKey); // 务必在 finally 释放
}
}
// 没抢到锁:短暂退避后重读缓存,避免直接压 DB
try {
Thread.sleep(50);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
return getProduct(id);
}
注意锁的 TTL 必须大于重建耗时,否则锁提前释放会失去互斥意义;生产环境更推荐用 Redisson 的 RLock,它自带看门狗自动续期,能规避”业务没跑完锁先过期”的问题。
方案二:逻辑过期 + 异步重建(高可用优先)
核心思路是缓存永不物理过期,而在 value 里存一个逻辑过期时间。读到过期数据时,先返回旧值保证响应,同时开一个异步线程去重建。用户永远拿得到数据(可能短暂是旧的),彻底消除阻塞——牺牲一点一致性换可用性,适合商品详情、榜单这类容忍秒级延迟的场景。
// value 结构:{"data": {...}, "expireAt": 1735660800000}
public Product getProductLogical(Long id) {
String key = "product:" + id;
String cached = redis.opsForValue().get(key);
if (cached == null) {
return rebuildSync(id, key); // 冷启动,同步建一次
}
CacheWrapper wrapper = JSON.parseObject(cached, CacheWrapper.class);
Product data = wrapper.getData();
// 未逻辑过期,直接返回
if (wrapper.getExpireAt() > System.currentTimeMillis()) {
return data;
}
// 已逻辑过期:抢锁的线程异步重建,所有线程立刻返回旧值
String lockKey = "lock:rebuild:" + id;
if (Boolean.TRUE.equals(redis.opsForValue()
.setIfAbsent(lockKey, "1", 10, TimeUnit.SECONDS))) {
REBUILD_POOL.submit(() -> {
try {
rebuildSync(id, key);
} finally {
redis.delete(lockKey);
}
});
}
return data; // 返回旧数据,不阻塞
}
private Product rebuildSync(Long id, String key) {
Product p = productMapper.selectById(id);
if (p == null) return null;
CacheWrapper w = new CacheWrapper(p,
System.currentTimeMillis() + Duration.ofMinutes(30).toMillis());
// 物理 TTL 远大于逻辑 TTL(或不设),避免真过期
redis.opsForValue().set(key, JSON.toJSONString(w), 24, TimeUnit.HOURS);
return p;
}
四、缓存雪崩:整层失效的连锁反应
雪崩有两种成因,必须分别处理:一是大批 key 同一时刻集中过期(比如凌晨定时任务一次性预热了 10 万条数据,TTL 都写死 1 小时);二是 Redis 实例整体宕机,所有流量瞬间转向数据库。
TTL 随机化:一行代码消除集中过期
/** 基础 TTL 上叠加 0~20% 随机抖动,把过期时间点摊平 */
private long randomTtl(long baseMinutes) {
long jitter = (long) (baseMinutes * 0.2 * ThreadLocalRandom.current().nextDouble());
return baseMinutes + jitter;
}
// 批量预热时使用,10 万个 key 的过期时间分散在 30~36 分钟区间
public void warmUp(List<Product> products) {
products.forEach(p -> redis.opsForValue().set(
"product:" + p.getId(),
JSON.toJSONString(p),
randomTtl(30), TimeUnit.MINUTES));
}
Redis 宕机时的三道防线
第一道是高可用架构:单机换成主从 + Sentinel 或 Cluster,避免单点。第二道是本地缓存兜底:用 Caffeine 在 JVM 内做一层 L1 缓存(容量小、TTL 短),Redis 挂掉时热点数据仍能命中本地。第三道是熔断降级:用 Sentinel/Resilience4j 对数据库访问设并发上限,超出直接返回兜底默认值或友好提示,保住数据库不被打死——宁可部分请求降级,也不能让整个库崩掉。
// L1 本地缓存 + L2 Redis 的两级读取
private final Cache<String, Product> local = Caffeine.newBuilder()
.maximumSize(10_000)
.expireAfterWrite(Duration.ofSeconds(30)) // 短 TTL 控一致性
.build();
public Product get(Long id) {
String key = "product:" + id;
Product p = local.getIfPresent(key);
if (p != null) return p;
try {
p = getProductLogical(id); // L2:Redis
} catch (RedisConnectionFailureException e) {
// Redis 不可用:降级到限流后的 DB 直查
p = degradeQuery(id);
}
if (p != null) local.put(key, p);
return p;
}
入口层同样值得加一道闸——用 Nginx 做限流能在流量到达应用前削峰,具体配置可参考 Nginx 限流防刷实战:漏桶算法与 WAF 基础配置。
五、方案选型对照:按场景挑,不要全都上
| 方案 | 解决问题 | 实现成本 | 一致性 | 适用场景 |
|---|---|---|---|---|
| 空值缓存 | 穿透 | 极低 | 弱(短 TTL 缓解) | ID 空间有限、快速止血 |
| 布隆过滤器 | 穿透 | 中(需维护同步) | 有假阳性 | 海量随机 ID 刷量 |
| 分布式锁重建 | 击穿 | 中 | 强 | 金额、库存等强一致数据 |
| 逻辑过期 | 击穿 | 较高 | 弱(秒级延迟) | 详情页、榜单等高 QPS 读 |
| TTL 随机化 | 雪崩 | 极低 | 无影响 | 所有批量预热场景,必做 |
| 多级缓存 + 熔断 | 雪崩 | 高 | 弱 | 核心链路、要求不可用时仍能兜底 |
务实的落地顺序是:TTL 随机化和空值缓存先无条件做(成本几乎为零);有明确热点 key 的接口再上逻辑过期或分布式锁;确认存在恶意刷量后才引入布隆过滤器;多级缓存和熔断只在核心交易链路投入。
六、验证与监控:别等事故发生才知道
防护上线后必须能观测。最关键的三个指标是缓存命中率、数据库 QPS 突刺、慢查询数量。命中率可以从 Redis 的 INFO stats 里算:
# 查看命中/未命中累计值,命中率 = hits / (hits + misses)
redis-cli INFO stats | grep keyspace
# 实时观察是否有大批 key 同时过期(expired_keys 陡增即雪崩前兆)
redis-cli --stat
# 找出内存占用最大的 key,排查空值缓存是否失控
redis-cli --bigkeys
# 压测验证击穿防护:并发 500、持续 30 秒打同一个热点 key
wrk -t8 -c500 -d30s http://127.0.0.1:8080/api/product/1
压测时同步盯数据库侧:如果热点 key 过期瞬间 DB QPS 出现尖峰,说明击穿防护没生效。指标采集与告警面板的搭建可参考 Prometheus + Grafana 监控面板实战;数据库侧的慢查询定位与执行计划分析,见 MySQL 深度调优:核心参数与执行计划解读 与 MySQL 索引底层原理与优化实践。
七、落地检查清单
上线前逐条对一遍,能挡掉绝大多数缓存类事故:
- 所有
set缓存的地方,TTL 是否都带了随机抖动?有没有写死的固定值? - 查库返回 null 时,是否写入了短 TTL 空标记?新增数据时是否清理了该标记?
- 热点 key(详情页、首页配置、榜单)是否做了击穿防护?锁的 TTL 是否大于重建耗时?
- 锁释放是否放在
finally?异常路径会不会导致锁永久占用? - Redis 连接异常时代码走什么分支?是抛 500 还是有降级兜底?
- 缓存命中率、DB QPS、慢查询是否已接入监控并配置告警阈值?
缓存防护的本质不是把所有方案都堆上去,而是想清楚每个 key 的数据特征和一致性要求,再选最小够用的方案。穿透治入口、击穿治并发、雪崩治时间分布——分清病因,对症下药,Redis 才是真正的性能杠杆而不是故障源。




