Redis 缓存设计避坑指南:穿透、击穿、雪崩与一致性问题
高并发系统中,Redis 是挡在数据库前的那道盾。但这道盾,稍有不慎就会被自己人捅穿。
本文把 Redis 缓存最致命的四大问题------穿透、击穿、雪崩、一致性------一次性讲透,给出可直接落地的方案。
一、缓存穿透:查一个不存在的东西,却把数据库查崩了
问题本质
用户请求一个数据库中根本不存在的 Key(如 userId = -1),缓存没命中,直接打到数据库。攻击者用大量不重复的随机 ID 发起请求,每一条都穿透到数据库,库被压垮。
三道防线
| 防线 | 原理 | 适用场景 | 局限 |
|---|---|---|---|
| 接口层参数校验 | 请求入口拦截非法参数(ID 必须为正整数等) | 所有场景 | 只能挡格式错误,挡不住"合法但不存在"的 ID |
| 缓存空值 | DB 查不到也缓存一个 null,TTL 设 5 分钟 |
重复查询少的场景 | 大量不重复攻击会塞满缓存,挤掉有效数据 |
| 布隆过滤器 | 预加载所有合法 Key 到位图,O(1) 判断是否存在 | 高并发、大数据量 | 有误判率(约 0.1%~1%),不支持删除 |
最佳实践:布隆过滤器 + 空值缓存组合使用。 布隆过滤器挡掉绝大多数非法请求,空值缓存兜底漏网之鱼。
kotlin
java
// 布隆过滤器预加载所有合法ID
BloomFilter<Long> bloomFilter = BloomFilter.create(
Funnels.longFunnel(), 10000000L, 0.001
);
allIds.forEach(bloomFilter::put);
// 查询时先过滤
if (!bloomFilter.mightContain(userId)) {
return null; // 一定不存在,直接拦截
}
二、缓存击穿:一个热点 Key 过期的瞬间,万箭齐发
问题本质
某个热点 Key(如首页商品)刚好过期,此时大量并发请求同时发现缓存失效,全部涌向数据库,造成瞬时压力激增。
与穿透的区别:穿透查的是"不存在的数据",击穿查的是"存在但缓存刚好过期的数据"。
三种解法
| 方案 | 原理 | 优缺点 |
|---|---|---|
| 互斥锁(分布式锁) | 缓存未命中时,只有拿到锁的线程去查 DB,其他线程等待 | 简单有效,但高并发下大量线程阻塞,吞吐量下降 |
| 热点数据永不过期 | 物理不设 TTL,逻辑过期时间存在 value 里,后台异步刷新 | 性能最优,但返回的可能是短暂旧数据 |
| 定时主动刷新 | 定时任务在过期前主动刷新缓存 | 实现复杂,适合 Key 固定的场景 |
推荐方案:热点数据永不过期(逻辑过期)
csharp
java
public class CacheItem {
private Object value;
private long expireTime; // 逻辑过期时间戳
public boolean isExpired() {
return System.currentTimeMillis() > expireTime;
}
}
// 读取时判断
CacheItem item = cache.get(key);
if (item.isExpired()) {
// 返回旧数据,同时异步刷新
CompletableFuture.runAsync(() -> refreshCache(key));
}
return item.getValue();
这种方式从根源上消灭了"过期瞬间"这个窗口期,是击穿问题的终极解法。
三、缓存雪崩:所有 Key 集体罢工,数据库裸奔
问题本质
大量缓存同时过期(如整点失效),或 Redis 集群宕机,所有请求瞬间穿透到数据库,数据库直接崩溃。
三板斧
1. TTL 加随机偏移(最常用、最有效)
ini
java
// 真实 TTL = 基础 TTL + 随机值(±5分钟)
long ttl = 3600 + (long)(Math.random() * 600 - 300);
redis.setex(key, ttl, value);
让失效时间均匀分布,避免集体失效。
2. 多级缓存
Caffeine 本地缓存(10分钟)→ Redis 分布式缓存(1小时)→ 数据库
本地缓存挡掉绝大部分请求,Redis 挂了还有一层缓冲。
3. 熔断降级
数据库压力过大时,返回默认值或兜底数据,而非硬扛。
typescript
java
@HystrixCommand(fallbackMethod = "getDefaultData")
public Object getData(String key) {
return db.query(key);
}
四、缓存一致性:更新了数据库,缓存还是旧的
这是四个问题中最隐蔽、最难搞的一个。
核心矛盾
数据库和缓存是两套独立存储,更新操作无法原子化,必然存在不一致窗口。
五种方案对比
| 方案 | 一致性级别 | 复杂度 | 性能影响 | 适用场景 |
|---|---|---|---|---|
| 先更新 DB,再删缓存(Cache-Aside) | 弱一致 | 低 | 低 | 90% 场景够用 |
| 延时双删 | 弱一致 | 中 | 中 | 读多写少,可容忍短暂不一致 |
| Write Through 代理层 | 强一致 | 高 | 中 | 核心业务,需简化业务逻辑 |
| Binlog 异步删除(Canal) | 最终一致 | 中 | 低 | 高并发读,弱一致可接受 |
| 分布式锁 + 版本号 | 强一致 | 高 | 高 | 金融交易,零容忍脏数据 |
最推荐的组合
常规场景:Cache-Aside + TTL 兜底
typescript
java
// 更新流程
public void updateData(String key, Object value) {
db.update(key, value); // 1. 先更新数据库
redis.del(key); // 2. 再删缓存
}
// 读流程
public Object getData(String key) {
Object data = redis.get(key);
if (data != null) return data;
data = db.query(key);
redis.setex(key, data, 3600); // 写入缓存,带 TTL 兜底
return data;
}
高一致场景:Cache-Aside + 延时双删 + Binlog 异步删除
scss
java
public void updateWithDoubleDelete(String key, Object value) {
redis.del(key); // 第一次删
db.update(key, value); // 更新数据库
asyncExecutor.submit(() -> {
Thread.sleep(500); // 延迟 = 查DB+写缓存耗时的1.5-2倍
redis.del(key); // 第二次删,清除窗口期内的脏数据
});
}
五、一张表终结所有决策
| 问题 | 核心原因 | 首选方案 | 备选方案 |
|---|---|---|---|
| 穿透 | 查不存在的数据 | 布隆过滤器 + 空值缓存 | 参数校验 |
| 击穿 | 热点 Key 过期瞬间 | 热点永不过期(逻辑过期) | 互斥锁 |
| 雪崩 | 大量 Key 同时失效 | TTL 随机偏移 + 多级缓存 | 熔断降级 |
| 一致性 | DB 与缓存更新不同步 | Cache-Aside + TTL | 延时双删 / Binlog |
六、最后说句实话
Redis 缓存没有银弹。穿透靠过滤,击穿靠永不过期,雪崩靠随机 TTL,一致性靠删缓存 + 兜底。
把这四个问题的方案组合起来,你的缓存层就能扛住 99% 的生产故障。 剩下那 1%,靠监控和预案。