一、前言

在高并发系统中,缓存是提升性能的核心手段。Redis 作为最常用的分布式缓存中间件,在 Java 后端架构中扮演着关键角色。然而,引入缓存的同时也带来了新的风险——缓存击穿、缓存穿透、缓存雪崩。这三个问题一旦发生,轻则数据库压力激增,重则服务雪崩、系统瘫痪。

本文将从实际生产经验出发,结合 Java 代码示例,深入剖析这三个问题的本质及应对方案。

前置说明

  1. 依赖:SpringBoot 2.7+/3.x,spring-boot-starter-data-redis,jackson 序列化,本地缓存 Caffeine,分布式锁 Redisson

  2. 场景:商品详情查询接口,根据商品 id 查询商品信息,数据库 MySQL,缓存 Redis

  3. 实体类

@Data
@NoArgsConstructor
@AllArgsConstructor
public class ProductDO implements Serializable {
    private Long id;
    private String productName;
    private Long price;
    private Integer stock;
}

二、缓存穿透(Cache Penetration)

2.1 什么是缓存穿透?

大量请求查询一个不存在的数据,Redis 查不到,请求直接打到数据库。 比如传商品 id=-1、id=99999999(数据库没有这条记录),Redis 没有 key,每次都访问 DB。 攻击者可以批量发送不存在 id,压垮数据库。

2. 解决方案

  1. 缓存空值(最简单):数据库查不到,往 Redis 写入空对象,设置较短过期时间。缺点:会产生大量无效 key。

  2. 布隆过滤器(生产推荐,高并发场景):把所有合法商品 id 预加载到布隆过滤器,请求先过过滤器,不存在直接拒绝,不访问 Redis 和 DB。缺点:布隆过滤器不能删除数据,有微小误判率。

3. 生产代码实现

方案一:缓存空值(推荐)

将查询不到的结果也缓存起来,设置一个较短的过期时间。

@Service
public class ProductServiceImpl implements ProductService {

    private static final String PRODUCT_KEY_PREFIX = "product:";
    // 空值过期时间 2分钟,防止大量无效key常驻redis
    private static final long EMPTY_EXPIRE_SECONDS = 120L;

    @Autowired
    private RedisTemplate<String, Object> redisTemplate;
    @Autowired
    private ProductMapper productMapper;

    @Override
    public ProductDO getProductById(Long productId) {
        String key = PRODUCT_KEY_PREFIX + productId;
        // 1. 查询redis缓存
        ProductDO cacheObj = (ProductDO) redisTemplate.opsForValue().get(key);
        // 缓存命中,并且不是空标记,直接返回
        if (cacheObj != null) {
            // 判断是否是空标记
            if ("NULL".equals(cacheObj.getProductName())) {
                return null;
            }
            return cacheObj;
        }
        // 2. 缓存未命中,查询数据库
        ProductDO dbProduct = productMapper.selectById(productId);
        if (dbProduct != null) {
            // 写入缓存,设置正常过期时间
            redisTemplate.opsForValue().set(key, dbProduct, 30, TimeUnit.MINUTES);
            return dbProduct;
        } else {
            // 数据库不存在,写入空标记,短过期,防止穿透
            ProductDO nullMark = new ProductDO();
            nullMark.setProductName("NULL");
            redisTemplate.opsForValue().set(key, nullMark, EMPTY_EXPIRE_SECONDS, TimeUnit.SECONDS);
            return null;
        }
    }
}

优点:实现简单,效果明显 缺点:会占用一定内存,需设置合理的空值过期时间

方案 2:布隆过滤器(生产高并发)

依赖:Redisson 自带布隆过滤器,不用自己造轮子

<dependency>
    <groupId>org.redisson</groupId>
    <artifactId>redisson-spring-boot-starter</artifactId>
    <version>3.23.5</version>
</dependency>
@Service
public class ProductServiceImpl implements ProductService {

    private static final String PRODUCT_KEY_PREFIX = "product:";
    private static final String BLOOM_FILTER_NAME = "product_bloom_filter";

    @Autowired
    private RedisTemplate<String, Object> redisTemplate;
    @Autowired
    private RedissonClient redissonClient;
    @Autowired
    private ProductMapper productMapper;

    // 项目启动时初始化布隆过滤器,加载全部商品id
    @PostConstruct
    public void initBloomFilter() {
        RBloomFilter<Long> bloomFilter = redissonClient.getBloomFilter(BLOOM_FILTER_NAME);
        // 预期插入100万条,误判率0.01
        bloomFilter.tryInit(1000000L, 0.01);
        List<Long> allProductIds = productMapper.selectAllProductId();
        allProductIds.forEach(bloomFilter::add);
    }

    @Override
    public ProductDO getProductById(Long productId) {
        RBloomFilter<Long> bloomFilter = redissonClient.getBloomFilter(BLOOM_FILTER_NAME);
        // 布隆过滤器判断不存在,直接返回,禁止访问Redis和DB
        if (!bloomFilter.contains(productId)) {
            return null;
        }
        String key = PRODUCT_KEY_PREFIX + productId;
        ProductDO cacheObj = (ProductDO) redisTemplate.opsForValue().get(key);
        if (cacheObj != null) {
            return cacheObj;
        }
        ProductDO dbProduct = productMapper.selectById(productId);
        if (dbProduct != null) {
            redisTemplate.opsForValue().set(key, dbProduct, 30, TimeUnit.MINUTES);
        }
        return dbProduct;
    }
}

优点:内存占用极小,拦截效果好 缺点:存在误判率(可接受),不支持删除元素(可用 Counting Bloom Filter 改进)

三、缓存击穿(Cache Breakdown)

1. 什么是缓存击穿

热点 key 过期瞬间,大量并发请求同时打到数据库。 比如秒杀爆款商品,key 设置 30 分钟过期,过期那一瞬间,上千请求同时进来,缓存失效,全部请求访问数据库。

和穿透区分:击穿是数据真实存在,只是缓存刚好过期;穿透是数据本身不存在

2. 解决方案

  1. 互斥锁(分布式锁,Redisson):缓存失效时,只放行一个线程去查 DB、更新缓存,其余线程等待。适合一致性要求高,并发量不是极端爆炸场景。

  2. 热点 key 永不过期(逻辑过期):Redis 不删除 key,value 里面存过期时间,后台异步更新数据。适合超高并发热点商品,允许短暂数据不一致。

3. 生产代码实现

方案 1:Redisson 分布式锁(互斥锁方案)

@Service
public class ProductServiceImpl implements ProductService {

    private static final String PRODUCT_KEY_PREFIX = "product:";
    private static final String LOCK_KEY_PREFIX = "product:lock:";

    @Autowired
    private RedisTemplate<String, Object> redisTemplate;
    @Autowired
    private RedissonClient redissonClient;
    @Autowired
    private ProductMapper productMapper;

    @Override
    public ProductDO getProductById(Long productId) {
        String cacheKey = PRODUCT_KEY_PREFIX + productId;
        ProductDO cacheObj = (ProductDO) redisTemplate.opsForValue().get(cacheKey);
        // 缓存命中直接返回
        if (cacheObj != null) {
            return cacheObj;
        }
        String lockKey = LOCK_KEY_PREFIX + productId;
        RLock lock = redissonClient.getLock(lockKey);
        try {
            // 尝试获取锁,最多等待3秒,锁持有时间10秒
            boolean acquire = lock.tryLock(3, 10, TimeUnit.SECONDS);
            if (!acquire) {
                // 获取锁失败,短暂休眠后重试,或者返回降级
                Thread.sleep(50);
                return getProductById(productId);
            }
            // 获取锁成功,再次查缓存(防止别的线程已经更新完缓存)
            cacheObj = (ProductDO) redisTemplate.opsForValue().get(cacheKey);
            if (cacheObj != null) {
                return cacheObj;
            }
            // 查询数据库,回填缓存
            ProductDO dbProduct = productMapper.selectById(productId);
            if (dbProduct != null) {
                redisTemplate.opsForValue().set(cacheKey, dbProduct, 30, TimeUnit.MINUTES);
            }
            return dbProduct;
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            throw new RuntimeException("查询商品异常", e);
        } finally {
            // 必须判断当前线程持有锁,才释放,防止释放别人的锁
            if (lock.isHeldByCurrentThread()) {
                lock.unlock();
            }
        }
    }
}

优点:有效防止数据库被击穿 缺点:加锁期间会有少量请求等待,实现复杂度稍高

方案 2:逻辑过期(热点 key 永不过期,超高并发)

封装缓存对象,增加逻辑过期时间字段,redis key 不设置 TTL

@Data
public class RedisData<T> implements Serializable {
    private T data;
    // 逻辑过期时间戳,毫秒
    private Long expireTime;
}
@Service
public class ProductServiceImpl implements ProductService {

    private static final String PRODUCT_KEY_PREFIX = "product:";
    private static final String LOCK_KEY_PREFIX = "product:lock:";
    // 逻辑过期时间30分钟
    private static final long LOGIC_EXPIRE = 30 * 60 * 1000L;

    @Autowired
    private RedisTemplate<String, Object> redisTemplate;
    @Autowired
    private RedissonClient redissonClient;
    @Autowired
    private ProductMapper productMapper;
    @Autowired
    private ExecutorService executorService;

    @Override
    public ProductDO getHotProductById(Long productId) {
        String cacheKey = PRODUCT_KEY_PREFIX + productId;
        RedisData<ProductDO> redisData = (RedisData<ProductDO>) redisTemplate.opsForValue().get(cacheKey);
        // 缓存不存在直接返回
        if (redisData == null) {
            return null;
        }
        // 判断是否逻辑过期
        long now = System.currentTimeMillis();
        ProductDO product = redisData.getData();
        if (redisData.getExpireTime() > now) {
            // 未过期直接返回
            return product;
        }
        // 已经逻辑过期,尝试获取分布式锁,异步更新
        String lockKey = LOCK_KEY_PREFIX + productId;
        RLock lock = redissonClient.getLock(lockKey);
        boolean acquire = lock.tryLock();
        if (acquire) {
            // 拿到锁,开启新线程异步更新缓存,主线程直接返回旧数据
            executorService.submit(() -> {
                try {
                    // 查询数据库
                    ProductDO dbProduct = productMapper.selectById(productId);
                    RedisData<ProductDO> newRedisData = new RedisData<>();
                    newRedisData.setData(dbProduct);
                    newRedisData.setExpireTime(System.currentTimeMillis() + LOGIC_EXPIRE);
                    redisTemplate.opsForValue().set(cacheKey, newRedisData);
                } finally {
                    if (lock.isHeldByCurrentThread()) {
                        lock.unlock();
                    }
                }
            });
        }
        // 不管有没有拿到锁,直接返回旧数据,保证高可用,允许短暂不一致
        return product;
    }

    // 预热热点商品,项目启动提前加载到redis
    public void preLoadHotProduct(Long productId) {
        ProductDO dbProduct = productMapper.selectById(productId);
        RedisData<ProductDO> redisData = new RedisData<>();
        redisData.setData(dbProduct);
        redisData.setExpireTime(System.currentTimeMillis() + LOGIC_EXPIRE);
        redisTemplate.opsForValue().set(PRODUCT_KEY_PREFIX + productId, redisData);
    }
}

特点:永远不会出现大量请求同时打 DB,适合秒杀、爆款;代价是数据有短暂延迟。

四、缓存雪崩(Cache Avalanche)

1. 什么是缓存雪崩

大量 Redis key 同一时间集体过期,或者 Redis 服务宕机,大量请求全部打到数据库,数据库瞬间压力爆满,甚至宕机。

击穿:单个热点 key 过期;雪崩:大批量 key 同时失效。

2. 解决方案

  1. 过期时间加随机值(最基础):在原有 TTL 基础上增加随机偏移量,避免大量 key 同时过期。

  2. Redis 集群高可用(主从 + 哨兵 / Cluster),防止 Redis 单点宕机。

  3. 多级缓存:本地 Caffeine 缓存 + Redis 缓存,Redis 挂了,本地缓存兜底。

  4. 服务熔断、限流、降级:使用 Sentinel 或者 Resilience4j,DB 压力大的时候直接返回兜底数据,拒绝新请求。

3. 生产代码实现

方案 1:过期时间增加随机偏移

// 设置缓存,基础30分钟 + 随机0~300秒,打散过期时间
public void setProductCache(ProductDO productDO) {
    String key = PRODUCT_KEY_PREFIX + productDO.getId();
    long baseSeconds = 30 * 60L;
    // 随机偏移 0~300秒
    long randomOffset = new Random().nextLong(300);
    redisTemplate.opsForValue().set(key, productDO, baseSeconds + randomOffset, TimeUnit.SECONDS);
}

方案 2:Caffeine 本地缓存 + Redis 多级缓存(兜底防雪崩)

<dependency>
    <groupId>com.github.benmanes.caffeine</groupId>
    <artifactId>caffeine</artifactId>
    <version>3.1.8</version>
</dependency>
@Service
public class ProductServiceImpl implements ProductService {

    private static final String PRODUCT_KEY_PREFIX = "product:";
    // Caffeine本地缓存,最大容量1000,写入后5分钟过期
    private final LoadingCache<Long, ProductDO> localCache = Caffeine.newBuilder()
            .maximumSize(1000)
            .expireAfterWrite(5, TimeUnit.MINUTES)
            .build(this::loadFromRedis);

    @Autowired
    private RedisTemplate<String, Object> redisTemplate;
    @Autowired
    private ProductMapper productMapper;

    // 本地缓存未命中,从Redis加载
    private ProductDO loadFromRedis(Long productId) {
        String key = PRODUCT_KEY_PREFIX + productId;
        ProductDO cacheObj = (ProductDO) redisTemplate.opsForValue().get(key);
        if (cacheObj != null) {
            return cacheObj;
        }
        ProductDO dbProduct = productMapper.selectById(productId);
        if (dbProduct != null) {
            long baseSeconds = 30 * 60L;
            long randomOffset = new Random().nextLong(300);
            redisTemplate.opsForValue().set(key, dbProduct, baseSeconds + randomOffset, TimeUnit.SECONDS);
        }
        return dbProduct;
    }

    @Override
    public ProductDO getProductById(Long productId) {
        try {
            // 优先读取本地缓存
            return localCache.get(productId);
        } catch (Exception e) {
            // 本地缓存异常降级,直接查询数据库(生产可以替换成固定兜底返回)
            return productMapper.selectById(productId);
        }
    }
}

方案 3:Sentinel 限流降级(防止数据库被打崩)

核心思路:当数据库异常、QPS 超过阈值,直接降级返回默认兜底数据,不再访问 DB。

@SentinelResource(value = "getProduct", fallback = "getProductFallback")
public ProductDO getProductByIdSentinel(Long productId) {
    // 查询逻辑
    return localCache.get(productId);
}

// 降级兜底方法
public ProductDO getProductFallback(Long productId, Throwable ex) {
    ProductDO fallback = new ProductDO();
    fallback.setProductName("商品查询繁忙,请稍后重试");
    return fallback;
}

三者对比总结

问题

场景

核心原因

核心方案

缓存穿透

查询不存在的数据

请求绕过 Redis 直达 DB

布隆过滤器 / 缓存空值

缓存击穿

热点 key 过期

单个热点 key 失效,并发打 DB

Redisson 分布式锁 / 逻辑过期

缓存雪崩

大量 key 同时过期 / Redis 宕机

大批量缓存同时失效

TTL 随机值、多级缓存、集群 + 限流降级

生产落地注意事项

  1. RedisTemplate 序列化一定要用 Jackson,不要用 JDK 序列化,否则 key 乱码,线上踩坑很多。

  2. 分布式锁务必使用 Redisson,不要自己手写 setnx 锁,存在锁续期、死锁问题。

  3. 布隆过滤器适合读多写少场景,频繁删数据不要用。

  4. 逻辑过期方案牺牲一致性,商品库存、金额这类强一致性场景不能用。

  5. 雪崩防护一定是组合拳:Redis 集群 + TTL 随机 + 本地缓存 + 限流降级,单靠一个方案不安全。