欢迎光临
我们一直在努力

Redis缓存穿透、缓存击穿与缓存雪崩:原理剖析与综合防御实战

缓存穿透:当请求绕过缓存直达数据库

缓存穿透是指请求的数据在缓存和数据库中都不存在,每次请求都直接穿透缓存层打到数据库。这种情况最常见的场景是恶意攻击或扫描器遍历不存在的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生产环境中最常见的三大”坑”。理解它们的原理和区别,掌握对应的解决方案,是每一位后端工程师的必修课。希望这篇文章能帮你构建一个更健壮的缓存系统,让你的应用在面对高并发时也能从容应对。

【本站文章皆为原创,未经允许不得转载】:汤不热吧 » Redis缓存穿透、缓存击穿与缓存雪崩:原理剖析与综合防御实战
分享到: 更多 (0)