缓存穿透:当请求绕过缓存直达数据库
缓存穿透是指请求的数据在缓存和数据库中都不存在,每次请求都直接穿透缓存层打到数据库。这种情况最常见的场景是恶意攻击或扫描器遍历不存在的ID。由于缓存层对不存在的数据不会缓存(除非使用空值缓存策略),这些请求会持续命中数据库,在高并发下瞬间打垮后端服务。
举个例子,一个电商系统的商品详情接口使用Redis缓存商品信息。攻击者批量请求不存在的商品ID(如负数ID或随机UUID),每次请求都查缓存未命中→查数据库未找到→返回空结果。如果QPS达到10000,数据库直接面临10000/s的无效查询压力。
解决方案一:缓存空值
对缓存穿透最直接的应对方案是:即使数据库中查不到数据,也把一个空值或特殊标记写入缓存,并设置较短的过期时间(通常30-60秒)。这样同一请求在短期内不会重复穿透到数据库。
// Java示例:缓存空值解决缓存穿透
public Product getProduct(Long id) {
String cacheKey = "product:" + id;
String cached = redis.get(cacheKey);
if (cached != null) {
// 如果是空值标记,直接返回null
if ("NULL_VALUE".equals(cached)) {
return null;
}
return JSON.parseObject(cached, Product.class);
}
Product product = productMapper.selectById(id);
if (product == null) {
// 缓存空值,过期时间30秒
redis.setex(cacheKey, 30, "NULL_VALUE");
return null;
}
redis.setex(cacheKey, 3600, JSON.toJSONString(product));
return product;
}
优点在于实现简单、对已有代码改动小。缺点是无法完全防护恶意攻击——攻击者每次用不同的key就能绕过去。同时大量空值缓存也会占用内存,不过可以通过限制空值缓存的总量来缓解。
解决方案二:布隆过滤器(Bloom Filter)
布隆过滤器是一种空间效率极高的概率性数据结构,可以非常确定地判断一个元素”一定不在集合中”,或”可能在集合中”。在缓存层前置布隆过滤器,可以拦截掉绝大多数的无效key请求。
布隆过滤器的原理是:使用多个哈希函数将一个元素映射到bit数组上的多个位。查询时,如果任意一个位为0,则该元素一定不存在;如果所有位都为1,则该元素可能存在(存在一定误判率)。
// Redis 4.0+ 使用布隆过滤器模块
// 1. 安装RedisBloom模块
// docker run -p 6379:6379 redislabs/rebloom
// 2. 初始化布隆过滤器
// 预计存储100万元素,误判率0.01
> BF.RESERVE product_bloom 0.01 1000000
OK
// 3. 添加已有商品ID
> BF.ADD product_bloom 1001
(integer) 1
> BF.ADD product_bloom 1002
(integer) 1
// 4. 查询商品是否存在
> BF.EXISTS product_bloom 9999
(integer) 0 // 一定不存在
> BF.EXISTS product_bloom 1001
(integer) 1 // 可能存在
| 方案 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| 缓存空值 | 实现简单,无需额外组件 | 大量无效key浪费内存 | 穿透量不大,key空间有限 |
| 布隆过滤器 | 内存占用极小,拦截率高 | 需维护数据同步,有误判率 | 高并发、key空间固定场景 |
| 参数校验+限流 | 从源头拦截恶意请求 | 无法拦截合法但不存在的数据 | 必须与其他方案配合 |
缓存击穿:热点Key的雪崩前兆
缓存击穿与穿透不同:击穿针对的是某个热点Key正好在过期的那一刻,大量并发请求同时涌入,发现缓存过期了,于是一齐去数据库查询。这就像一扇门刚好在人群涌来时关上,所有人同时撞上去。
典型场景:一个日活千万的首页推荐列表,缓存过期时间为1小时。在过期的那一秒,同时有5000个请求发现缓存为空,全部打到数据库,数据库连接池瞬间耗尽。
解决方案一:互斥锁(Mutex Lock)
只让一个线程去查数据库重建缓存,其他线程等待。这是最经典也最有效的方案:
// Go语言实现:使用互斥锁防止缓存击穿
import (
"sync"
"time"
"github.com/redis/go-redis/v9"
)
var (
rdb *redis.Client
locker sync.Mutex // 进程内锁,适用于单机
)
func GetHotData(ctx context.Context, key string) (string, error) {
val, err := rdb.Get(ctx, key).Result()
if err == redis.Nil { // 缓存不存在
locker.Lock()
defer locker.Unlock()
// 双重检查:另一个线程可能已经重建了缓存
val, err = rdb.Get(ctx, key).Result()
if err != redis.Nil {
return val, nil
}
// 查询数据库
data := queryDatabase()
rdb.Set(ctx, key, data, 3600*time.Second)
return data, nil
}
return val, err
}
在分布式环境下,需要使用Redis分布式锁代替进程内锁:
// Redis分布式锁实现缓存击穿防护
// 使用SET NX + Lua脚本保证原子性
String lockKey = "lock:hot_key";
String lockValue = UUID.randomUUID().toString();
// 尝试获取锁,过期时间5秒
Boolean lock = redis.setIfAbsent(lockKey, lockValue, 5, TimeUnit.SECONDS);
if (lock) {
try {
// 拿到锁的线程查数据库并重建缓存
String data = queryDatabase();
redis.setex(cacheKey, 3600, data);
return data;
} finally {
// Lua脚本保证原子释放锁
String luaScript = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
redis.eval(luaScript, Collections.singletonList(lockKey), Collections.singletonList(lockValue));
}
} else {
// 没拿到锁的线程等待后重试
Thread.sleep(50);
return getProduct(id); // 递归重试
}
解决方案二:逻辑过期 + 异步刷新
不让缓存真正过期,而是利用”逻辑过期”概念。缓存永不过期(物理上),但数据中带一个逻辑过期时间。后台异步线程检测到逻辑过期后,主动去更新缓存:
// 逻辑过期数据结构
public class CacheItem<T> {
private T data; // 实际数据
private long expireTime; // 逻辑过期时间戳
public boolean isExpired() {
return System.currentTimeMillis() > expireTime;
}
}
// 使用时:即使逻辑过期也返回旧数据,后台异步刷新
public Product getProductWithAsyncRefresh(Long id) {
String cacheKey = "product:" + id;
CacheItem<Product> item = redis.getObject(cacheKey);
if (item != null && !item.isExpired()) {
return item.getData(); // 缓存有效,直接返回
}
if (item != null && item.isExpired()) {
// 尝试获取重建锁
String lockKey = "lock:rebuild:" + id;
if (tryLock(lockKey, 3, TimeUnit.SECONDS)) {
// 异步线程池执行数据更新
threadPool.execute(() -> {
Product newData = queryDatabase();
redis.setex(cacheKey, 24*3600, new CacheItem<>(newData, System.currentTimeMillis() + 3600*1000));
unlock(lockKey);
});
}
// 不管是否拿到锁,都返回旧数据
return item.getData();
}
// 缓存完全不存在(可能是第一次加载)
return loadToCache(id);
}
缓存雪崩:大规模缓存集体失效
缓存雪崩是最严重的缓存故障。它指的是大量缓存Key在同一时间过期,或者缓存节点宕机,导致海量请求直接涌入数据库。与击穿不同,雪崩是”大面积”的,而不是针对某一个Key。
真实案例:某电商平台将商品缓存统一设置为1小时过期,所有缓存在00:00同时失效。零点过后数据库QPS从500飙升到50000,直接导致数据库连接池耗尽、主从延迟超过10秒,整个商品服务瘫痪30分钟。
解决方案一:过期时间随机化
这是最简单有效的预防手段。不给所有Key设置相同的过期时间,而是在基础时间上增加一个随机偏移量:
// Java:过期时间加随机偏移
// 基础1小时 + 随机0-10分钟
int baseTTL = 3600;
int randomOffset = new Random().nextInt(600);
redis.setex(key, baseTTL + randomOffset, value);
// Python:带随机偏移的缓存设置
import random
def set_cache(redis_client, key, value, base_ttl=3600):
jitter = random.randint(0, 600) # 0-10分钟随机
redis_client.setex(key, base_ttl + jitter, value)
解决方案二:多级缓存架构
在Redis前面再加一层本地缓存(如Caffeine、Guava Cache),形成二级或三级缓存结构。即使Redis层的缓存全部失效,本地缓存仍然可以挡住一部分流量:
// Spring Boot + Caffeine 多级缓存示例
@Configuration
public class MultiLevelCacheConfig {
@Bean
public Cache<String, Object> localCache() {
return Caffeine.newBuilder()
.initialCapacity(1000)
.maximumSize(10000)
.expireAfterWrite(5, TimeUnit.MINUTES) // 本地缓存5分钟
.recordStats()
.build();
}
}
@Service
public class ProductService {
@Autowired
private Cache<String, Object> localCache;
@Autowired
private StringRedisTemplate redisTemplate;
public Product getProduct(Long id) {
String cacheKey = "product:" + id;
// 1. 查本地缓存
Product local = (Product) localCache.get(cacheKey, k -> null);
if (local != null) return local;
// 2. 查Redis
String redisData = redisTemplate.opsForValue().get(cacheKey);
if (redisData != null) {
Product p = JSON.parseObject(redisData, Product.class);
localCache.put(cacheKey, p); // 回填本地缓存
return p;
}
// 3. 查数据库(降级保护)
Product dbData = queryFromDatabaseWithFallback(id);
if (dbData != null) {
redisTemplate.opsForValue().set(cacheKey, JSON.toJSONString(dbData), 3600, TimeUnit.SECONDS);
localCache.put(cacheKey, dbData);
}
return dbData;
}
// 数据库查询带降级保护
private Product queryFromDatabaseWithFallback(Long id) {
// 使用信号量控制并发数据库查询
if (!dbSemaphore.tryAcquire()) {
// 超过并发限制,返回降级数据或抛出友好异常
return Product.getDefaultProduct();
}
try {
return productMapper.selectById(id);
} finally {
dbSemaphore.release();
}
}
}
解决方案三:Redis集群高可用
针对缓存节点宕机导致的雪崩,必须从架构层面保证Redis的高可用:
- 主从复制 + 哨兵模式(Sentinel):自动故障转移,秒级切换。适合不超过10个节点的中小规模部署。
- Redis Cluster:数据分片存储,部分节点宕机不影响整体服务。适合大规模分布式部署。
- 冗余部署:部署两套独立的Redis集群,应用层做双读双写,一套挂了自动切换到另一套。
- 本地缓存兜底:即使Redis完全不可用,本地缓存依然可以提供服务,虽然数据可能稍有滞后但系统不会崩溃。
三大场景的综合防御架构
在实际生产环境中,这三种场景往往同时存在,需要构建一个综合的防御体系。以下是一个经过线上验证的多层防御架构:
# 综合缓存防御架构(以秒杀系统为例)
请求入口
│
├─ 第1层:参数校验 & 限流(Nginx + Sentinel)
│ ├── 校验参数合法性(ID格式、数值范围)
│ ├── 对同一用户/设备限流
│ └── 对热点接口整体限流
│
├─ 第2层:布隆过滤器(拦截穿透)
│ ├── 预热时加载所有有效ID
│ └── BF.EXISTS == 0 ⇒ 直接返回404
│
├─ 第3层:本地缓存 Caffeine(拦截击穿 & 防雪崩)
│ ├── 过期时间:60秒,最大条目:10000
│ └── 即使Redis挂了,仍有缓存
│
├─ 第4层:Redis Cluster(主缓存层)
│ ├── Key过期时间:基础值 + 随机偏移
│ ├── 热点Key:逻辑过期 + 异步刷新
│ └── 不存在的数据:缓存空值 30秒
│
└─ 第5层:数据库(最终防线)
├── 连接池限制(HikariCP max=20)
├── 读写分离(主库写、从库读)
└── 慢查询监控 + 熔断降级
性能对比与实战建议
| 防护手段 | 应对场景 | 性能开销 | 实现复杂度 | 推荐指数 |
|---|---|---|---|---|
| 缓存空值 | 缓存穿透 | 极低 | ⭐ | ★★★★★ |
| 布隆过滤器 | 缓存穿透 | 低 | ⭐⭐ | ★★★★☆ |
| 互斥锁 | 缓存击穿 | 中(有等待) | ⭐⭐ | ★★★★★ |
| 逻辑过期+异步 | 缓存击穿 | 低(无等待) | ⭐⭐⭐ | ★★★★★ |
| 过期时间随机化 | 缓存雪崩 | 极低 | ⭐ | ★★★★★ |
| 本地缓存兜底 | 缓存雪崩 | 低 | ⭐⭐ | ★★★★★ |
| Redis高可用 | 缓存雪崩 | 中 | ⭐⭐⭐⭐ | ★★★★★ |
最后给出几条切实可行的建议:
- 必须做:过期时间加随机偏移 + 缓存空值处理。这两项改动最小、收益最大,任何Redis项目都应默认实施。
- 推荐做:对热点数据使用逻辑过期+异步刷新,避免击穿时的大量等待。
- 高级方案:引入布隆过滤器拦截穿透请求,配合多级缓存架构应对极端流量。
- 监控告警:监控Redis缓存命中率、数据库QPS、慢查询数量,设置分级告警阈值。当缓存命中率低于80%或数据库QPS超过正常值2倍时自动触发告警。
- 压测验证:上线前使用压测工具模拟缓存穿透、击穿和雪崩场景,验证防护策略是否有效。推荐使用JMeter或Locust进行实际演练。
缓存穿透、击穿和雪崩是Redis生产环境中最常见的三大”坑”。理解它们的原理和区别,掌握对应的解决方案,是每一位后端工程师的必修课。希望这篇文章能帮你构建一个更健壮的缓存系统,让你的应用在面对高并发时也能从容应对。
汤不热吧