Redis缓存中三大问题——穿透、击穿、雪崩

Redis 缓存的穿透、击穿、雪崩 是生产环境中最常见的三大性能与可用性问题,三者的核心差异在于触发场景、影响范围、解决方案 ,但本质都是缓存未命中后,请求直接冲击数据库,导致数据库压力陡增甚至宕机。

问题类型 核心定义 影响范围 缓存状态 数据库压力
缓存穿透 请求不存在的 key,缓存和数据库均无数据,请求持续穿透到数据库 单个无效 key 的高频请求 缓存未命中(永久) 持续低至中等压力
缓存击穿 热点 key 突然失效,大量请求同时穿透到数据库 单个热点 key 的瞬时请求 缓存命中 → 突然失效 瞬时极高压力
缓存雪崩 大量 key 同时失效缓存服务整体宕机,导致大量请求穿透到数据库 整个缓存集群的所有请求 大面积缓存失效 / 缓存不可用 整体极高压力,可能直接压垮数据库

一、缓存穿透(Cache Penetration)

1、定义

客户端请求一个缓存和数据库中都不存在的 key ,由于缓存中没有数据,请求会直接穿透到数据库;而数据库中也没有对应数据,无法将结果写入缓存。最终导致所有此类请求都会直接访问数据库,造成数据库的无效负载。

2、触发原因

  • 业务逻辑问题:用户查询不存在的业务数据(如查询一个不存在的用户 ID、订单号)。
  • 恶意攻击 :黑客构造大量无效的 key 发起请求(如批量请求 user:-1order:abc 等),专门用于穿透缓存,攻击数据库。

3、典型场景

  • 电商平台中,黑客批量请求不存在的商品 ID,导致商品查询接口的数据库压力陡增。
  • 接口未做参数校验,攻击者传入非法参数(如负数、超长字符串),触发大量无效查询。

4、解决方案

(1)参数校验 + 业务过滤(第一道防线)

在请求到达缓存之前,先对参数进行合法性校验,过滤掉明显无效的请求。

java 复制代码
// 用户查询接口,先校验用户ID的合法性
public User getUserById(Long userId) {
    // 过滤无效参数:ID必须大于0
    if (userId == null || userId <= 0) {
        return null;
    }
    // 后续缓存查询逻辑
    String key = "user:" + userId;
    User user = redisTemplate.opsForValue().get(key);
    if (user == null) {
        user = userMapper.selectById(userId);
        if (user != null) {
            redisTemplate.opsForValue().set(key, user, 30, TimeUnit.MINUTES);
        }
    }
    return user;
}

(2)空值缓存(核心解决方案)

对于数据库中不存在的 key,将其空结果写入缓存 ,并设置一个较短的过期时间(如 5 分钟)。这样,后续相同的无效请求会被缓存拦截,不会穿透到数据库。注意

  • 过期时间不能太长,否则会导致缓存中积累大量空值,浪费内存。
  • 可以为空值设置独立的缓存前缀(如 null:user:-1),方便后续批量清理。
java 复制代码
public User getUserById(Long userId) {
    if (userId == null || userId <= 0) {
        return null;
    }
    String key = "user:" + userId;
    User user = redisTemplate.opsForValue().get(key);
    if (user != null) {
        return user;
    }
    // 数据库查询
    user = userMapper.selectById(userId);
    if (user != null) {
        // 有数据,缓存30分钟
        redisTemplate.opsForValue().set(key, user, 30, TimeUnit.MINUTES);
    } else {
        // 无数据,缓存空值5分钟,防止穿透
        redisTemplate.opsForValue().set(key, null, 5, TimeUnit.MINUTES);
    }
    return user;
}

(3)布隆过滤器(终极解决方案,适用于海量数据场景)

布隆过滤器是一种空间效率极高的概率型数据结构,可以用来判断一个元素是否在一个集合中。其核心特点是:

  • 不存在的元素:一定能被准确判断为「不存在」,从而直接拦截请求。
  • 存在的元素:可能会被判断为「存在」(存在一定的误判率,可通过调整参数控制)。

适用场景 :数据量极大(如亿级用户 ID、商品 ID),空值缓存会占用大量内存的场景。实现步骤

  • 系统启动时,将数据库中所有有效的 key 加载到布隆过滤器中。
  • 接收到请求时,先通过布隆过滤器判断 key 是否存在:
    • 若不存在,直接返回 null,不访问缓存和数据库。
    • 若存在,再走正常的缓存 → 数据库流程。
java 复制代码
// 初始化布隆过滤器,预计存放1000万条数据,误判率为0.01
private static BloomFilter<Long> userBloomFilter = BloomFilter.create(
    Funnels.longFunnel(),
    10_000_000,
    0.01
);

// 系统启动时,加载所有用户ID到布隆过滤器
@PostConstruct
public void initBloomFilter() {
    List<Long> allUserId = userMapper.selectAllUserId();
    for (Long userId : allUserId) {
        userBloomFilter.put(userId);
    }
}

public User getUserById(Long userId) {
    if (userId == null || userId <= 0) {
        return null;
    }
    // 布隆过滤器判断:若不存在,直接返回
    if (!userBloomFilter.mightContain(userId)) {
        return null;
    }
    // 后续缓存 → 数据库流程
    String key = "user:" + userId;
    User user = redisTemplate.opsForValue().get(key);
    if (user == null) {
        user = userMapper.selectById(userId);
        if (user != null) {
            redisTemplate.opsForValue().set(key, user, 30, TimeUnit.MINUTES);
        } else {
            // 布隆过滤器误判,缓存空值
            redisTemplate.opsForValue().set(key, null, 5, TimeUnit.MINUTES);
        }
    }
    return user;
}

5. 优缺点

  • 参数校验:优点是实现简单,开销小;缺点是只能过滤明显无效的请求,无法处理合法但不存在的 key。
  • 空值缓存:优点是实现简单,效果好;缺点是会占用一定的缓存空间,且存在缓存更新的问题。
  • 布隆过滤器:优点是空间效率极高,能有效拦截海量无效请求;缺点是存在误判率,且需要提前加载数据,不支持动态删除。

二、缓存击穿(Cache Breakdown)

1、定义

一个热点 key (如秒杀商品、热门新闻、首页缓存)在缓存中突然失效 (过期或被淘汰),此时大量的请求同时访问这个 key,由于缓存未命中,这些请求会瞬间全部穿透到数据库,导致数据库在短时间内承受巨大的压力,甚至被压垮。

2、核心区别于穿透

  • 穿透:key 永远不存在,缓存和数据库都没有。
  • 击穿:key 曾经存在 ,缓存中突然失效,数据库中仍然存在。

3、触发原因

  • 热点 key 过期:为热点 key 设置了过期时间,且过期时间集中在同一时刻。
  • 缓存淘汰:热点 key 被 LRU、LFU 等缓存淘汰策略清理掉。
  • 主动删除:业务代码主动删除了热点 key。

4、典型场景

  • 电商秒杀活动中,某个热门商品的缓存过期,瞬间有 10 万用户同时查询该商品,所有请求都穿透到数据库。
  • 首页的缓存数据(如轮播图、推荐商品)过期,大量用户访问首页,导致数据库压力陡增。

5、解决方案

(1)热点 key 永不过期(最简单有效)

对于真正的热点 key,不设置过期时间 ,由业务代码主动更新缓存,而不是依赖缓存的过期机制。注意

  • 必须保证业务代码能主动更新缓存,否则会导致缓存数据不一致。
  • 适用于数据更新频率低的热点 key(如首页静态数据、热门商品的基本信息)。
java 复制代码
public Product getHotProduct(Long productId) {
    String key = "hot:product:" + productId;
    Product product = redisTemplate.opsForValue().get(key);
    if (product == null) {
        // 数据库查询,加锁防止并发穿透
        synchronized (this) {
            product = redisTemplate.opsForValue().get(key);
            if (product == null) {
                product = productMapper.selectById(productId);
                if (product != null) {
                    // 永不过期,主动更新
                    redisTemplate.opsForValue().set(key, product);
                }
            }
        }
    }
    return product;
}

// 主动更新缓存的方法,由定时任务或业务触发
public void updateHotProductCache(Long productId) {
    String key = "hot:product:" + productId;
    Product product = productMapper.selectById(productId);
    if (product != null) {
        redisTemplate.opsForValue().set(key, product);
    }
}

(2)互斥锁(分布式锁,最通用)

当缓存未命中时,只有一个请求能获得锁 ,并去数据库查询数据,其他请求则等待锁释放 ,然后从缓存中获取数据。这样可以保证同一时刻只有一个请求穿透到数据库 ,有效解决击穿问题。实现方式

  • 本地锁(synchronized、ReentrantLock):适用于单节点应用。
  • 分布式锁(Redis Redlock、ZooKeeper):适用于分布式集群应用。
java 复制代码
public Product getProductById(Long productId) {
    String key = "product:" + productId;
    String lockKey = "lock:product:" + productId;
    Product product = redisTemplate.opsForValue().get(key);
    if (product == null) {
        // 尝试获取分布式锁,超时时间3秒,过期时间5秒
        Boolean locked = redisTemplate.opsForValue().setIfAbsent(lockKey, "1", 5, TimeUnit.SECONDS);
        if (locked != null && locked) {
            try {
                // 获得锁,查询数据库
                product = productMapper.selectById(productId);
                if (product != null) {
                    redisTemplate.opsForValue().set(key, product, 30, TimeUnit.MINUTES);
                } else {
                    // 空值缓存,防止穿透
                    redisTemplate.opsForValue().set(key, null, 5, TimeUnit.MINUTES);
                }
            } finally {
                // 释放锁
                redisTemplate.delete(lockKey);
            }
        } else {
            // 未获得锁,等待50毫秒后重试
            Thread.sleep(50);
            return getProductById(productId);
        }
    }
    return product;
}

(3)过期时间随机化(预防策略)

对于必须设置过期时间的 key,在基础过期时间上增加一个随机值(如 0~60 秒),避免大量 key 在同一时刻过期。

java 复制代码
public void setCacheWithRandomExpire(String key, Object value, int baseExpireSeconds) {
    // 随机增加0~60秒的过期时间
    int randomExpire = new Random().nextInt(60);
    int totalExpire = baseExpireSeconds + randomExpire;
    redisTemplate.opsForValue().set(key, value, totalExpire, TimeUnit.SECONDS);
}

6、优缺点

  • 永不过期:优点是实现简单,效果好;缺点是需要主动更新缓存,可能导致数据不一致,且占用缓存空间。
  • 互斥锁:优点是通用,适用于所有场景,能有效防止并发穿透;缺点是实现稍复杂,存在锁竞争的开销,可能导致请求延迟。
  • 随机过期时间:优点是实现简单,能有效预防击穿;缺点是只能预防,不能解决已经发生的击穿问题。

三、缓存雪崩(Cache Avalanche)

1、定义

大量的 key 在同一时刻同时失效 ,或者整个缓存服务突然宕机 (如 Redis 集群崩溃、网络故障),导致所有的请求都穿透到数据库,数据库在短时间内承受巨大的压力,甚至被压垮,从而引发整个系统的雪崩效应。

2、核心区别于击穿

  • 击穿:单个热点 key 失效,影响范围小。
  • 雪崩:大量 key 同时失效缓存服务整体宕机,影响范围大,是击穿的升级版。

3、触发原因

(1)缓存层原因

  • 大量 key 同时过期:系统初始化时,为大量 key 设置了相同的过期时间;或者某个批量操作,为大量 key 设置了相同的过期时间。
  • 缓存服务整体宕机:Redis 集群崩溃、网络故障、硬件故障等,导致缓存服务不可用。
  • 缓存淘汰:大量 key 被 LRU、LFU 等缓存淘汰策略批量清理。

(2)应用层原因

  • 大量请求同时涌入,导致缓存层压力过大,最终崩溃。

4、典型场景

  • 电商平台的大促活动中,大量商品的缓存都设置了 24 小时的过期时间,活动结束后,这些缓存同时过期,导致大量请求穿透到数据库。
  • Redis 集群的主节点宕机,从节点没有及时切换,导致整个缓存服务不可用,所有请求都穿透到数据库。

5、解决方案

缓存雪崩的影响范围极大,因此需要采用分层防御策略 ,从缓存层、数据库层、应用层三个层面进行防护。

(1)缓存层防护(核心)

  • 过期时间随机化:为每个 key 的过期时间增加一个随机值,避免大量 key 在同一时刻过期。(同击穿的预防策略)
  • 缓存集群高可用 :搭建 Redis 集群,采用主从复制 + 哨兵模式Redis Cluster,保证缓存服务的高可用。即使某个节点宕机,其他节点仍能提供服务。
  • 多级缓存 :引入本地缓存(如 Caffeine、Guava Cache) + 分布式缓存(Redis) 的多级缓存架构。当分布式缓存宕机时,本地缓存可以作为兜底,拦截一部分请求。

(2)数据库层防护(兜底)

  • 数据库读写分离:搭建数据库的读写分离架构,将读请求分散到多个从库,提高数据库的处理能力。
  • 数据库分库分表:对数据库进行分库分表,分散单表的压力。
  • 限流降级:在数据库层设置限流策略,当请求量超过阈值时,拒绝部分请求,或者返回降级数据(如缓存的旧数据、默认数据)。

(3)应用层防护(前置)

  • 限流:在应用层设置限流策略(如使用 Sentinel、Hystrix),限制每秒的请求数,避免大量请求同时涌入。
  • 降级:当缓存服务宕机时,应用层自动降级,返回兜底数据(如本地缓存的旧数据、默认数据),而不是直接访问数据库。
  • 熔断:当数据库的压力超过阈值时,应用层自动熔断,停止访问数据库,返回兜底数据,避免数据库被压垮。

6、典型实现(多级缓存 + 限流降级)

java 复制代码
// 本地缓存,Caffeine,过期时间5分钟
private static final Cache<Long, Product> localCache = Caffeine.newBuilder()
    .expireAfterWrite(5, TimeUnit.MINUTES)
    .maximumSize(1000)
    .build();

// 分布式缓存,Redis
public Product getProductById(Long productId) {
    // 1. 先查本地缓存
    Product product = localCache.getIfPresent(productId);
    if (product != null) {
        return product;
    }
    // 2. 再查分布式缓存
    String key = "product:" + productId;
    product = redisTemplate.opsForValue().get(key);
    if (product != null) {
        // 写入本地缓存
        localCache.put(productId, product);
        return product;
    }
    // 3. 最后查数据库,加分布式锁
    String lockKey = "lock:product:" + productId;
    Boolean locked = redisTemplate.opsForValue().setIfAbsent(lockKey, "1", 5, TimeUnit.SECONDS);
    if (locked != null && locked) {
        try {
            product = productMapper.selectById(productId);
            if (product != null) {
                redisTemplate.opsForValue().set(key, product, 30, TimeUnit.MINUTES);
                localCache.put(productId, product);
            } else {
                redisTemplate.opsForValue().set(key, null, 5, TimeUnit.MINUTES);
            }
        } finally {
            redisTemplate.delete(lockKey);
        }
    } else {
        Thread.sleep(50);
        return getProductById(productId);
    }
    return product;
}
相关推荐
小毅&Nora7 小时前
【Java线程安全实战】⑨ CompletableFuture的高级用法:从基础到高阶,结合虚拟线程
java·线程安全·虚拟线程
小璐猪头7 小时前
专为 Spring Boot 设计的 Elasticsearch 日志收集 Starter
java
阿里巴巴P8资深技术专家7 小时前
基于 Spring AI 和 Redis 向量库的智能对话系统实践
人工智能·redis·spring
ps酷教程8 小时前
HttpPostRequestDecoder源码浅析
java·http·netty
闲人编程8 小时前
消息通知系统实现:构建高可用、可扩展的企业级通知服务
java·服务器·网络·python·消息队列·异步处理·分发器
栈与堆8 小时前
LeetCode-1-两数之和
java·数据结构·后端·python·算法·leetcode·rust
oMcLin8 小时前
如何在 AlmaLinux 9 上配置并优化 Redis 集群,支持高并发的实时数据缓存与快速查询?
数据库·redis·缓存
OC溥哥9998 小时前
Paper MinecraftV3.0重大更新(下界更新)我的世界C++2D版本隆重推出,拷贝即玩!
java·c++·算法
星火开发设计8 小时前
C++ map 全面解析与实战指南
java·数据结构·c++·学习·算法·map·知识