侧边栏壁纸
  • 累计撰写 38 篇文章
  • 累计创建 8 个标签
  • 累计收到 3 条评论

目 录CONTENT

文章目录

Redis 缓存穿透、击穿与雪崩的解决方案 (基于 Spring Boot + JPA)

Administrator
2026-04-28 / 0 评论 / 0 点赞 / 28 阅读 / 0 字
温馨提示:
本文最后更新于2026-04-29,若内容或图片失效,请留言反馈。 部分素材来自网络,若不小心影响到您的利益,请联系我们删除。

声明:本文内容经过大量的 AI 生成和辅助。

项目代码,点我下载

在使用 Redis 保护底层数据库(如 MySQL)的高并发场景中,通常需要防范三个经典问题:缓存穿透、缓存击穿和缓存雪崩。下面简单梳理这三个问题的产生原因及应对思路,并提供一套整合的参考代码。

1. 核心问题与解决思路

缓存穿透

  • 描述:查询一条数据库和缓存中都不存在的数据。如果不做处理,这类请求每次都会打到数据库,恶意攻击者常利用此漏洞压垮系统。

  • 解决方案

    1. 布隆过滤器:作为第一道防线,将所有存在的记录 ID 放入布隆过滤器。查询前先判断,如果过滤器判断不存在,直接拦截。

    2. 缓存空值:作为兜底方案,如果请求穿过了布隆过滤器且数据库确实查不到数据,就在 Redis 中存入一个空字符串,并设置较短的过期时间。

缓存击穿

  • 描述:某个高频访问的热点数据在缓存中突然过期,导致瞬间产生的大量并发请求直接涌入数据库去重建缓存。

  • 解决方案:使用互斥锁(分布式锁)。当缓存失效时,强制所有的请求去抢锁,只允许拿到锁的一个线程去数据库查询并把数据写回缓存。其他没抢到锁的线程稍微等待后,重新尝试从缓存获取。

缓存雪崩

  • 描述:大量的缓存数据在同一时间点集中过期,或者 Redis 服务宕机,导致原本被缓存挡住的流量全部砸向数据库。

  • 解决方案:在给数据设置基础过期时间(TTL)时,额外加上一个随机的波动时间(例如 1 到 5 分钟),将过期时间打散,避免集体失效。


2. 核心工具类代码实现

下面是一个将上述三种防护策略封装在一起的工具类。业务代码只需调用该方法即可。

Java

package com.lj2508.redis_demo.Util;

import lombok.extern.slf4j.Slf4j;
import org.redisson.api.RBloomFilter;
import org.redisson.api.RLock;
import org.redisson.api.RedissonClient;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;

import java.util.concurrent.TimeUnit;
import java.util.concurrent.ThreadLocalRandom;
import java.util.function.Function;
import  tools.jackson.databind.ObjectMapper; // 假设使用 Jackson 序列化

@Slf4j
@Component
public class CacheClient {

    private final StringRedisTemplate stringRedisTemplate;
    private final RedissonClient redissonClient;
    private final ObjectMapper objectMapper;

    // 缓存空值的默认过期时间(短时间,防穿透)
    private static final Long CACHE_NULL_TTL = 2L;

    public CacheClient(StringRedisTemplate stringRedisTemplate, RedissonClient redissonClient, ObjectMapper objectMapper) {
        this.stringRedisTemplate = stringRedisTemplate;
        this.redissonClient = redissonClient;
        this.objectMapper = objectMapper;
    }

    /**
     * 终极防护版:解决穿透、击穿、雪崩
     *
     * @param keyPrefix  缓存 Key 前缀
     * @param id         查询的 ID
     * @param returnType 返回值类型
     * @param dbFallback 数据库查询回调函数 (当缓存未命中时执行)
     * @param time       缓存基础过期时间
     * @param unit       时间单位
     * @param bloomFilterName 布隆过滤器名称
     * @return 业务数据
     */
    public <R, ID> R queryWithCompleteProtection(
            String keyPrefix, ID id, Class<R> returnType,
            Function<ID, R> dbFallback, Long time, TimeUnit unit, String bloomFilterName) {

        String key = keyPrefix + id;

        // ================= 1. 缓存穿透防护:布隆过滤器拦截 =================
        RBloomFilter<String> bloomFilter = redissonClient.getBloomFilter(bloomFilterName);
        if (bloomFilter.isExists() && !bloomFilter.contains(key)) {
            log.warn("布隆过滤器拦截了非法请求: {}", key);
            return null;
        }

        try {
            // ================= 2. 尝试查询缓存 =================
            String json = stringRedisTemplate.opsForValue().get(key);

            // 命中有效数据
            if (json != null && !json.isEmpty()) {
                return objectMapper.readValue(json, returnType);
            }
            // 命中空字符串(缓存穿透的兜底防护:之前查过DB确实没有,缓存了空串)
            if (json != null && json.isEmpty()) {
                return null;
            }

            // ================= 3. 缓存击穿防护:获取分布式锁 =================
            String lockKey = "lock:" + key;
            RLock lock = redissonClient.getLock(lockKey);

            // 尝试加锁,最多等待50ms,上锁以后10秒自动解锁
            boolean isLock = lock.tryLock(50, 10000, TimeUnit.MILLISECONDS);

            if (!isLock) {
                // 加锁失败,说明其他线程正在查DB重建缓存。休眠50ms后重试本方法
                Thread.sleep(50);
                return queryWithCompleteProtection(keyPrefix, id, returnType, dbFallback, time, unit, bloomFilterName);
            }

            try {
                // 获取锁成功后,必须【双重检查】缓存,防止其他线程刚刚重建好
                json = stringRedisTemplate.opsForValue().get(key);
                if (json != null && !json.isEmpty()) {
                    return objectMapper.readValue(json, returnType);
                }
                if (json != null && json.isEmpty()) {
                    return null;
                }

                // ================= 4. 查询数据库 =================
                R result = dbFallback.apply(id);

                // ================= 5. 缓存雪崩 & 穿透收尾处理 =================
                if (result == null) {
                    // DB查不到:缓存空值(防穿透),设置极短TTL
                    stringRedisTemplate.opsForValue().set(key, "", CACHE_NULL_TTL, TimeUnit.MINUTES);
                } else {
                    // DB查到了:写入缓存。
                    // 防雪崩:在基础TTL上,加上 1~5 分钟的随机抖动时间
                    long randomJitter = ThreadLocalRandom.current().nextLong(1, 6);
                    long finalTtlMinutes = unit.toMinutes(time) + randomJitter;

                    stringRedisTemplate.opsForValue().set(
                            key,
                            objectMapper.writeValueAsString(result),
                            finalTtlMinutes,
                            TimeUnit.MINUTES
                    );
                }
                return result;

            } finally {
                // 释放锁
                if (lock.isHeldByCurrentThread()) {
                    lock.unlock();
                }
            }

        } catch (Exception e) {
            log.error("缓存处理异常", e);
            throw new RuntimeException("缓存处理异常", e);
        }
    }
}

3. 代码实现

3.1 调用实例 (ProductService)

在 Service 层中,结合 Spring Data JPA 进行调用,代码会保持非常简洁。

Java

package com.lj2508.redis_demo.service;

import com.lj2508.redis_demo.Util.CacheClient;
import com.lj2508.redis_demo.entity.Product;
import com.lj2508.redis_demo.repository.ProductRepository;
import jakarta.transaction.Transactional;
import lombok.extern.slf4j.Slf4j;
import org.redisson.api.RBloomFilter;
import org.redisson.api.RedissonClient;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

import java.util.concurrent.TimeUnit;

@Service
@Slf4j
public class ProductService {

    @Autowired
    private ProductRepository productRepository;

    @Autowired
    private CacheClient cacheClient;

    @Autowired
    private RedissonClient redissonClient;

    /**
     * 获取商品详情:整合 Redis 三大防护 + JPA
     */
    public Product getProductById(Long id) {
        return cacheClient.queryWithCompleteProtection(
                "cache:product:",           // 缓存前缀
                id,                         // 查询 ID
                Product.class,              // 目标类型
                // 使用 JPA 查询数据库 (核心:如果 DB 没数据,返回 null)
                productId -> productRepository.findById(productId).orElse(null),
                30L,                        // 基础过期时间
                TimeUnit.MINUTES,           // 时间单位
                "productBloomFilter"        // 布隆过滤器名称
        );
    }
    /**
     * 新增商品并同步布隆过滤器
     */
    @Transactional
    public Product createProduct(Product product) {
        // 1. 保存到数据库
        Product savedProduct = productRepository.save(product);

        // 2. 同步到布隆过滤器
        RBloomFilter<String> bloomFilter = redissonClient.getBloomFilter("productBloomFilter");
        bloomFilter.add("cache:product:" + savedProduct.getId());

        return savedProduct;
    }
}
3.2 初始预热与增量同步

项目启动预热:

Java

@Component
public class BloomFilterInitializer implements CommandLineRunner {
    @Autowired private ProductRepository productRepository;
    @Autowired private RedissonClient redissonClient;

    @Override
    public void run(String... args) {
        RBloomFilter<String> bloomFilter = redissonClient.getBloomFilter("productBloomFilter");
        bloomFilter.tryInit(100000L, 0.01);
        // 全量同步 ID
        productRepository.findAll().forEach(p -> bloomFilter.add("cache:product:" + p.getId()));
    }
}

新增数据同步:

Java

@Transactional
public Product saveProduct(Product product) {
    Product saved = productRepository.save(product);
    // 新增时必须填充布隆过滤器
    redissonClient.getBloomFilter("productBloomFilter").add("cache:product:" + saved.getId());
    return saved;
}

4. 维护与优化说明

  1. 关于删除操作: 标准布隆过滤器采用位映射,不支持物理删除(因为多个数据的 Hash 可能映射到同一个位,删除一个会影响其他数据)。

    • 为什么不用处理?:当数据库删除数据后,布隆过滤器依然会放行该请求,但请求进入缓存层会发现没有数据,进入数据库层也查不到,此时会缓存一个空对象。这仅仅增加了极少量的无效查询,不会对数据库造成压力。

  2. 关于凌晨重建: 如果数据库频繁删除数据,导致布隆过滤器的“假阳性(误判率)”逐渐增高,建议在凌晨流量低谷期,通过异步任务清空并重新从数据库加载 ID,以保证过滤器的准确性。

声明:本文内容经过大量的 AI 生成和辅助。仅为了给遇到此问题的朋友一个简单的思路,如果有更好的方法,欢迎大家评论区交流

0
  1. 支付宝打赏

    qrcode alipay
  2. 微信打赏

    qrcode weixin

评论区