高并发下,Redis是抗住流量的大山,但三大“缓存杀手”分分钟让数据库崩给你看。本文将深入原理,给出生产级解决方案。
一、开篇:缓存为何会成为噩梦?
后端开发都听过这句话:“缓存是万金油,用不好是砒霜。”
在高并发场景下,Redis 帮我们挡住了 90% 以上的数据库请求。但一旦遇到缓存雪崩、缓存击穿、缓存穿透,数据库瞬间会收到海量请求,CPU 飙升、连接池爆满、服务雪崩……
面试必考,生产必防。今天一篇讲透它们的产生原因、症状、解决方案,并给出可直接落地的代码示例。
二、三大“杀手”对号入座
| 问题 | 表象 | 核心原因 | 破坏力 |
|---|---|---|---|
| 缓存穿透 | 查不到的数据,每次穿透DB | 请求不存在的数据 | ⭐⭐⭐ |
| 缓存击穿 | 热点key过期,瞬间DB被打爆 | 单个热点key失效 + 高并发 | ⭐⭐⭐⭐ |
| 缓存雪崩 | 大面积key同时失效,DB瘫痪 | 大量key TTL相同 / Redis宕机 | ⭐⭐⭐⭐⭐ |
下面逐一解剖,并给出硬核代码。
三、缓存穿透 —— 空查询的噩梦
3.1 什么是穿透?
用户请求的数据在缓存和数据库中都不存在。比如请求user_id = -1或一个不存在的 ID。
由于缓存不会保存“不存在”的状态,每次请求都会越过 Redis 直接打到数据库。
攻击场景:恶意攻击者故意用大量不存在的 key 发起请求,DB 直接被打死。
3.2 解决方案
✅ 方案一:缓存空对象(最简单有效)
当 DB 查询结果为空时,仍然将一个空值(null)缓存起来,并设置较短的过期时间(如 60 秒)。
java
public Object getData(String key) { // 1. 从缓存取 Object val = redis.get(key); if (val != null) { return val.equals("NULL") ? null : val; } // 2. 查数据库 Object dbVal = db.query(key); if (dbVal == null) { // 缓存空值,过期时间 60 秒 redis.setex(key, 60, "NULL"); return null; } // 3. 正常缓存 redis.set(key, dbVal); return dbVal; }优点:实现简单,防住大部分穿透。
缺点:若攻击 key 无限变化(如每次不同 ID),会存大量空对象,浪费内存。
✅ 方案二:布隆过滤器(工业级防穿透)
布隆过滤器是一个二进制向量 + 多个哈希函数,能100% 判断一个元素“一定不存在”(但存在小概率误判为“存在”)。
流程:
启动时,将所有存在的 key(如所有合法 user_id)加入布隆过滤器。
请求来时,先过布隆过滤器:
如果判断不存在→ 直接返回(连 Redis 都不查)。
如果判断存在→ 再去查 Redis / DB。
使用 Redisson 内置布隆过滤器:
java
RBloomFilter<String> bloomFilter = redisson.getBloomFilter("userFilter"); // 初始化:预计容量 100000L,误判率 0.01 bloomFilter.tryInit(100000L, 0.01); // 添加所有合法 user_id for (String uid : allValidUserIds) { bloomFilter.add(uid); } // 请求拦截 public Object getUser(String uid) { if (!bloomFilter.contains(uid)) { return null; // 一定不存在,直接拦截 } // 走正常缓存查询 return getFromCacheOrDB(uid); }注意:布隆过滤器无法删除元素,适合数据相对固定的场景(如商品 ID、用户 ID 白名单)。如果要支持删除,可用布谷鸟过滤器。
四、缓存击穿 —— 热点 key 之殇
4.1 什么是击穿?
某个热点 key(如微博热搜、秒杀商品)在失效的一瞬间,海量并发请求同时打到数据库,DB 瞬间压力爆炸。
4.2 解决方案
✅ 方案一:互斥锁(分布式锁)
只允许一个线程去查数据库并重建缓存,其他线程等待。
使用 Redis setnx 实现锁:
java
public Object getDataWithMutex(String key) { Object val = redis.get(key); if (val != null) { return val; } // 尝试获取锁 String lockKey = key + ":lock"; boolean locked = redis.setnx(lockKey, "1", 10, TimeUnit.SECONDS); if (locked) { try { // 双重检查,防止在获取锁瞬间缓存被其他线程重建 val = redis.get(key); if (val != null) return val; // 查数据库 val = db.query(key); redis.set(key, val, 3600); return val; } finally { redis.del(lockKey); } } else { // 未获得锁,休眠后重试 Thread.sleep(50); return getDataWithMutex(key); // 递归重试 } }优点:保证 DB 只被查一次。
缺点:会阻塞其他线程,有一定延时。
✅ 方案二:逻辑过期(不设置物理 TTL)
不给 key 设置过期时间,而是在 value 中存储逻辑过期时间。后台线程异步刷新。
数据结构:
java
class CacheValue { Object data; Long expireTime; // 逻辑过期时间戳 }读取逻辑:
java
public Object getLogical(String key) { CacheValue cacheVal = redis.get(key); if (cacheVal == null) { return db.query(key); // 降级查库 } if (cacheVal.expireTime > System.currentTimeMillis()) { return cacheVal.data; // 未过期,直接返回 } // 逻辑过期,尝试加锁去更新缓存(非阻塞) String lockKey = key + ":refresh"; boolean locked = redis.setnx(lockKey, "1", 2, TimeUnit.SECONDS); if (locked) { // 异步更新缓存 threadPool.submit(() -> { Object newData = db.query(key); CacheValue newVal = new CacheValue(newData, System.currentTimeMillis() + 3600*1000); redis.set(key, newVal); redis.del(lockKey); }); } // 返回旧数据,不阻塞请求 return cacheVal.data; }优点:完全不阻塞读请求,性能极高。
缺点:会短暂返回脏数据(逻辑过期到更新完成之间),适合对一致性要求不高的场景(如商品详情、文章内容)。
五、缓存雪崩 —— 集群的末日
5.1 什么是雪崩?
场景一:大量 key 在同一时间点失效,比如将缓存过期时间都设为 1 小时。
场景二:Redis 实例宕机,所有请求直奔数据库。
5.2 解决方案
✅ 方案一:过期时间打散(最简单)
给 TTL 增加一个随机偏移量,避免集体失效。
java
int baseTTL = 3600; // 1小时 int randomOffset = new Random().nextInt(300); // 0~300秒 int realTTL = baseTTL + randomOffset; redis.setex(key, realTTL, value);
✅ 方案二:多级缓存(本地 + 分布式)
在 Redis 之前再加一层本地缓存(Caffeine / Guava Cache)。
java
// 本地缓存(Caffeine) LoadingCache<String, Object> localCache = Caffeine.newBuilder() .expireAfterWrite(30, TimeUnit.SECONDS) .maximumSize(10000) .build(key -> redis.get(key)); // 本地 miss 后查 Redis // 业务调用 public Object getData(String key) { try { return localCache.get(key); } catch (Exception e) { // 降级查 DB return db.query(key); } }好处:即使 Redis 挂了,本地缓存依然能扛住大部分读请求。
✅ 方案三:Redis 高可用 + 熔断限流
主从 + 哨兵:保证 Redis 不会单点宕机。
集群模式(Cluster):分片存储,部分节点挂不影响整体。
熔断降级:使用 Sentinel 或 Hystrix,当 Redis 出现超时或大量异常时,直接拒绝请求或返回默认值。
Sentinel 限流示例:
java
// 资源名 = 缓存key前缀 try (Entry entry = SphU.entry("getCache")) { return redis.get(key); } catch (BlockException e) { // 触发限流,直接查DB(需控制并发) return db.queryWithCircuitBreaker(key); }✅ 方案四:缓存预热 + 永不过期
在系统低峰期(如凌晨)将热点数据提前加载到 Redis,并采用“逻辑永不过期”(参考击穿方案二),后台定时刷新。
六、三大坑对比总结(面试无敌版)
| 维度 | 穿透 | 击穿 | 雪崩 |
|---|---|---|---|
| 数据存在性 | 数据不存在 | 数据存在,只是过期 | 大量数据同时过期 |
| 并发层级 | 大量不同key | 单个热点key | 大量不同key |
| 核心解决思路 | 拦截不存在请求 | 避免 DB 被同 key 并发查询 | 打散失效时间 + 提高可用性 |
| 首选方案 | 布隆过滤器 | 互斥锁 或 逻辑过期 | TTL 随机化 + 多级缓存 |
| 极端防御 | 前端参数校验 + 空值缓存 | 热点数据预热 + 后台异步刷新 | 熔断降级 + Redis Cluster |
七、生产环境终极建议
不要迷信单一方案:穿透防攻击用布隆过滤器 + 空值缓存双重保险;击穿用互斥锁保护 DB;雪崩用随机 TTL + 本地缓存。
监控报警:统计 Redis 命中率、DB 请求量突增、慢查询,及时人工介入。
压力测试:模拟 key 批量失效场景,观察系统表现。
冷热数据分离:热数据单独设长 TTL,冷数据走短 TTL 或直接查 DB。
八、写在最后
缓存三大坑是每一个后端工程师的“成人礼”。搞懂了它们,你不仅能写出高可用的缓存代码,更能在面试中让面试官眼前一亮。
硬核要点回顾:
穿透 → 布隆过滤器(存在性过滤)
击穿 → 互斥锁(强一致)或逻辑过期(高吞吐)
雪崩 → TTL 随机 + 多级缓存 + 熔断
希望这篇文章能帮你彻底掌握 Redis 缓存三兄弟。如果觉得有收获,欢迎点赞、收藏、转发,让更多同学少踩坑!