Redis 缓存中,穿透、击穿、雪崩的区别是什么?如何避免?

在 Redis 缓存使用中,穿透、击穿、雪崩 是三种典型的缓存异常场景,核心区别在于触发原因、影响范围和发生时机不同。

1.穿透、击穿、雪崩的核心区别

特性 缓存穿透(Cache Penetration) 缓存击穿(Cache Breakdown) 缓存雪崩(Cache Avalanche)
触发原因 访问 不存在的 key(缓存和数据库均无数据),请求直接穿透到数据库 访问 热点 key(缓存中存在但已过期),同时大量请求直达数据库 大量缓存 key 同时过期 或 Redis 服务宕机,导致所有请求直达数据库
数据状态 缓存无、数据库无 缓存无(过期)、数据库有 缓存无(批量过期 / 服务挂了)、数据库有
请求流量 单个或少量请求(可能是恶意攻击,如遍历无效 ID) 高并发请求(集中在同一个热点 key) 海量请求(覆盖多个 key,甚至全量请求)
影响范围 数据库压力较小(但长期恶意请求会耗资源) 数据库单点压力暴增(热点 key 对应的表可能被打崩) 数据库整体压力骤增,可能导致数据库宕机(服务雪崩)
典型场景 恶意攻击(如查询用户 ID=-1)、业务逻辑错误(查询不存在的资源) 秒杀商品、热门活动页面(缓存过期后瞬时大量请求) 缓存集群重启、批量 key 设置相同过期时间(如凌晨 1 点)

2.具体解决方案(结合 Spring Boot 实践)

2.1 缓存穿透:避免 "不存在的 key" 直达数据库

核心思路:对无效 key 进行缓存占位、校验拦截、限制请求频率

2.1.1缓存空值(空对象 / 空字符串)

  • 逻辑:当数据库查询结果为空时,仍将该 key 对应的空值存入缓存(设置较短过期时间,如 5 分钟),避免后续相同请求重复穿透。
  • 注意:需区分 "业务空值"(如用户已删除,返回 null)和 "无效 key"(如 ID 格式错误),避免缓存垃圾数据。
  • Spring Boot 代码示例:
java 复制代码
@Service
public class UserService {
    @Autowired
    private StringRedisTemplate redisTemplate;
    @Autowired
    private UserMapper userMapper;

    // 空值缓存前缀(避免与真实数据冲突)
    private static final String EMPTY_KEY_PREFIX = "empty:";
    // 空值过期时间(5分钟)
    private static final long EMPTY_EXPIRE = 300;

    public User getUserById(Long id) {
        // 1. 校验key有效性(基础拦截)
        if (id == null || id <= 0) {
            return null;
        }
        String key = "user:" + id;

        // 2. 查询缓存
        String json = redisTemplate.opsForValue().get(key);
        if (json != null) {
            // 3. 若为缓存的空值,直接返回null
            if (json.equals(EMPTY_KEY_PREFIX)) {
                return null;
            }
            // 4. 真实数据,反序列化返回
            return JSON.parseObject(json, User.class);
        }

        // 5. 缓存未命中,查询数据库
        User user = userMapper.selectById(id);
        if (user != null) {
            // 6. 数据库有数据,缓存真实值(过期时间1小时)
            redisTemplate.opsForValue().set(key, JSON.toJSONString(user), 3600, TimeUnit.SECONDS);
        } else {
            // 7. 数据库无数据,缓存空值(短期过期)
            redisTemplate.opsForValue().set(key, EMPTY_KEY_PREFIX, EMPTY_EXPIRE, TimeUnit.SECONDS);
        }
        return user;
    }
}

2.1.2布隆过滤器(Bloom Filter)拦截无效 key

  • 逻辑:在缓存之前增加布隆过滤器,将数据库中所有有效 key(如用户 ID、商品 ID)提前存入过滤器。请求到来时,先通过过滤器判断 key 是否存在,不存在则直接返回,无需查询缓存和数据库。
  • 适用场景:数据量极大(如千万级用户 ID),缓存空值会占用大量内存的场景。
  • Spring Boot 集成示例(使用 Redisson 实现布隆过滤器):
java 复制代码
// 1. 引入 Redisson 依赖
<dependency>
    <groupId>org.redisson</groupId>
    <artifactId>redisson-spring-boot-starter</artifactId>
    <version>3.23.3</version>
</dependency>

// 2. 配置布隆过滤器(初始化时加载有效key)
@Configuration
public class RedissonConfig {
    @Bean
    public RBloomFilter<Long> userBloomFilter(RedissonClient redissonClient) {
        // 布隆过滤器名称
        String filterName = "user:id:filter";
        RBloomFilter<Long> bloomFilter = redissonClient.getBloomFilter(filterName);
        // 初始化:预计数据量100万,误判率0.01
        bloomFilter.tryInit(1000000, 0.01);
        
        // 加载数据库中所有有效用户ID到过滤器(实际应异步批量加载)
        List<Long> validUserIds = userMapper.selectAllValidIds();
        for (Long id : validUserIds) {
            bloomFilter.add(id);
        }
        return bloomFilter;
    }
}

// 3. 业务层使用布隆过滤器拦截
@Service
public class UserService {
    @Autowired
    private RBloomFilter<Long> userBloomFilter;

    public User getUserById(Long id) {
        // 1. 布隆过滤器判断:不存在则直接返回(避免穿透)
        if (!userBloomFilter.contains(id)) {
            return null;
        }
        // 2. 后续查询缓存、数据库(同方案1)
        // ...
    }
}

2.1.3接口参数校验 + 限流

  • 逻辑:在网关层(如 Spring Cloud Gateway)对请求参数进行校验(如用户 ID 必须为正整数),同时对异常请求进行限流(如使用 Sentinel 限制单个 IP 的无效请求频率),从源头阻断穿透。

2.2缓存击穿:保护 "热点 key" 过期后的数据库

核心思路:避免热点 key 同时过期,或过期后避免并发请求直达数据库

2.2.1热点 key 永不过期(物理过期 + 逻辑过期)

  • 逻辑:
    • 物理过期:缓存不设置过期时间,避免自动失效;
    • 逻辑过期:在缓存 value 中嵌入过期时间(如 {"data": "...", "expireTime": 1699999999999}),业务层查询时判断是否过期,若过期则异步更新缓存,不影响当前请求。
  • 优点:避免并发穿透,用户体验无感知;
  • 缺点:需要异步线程维护缓存,可能存在短期数据不一致(可接受)。
  • 代码示例:
java 复制代码
@Service
public class SeckillService {
    @Autowired
    private StringRedisTemplate redisTemplate;
    @Autowired
    private SeckillMapper seckillMapper;
    // 线程池(异步更新缓存)
    private static final ExecutorService CACHE_UPDATE_POOL = Executors.newFixedThreadPool(5);

    public SeckillGoods getSeckillGoods(Long goodsId) {
        String key = "seckill:goods:" + goodsId;
        String json = redisTemplate.opsForValue().get(key);
        if (json == null) {
            // 缓存未命中(可能是首次查询),查询数据库并缓存(逻辑过期1小时)
            return loadDataToCache(goodsId, key);
        }

        // 解析缓存数据(包含逻辑过期时间)
        JsonNode jsonNode = JSON.parseObject(json);
        SeckillGoods goods = JSON.parseObject(jsonNode.get("data").toString(), SeckillGoods.class);
        long expireTime = jsonNode.get("expireTime").asLong();

        // 逻辑未过期:直接返回数据
        if (System.currentTimeMillis() < expireTime) {
            return goods;
        }

        // 逻辑已过期:异步更新缓存,当前请求返回旧数据(避免穿透)
        CACHE_UPDATE_POOL.submit(() -> loadDataToCache(goodsId, key));
        return goods;
    }

    // 加载数据到缓存(逻辑过期)
    private SeckillGoods loadDataToCache(Long goodsId, String key) {
        // 查询数据库(实际应加分布式锁,避免多线程重复查询数据库)
        SeckillGoods goods = seckillMapper.selectById(goodsId);
        if (goods == null) {
            return null;
        }
        // 逻辑过期时间:当前时间+1小时
        long expireTime = System.currentTimeMillis() + 3600 * 1000;
        Map<String, Object> cacheValue = new HashMap<>();
        cacheValue.put("data", goods);
        cacheValue.put("expireTime", expireTime);
        // 缓存永不过期(物理)
        redisTemplate.opsForValue().set(key, JSON.toJSONString(cacheValue));
        return goods;
    }
}

2.2.2分布式锁(互斥锁)保护

  • 逻辑:当热点 key 过期时,只有一个线程能获取分布式锁,进入数据库查询并更新缓存,其他线程等待锁释放后直接查询缓存,避免并发穿透。
  • 适用场景:数据一致性要求高,不允许返回旧数据的场景;
  • 注意:锁的过期时间需合理设置(避免死锁),且需处理锁竞争导致的线程等待(可设置超时时间)。
  • 代码示例(使用 Redisson 分布式锁):
java 复制代码
@Service
public class SeckillService {
    @Autowired
    private RedissonClient redissonClient;

    public SeckillGoods getSeckillGoods(Long goodsId) {
        String key = "seckill:goods:" + goodsId;
        String json = redisTemplate.opsForValue().get(key);
        if (json != null) {
            return JSON.parseObject(json, SeckillGoods.class);
        }

        // 缓存未命中,获取分布式锁
        RLock lock = redissonClient.getLock("lock:seckill:" + goodsId);
        try {
            // 尝试获取锁(3秒超时,10秒自动释放)
            boolean locked = lock.tryLock(3, 10, TimeUnit.SECONDS);
            if (locked) {
                // 再次查询缓存(避免其他线程已更新)
                String newJson = redisTemplate.opsForValue().get(key);
                if (newJson != null) {
                    return JSON.parseObject(newJson, SeckillGoods.class);
                }
                // 查询数据库并更新缓存
                SeckillGoods goods = seckillMapper.selectById(goodsId);
                if (goods != null) {
                    redisTemplate.opsForValue().set(key, JSON.toJSONString(goods), 3600, TimeUnit.SECONDS);
                }
                return goods;
            } else {
                // 未获取到锁,重试(或返回默认值)
                Thread.sleep(50);
                return getSeckillGoods(goodsId);
            }
        } catch (InterruptedException e) {
            throw new RuntimeException("获取锁失败", e);
        } finally {
            // 释放锁(只有持有锁的线程才释放)
            if (lock.isHeldByCurrentThread()) {
                lock.unlock();
            }
        }
    }
}

2.2.3热点 key 过期时间错开

  • 逻辑:对批量热点 key(如秒杀商品)设置过期时间时,增加随机偏移量(如 3600 ± 60 秒),避免所有 key 同时过期。
  • 代码示例:
java 复制代码
// 批量缓存热点商品,过期时间错开(1小时±1分钟)
public void batchCacheSeckillGoods(List<Long> goodsIds) {
    for (Long id : goodsIds) {
        SeckillGoods goods = seckillMapper.selectById(id);
        if (goods != null) {
            String key = "seckill:goods:" + id;
            // 随机过期时间:3600秒 ± 60秒
            long expire = 3600 + new Random().nextInt(120) - 60;
            redisTemplate.opsForValue().set(key, JSON.toJSONString(goods), expire, TimeUnit.SECONDS);
        }
    }
}

2.3 缓存雪崩:避免 "批量 key 过期" 或 "Redis 宕机" 导致的服务雪崩

核心思路:分散过期时间、提高缓存可用性、降级熔断保护数据库

2.3.1缓存过期时间随机化(核心方案)

  • 逻辑:对所有缓存 key 的过期时间添加随机偏移量(如 基础过期时间 + 随机数),避免批量 key 同时过期。
  • 示例:基础过期时间 1 小时,随机偏移量 0-300 秒,最终过期时间为 3600~4100 秒。
  • 代码示例:
java 复制代码
// 通用缓存工具类(添加随机过期时间)
@Component
public class CacheUtil {
    @Autowired
    private StringRedisTemplate redisTemplate;
    // 最大随机偏移量(5分钟)
    private static final int MAX_RANDOM_EXPIRE = 300;

    public void setCacheWithRandomExpire(String key, Object value, long baseExpire, TimeUnit timeUnit) {
        // 转换基础过期时间为秒
        long baseExpireSec = timeUnit.toSeconds(baseExpire);
        // 随机偏移量(0~MAX_RANDOM_EXPIRE秒)
        int random = new Random().nextInt(MAX_RANDOM_EXPIRE);
        // 最终过期时间
        long finalExpire = baseExpireSec + random;
        redisTemplate.opsForValue().set(key, JSON.toJSONString(value), finalExpire, TimeUnit.SECONDS);
    }
}

2.3.2缓存集群高可用(避免 Redis 宕机)

  • 逻辑:

    • 部署 Redis 主从集群(1 主 N 从),主节点故障时从节点自动切换;
    • 开启 Redis 哨兵模式(Sentinel)或使用 Redis Cluster 集群,确保集群可用性;
    • 关键业务可跨机房部署,避免单点故障。
  • Spring Boot 配置 Redis 集群示例:

    spring:
    redis:
    cluster:
    nodes:
    - 192.168.1.101:6379
    - 192.168.1.102:6379
    - 192.168.1.103:6379
    max-redirects: 3 # 最大重定向次数
    lettuce:
    pool:
    max-active: 8
    max-idle: 8
    min-idle: 2

2.3.3 服务降级与熔断(保护数据库)

  • 逻辑:当 Redis 宕机或缓存命中率骤降时,通过降级策略限制对数据库的请求,避免数据库雪崩。
  • 实现方式:使用 Sentinel 或 Hystrix 进行熔断降级,如:
    • 缓存失效时,返回默认数据(如 "系统繁忙,请稍后重试");
    • 数据库压力达到阈值时,拒绝部分非核心请求;
  • 代码示例(Sentinel 注解降级):
java 复制代码
@Service
public class UserService {
    // 配置熔断规则:当异常比例>50%且每秒请求>10时,熔断5秒,返回默认值
    @SentinelResource(
        value = "getUserById",
        fallback = "getUserFallback",
        blockHandler = "getUserBlockHandler"
    )
    public User getUserById(Long id) {
        // 正常查询缓存、数据库逻辑
        // ...
    }

    // 降级 fallback:异常时返回默认用户
    public User getUserFallback(Long id, Throwable e) {
        return new User(-1L, "默认用户", "系统繁忙,请稍后重试");
    }

    // 限流 blockHandler:被限流时返回提示
    public User getUserBlockHandler(Long id, BlockException e) {
        return new User(-1L, "限流提示", "请求过于频繁,请稍后重试");
    }
}

2.3.4数据库限流(最后的防线)

  • 逻辑:通过数据库连接池限制最大并发连接数(如 HikariCP 配置 maximum-pool-size=20),或使用数据库中间件(如 MyCat)进行限流,避免数据库因瞬间高并发被打崩。

3.总结与最佳实践

3.1 核心区别速记

  • 穿透:查 "不存在的 key"(缓存 + 数据库都没有);
  • 击穿:查 "过期的热点 key"(缓存没有,数据库有,高并发);
  • 雪崩:"大量 key 过期" 或 "Redis 宕机"(缓存整体失效,海量请求)。

3.2 企业级最佳实践组合

异常类型 推荐方案组合
缓存穿透 布隆过滤器 + 缓存空值 + 接口参数校验
缓存击穿 逻辑过期(异步更新) + 分布式锁
缓存雪崩 随机过期时间 + Redis 集群高可用 + Sentinel 降级

3.3 关键注意点

  • 缓存空值时需设置短期过期时间,避免占用过多内存;
  • 分布式锁需注意锁的粒度(避免过大)和过期时间(避免死锁);
  • 布隆过滤器存在误判率,需根据业务场景调整参数(预计数据量、误判率);
  • 降级策略需区分核心业务和非核心业务,避免影响关键功能。
相关推荐
@游子1 小时前
第二章-MySQL之手工注入(二)
数据库·mysql
前进的李工1 小时前
SQL入门:从零掌握数据库查询语言
数据库·sql·mysql
心无旁骛~1 小时前
openGauss 在 AI、RAG 与向量数据库时代的技术破局与生态深耕
数据库·人工智能
6***94151 小时前
MySQL 字符串日期格式转换
android·数据库·mysql
g***86691 小时前
MySQL - Navicat自动备份MySQL数据
android·数据库·mysql
youxiao_902 小时前
数据库基础与安装
数据库
q***01772 小时前
【MySQL】数据类型
android·数据库·mysql
SelectDB2 小时前
Apache Doris 在小米统一 OLAP 和湖仓一体的实践
运维·数据库·程序员
n***33352 小时前
【Oracle11g SQL详解】日期和时间函数:SYSDATE、TO_DATE、TO_CHAR 等
数据库·sql