Day33-数据层 × 中间件AI化篇:Redis缓存经典问题-击穿、穿透、雪崩的终极解决方案

面试很喜欢问一题:"给我说说缓存穿透、缓存击穿、缓存雪崩的区别和解决方案。"能完整讲清楚的,十个里不到3个。今天这篇文章,把这三个问题从根因到代码一趟讲透。

一、先搞清楚:三个问题本质不同

问题 触发条件 核心矛盾 形象比喻
缓存穿透 查询一个数据库里也没有的数据 Redis和MySQL都没有,每次请求都白查一遍DB 不停问一个不存在的人借钱
缓存击穿 一个热点key在缓存过期的瞬间,大量并发打到DB 单个热点key失效时的瞬时并发洪峰 热门餐厅突然关门,顾客全部涌向隔壁
缓存雪崩 大量key在同一时间段集中过期,或者Redis宕机 大面积缓存失效导致DB被压垮 整个批发市场的店铺同时歇业

理解区别后,我们一个一个拆解。


二、缓存穿透:布隆过滤器是最终的答案

2.1 问题场景

java 复制代码
// 恶意攻击者或爬虫不断请求不存在的商品ID
// 比如:productId = -1,productId = "invalid_xxx"
// 每次请求都会穿透Redis,直击数据库
public Product getProductById(String productId) {
    // 1. 查缓存
    String cacheKey = "product:" + productId;
    Product product = (Product) redisTemplate.opsForValue().get(cacheKey);
    if (product != null) return product;

    // 2. 查数据库------但数据库里也没有这条数据!
    product = productMapper.selectById(productId);
    if (product == null) {
        // 数据库也没有,但这次已经白查了DB
        return null;
    }

    // 3. 写缓存
    redisTemplate.opsForValue().set(cacheKey, product, 30, TimeUnit.MINUTES);
    return product;
}

每一次恶意请求都穿透到MySQL。1000QPS的无效查询就能让你的数据库跪下。

2.2 方案一:缓存空对象(临时凑合)

最简单粗暴的办法------数据库查不到,也在Redis里存一个"空值标记":

java 复制代码
public Product getProductById(String productId) {
    String cacheKey = "product:" + productId;

    Product product = (Product) redisTemplate.opsForValue().get(cacheKey);

    // 命中空值标记(说明之前查过,确实不存在)
    if (EMPTY_PLACEHOLDER.equals(product)) {
        return null;
    }

    if (product != null) return product;

    product = productMapper.selectById(productId);

    if (product == null) {
        // 缓存空对象,设置较短的过期时间(防止占用太多内存)
        redisTemplate.opsForValue().set(cacheKey, EMPTY_PLACEHOLDER, 5, TimeUnit.MINUTES);
        return null;
    }

    redisTemplate.opsForValue().set(cacheKey, product, 30, TimeUnit.MINUTES);
    return product;
}

问题也很明显:如果攻击者用大量不存在的随机key轰炸,Redis里会塞满垃圾空值。这是堵漏,不是根治。

2.3 方案二:布隆过滤器(正确解法)

布隆过滤器的核心思想:在查缓存之前,先用一个超轻量的数据结构判断key是否可能存在

原理说起来也简单:一个巨大的bit数组 + 多个哈希函数。插入时把几个哈希位置都置为1;查询时如果任何一个位置是0,那这个key一定不存在 。如果所有位置都是1,只能说可能存在------会有一定的误判率。

java 复制代码
// ========== 依赖版本 ==========
// Spring Boot 3.2 + Redisson 3.25.2(内置布隆过滤器)
// <dependency>
//     <groupId>org.redisson</groupId>
//     <artifactId>redisson-spring-boot-starter</artifactId>
//     <version>3.25.2</version>
// </dependency>

import org.redisson.api.RBloomFilter;
import org.redisson.api.RedissonClient;
import org.springframework.stereotype.Service;

@Service
public class ProductService {

    private final RedisTemplate<String, Object> redisTemplate;
    private final ProductMapper productMapper;
    private final RBloomFilter<String> bloomFilter;

    public ProductService(RedisTemplate<String, Object> redisTemplate,
                          ProductMapper productMapper,
                          RedissonClient redissonClient) {
        this.redisTemplate = redisTemplate;
        this.productMapper = productMapper;

        // 初始化布隆过滤器:预期插入100万条,误判率0.01
        this.bloomFilter = redissonClient.getBloomFilter("product:bloom");
        this.bloomFilter.tryInit(1_000_000L, 0.001);
    }

    // 启动时加载已有商品ID到布隆过滤器
    @PostConstruct
    public void init() {
        List<String> allIds = productMapper.selectAllProductIds();
        allIds.forEach(bloomFilter::add);
    }

    // 新增商品时同步更新布隆过滤器
    public void addProduct(Product product) {
        productMapper.insert(product);
        String id = String.valueOf(product.getId());
        bloomFilter.add(id);
        redisTemplate.opsForValue().set("product:" + id, product, 30, TimeUnit.MINUTES);
    }

    // 核心查询方法:布隆过滤器 + Redis + MySQL 三层防护
    public Product getProductById(String productId) {
        // 第一层:布隆过滤器,直接拦截不存在的数据
        // contains() 返回 false → 这个key百分百不存在,不用查Redis,更不用查DB
        if (!bloomFilter.contains(productId)) {
            return null; // 秒级返回,保护Redis和MySQL
        }

        String cacheKey = "product:" + productId;

        // 第二层:Redis缓存
        Product product = (Product) redisTemplate.opsForValue().get(cacheKey);
        if (product != null) return product;

        // 第三层:数据库(只有布隆过滤器认为"可能存在"的key才会走到这里)
        product = productMapper.selectById(productId);
        if (product != null) {
            redisTemplate.opsForValue().set(cacheKey, product, 30, TimeUnit.MINUTES);
        }
        return product;
    }
}

选型提醒:Redisson内置的布隆过滤器是单机内存方案。如果是分布式环境(多个服务节点),可以考虑Redis Bitmap自建、或者Guava BloomFilter + 容器启动时全量加载。误判率设为0.1%~1%之间是比较合适的。


三、缓存击穿:互斥锁 vs 逻辑过期,实战怎么选

3.1 问题场景

秒杀商品的缓存只有30秒。31秒时,500个线程同时发现缓存过期,它们不约而同地去查MySQL重建缓存。500个并发查询,DB卒。

3.2 方案一:互斥锁(简单可靠)

核心思路:缓存未命中时,只让一个线程去查DB,其他线程自旋等待

java 复制代码
import java.util.concurrent.TimeUnit;

@Service
public class HotProductService {

    private final StringRedisTemplate redisTemplate;
    private final ProductMapper productMapper;

    public HotProductService(StringRedisTemplate redisTemplate, ProductMapper productMapper) {
        this.redisTemplate = redisTemplate;
        this.productMapper = productMapper;
    }

    public Product getProductWithMutex(String productId) {
        String cacheKey = "hot:product:" + productId;
        String lockKey = "lock:product:" + productId;

        // 1. 查缓存
        String cachedJson = redisTemplate.opsForValue().get(cacheKey);
        if (cachedJson != null) {
            return JSON.parseObject(cachedJson, Product.class);
        }

        // 2. 缓存未命中,尝试获取互斥锁
        try {
            // SETNX + EXPIRE 原子操作,锁过期时间5秒(防止死锁)
            Boolean locked = redisTemplate.opsForValue()
                    .setIfAbsent(lockKey, "1", 5, TimeUnit.SECONDS);

            if (Boolean.TRUE.equals(locked)) {
                // 获得锁------去查数据库重建缓存
                try {
                    // 双重检查:锁内再查一次缓存(防止重复建)
                    cachedJson = redisTemplate.opsForValue().get(cacheKey);
                    if (cachedJson != null) {
                        return JSON.parseObject(cachedJson, Product.class);
                    }

                    Product product = productMapper.selectById(productId);
                    if (product != null) {
                        String json = JSON.toJSONString(product);
                        // 加一个随机偏移量,避免雪崩
                        int ttl = 60 + ThreadLocalRandom.current().nextInt(10);
                        redisTemplate.opsForValue().set(cacheKey, json, ttl, TimeUnit.SECONDS);
                    }
                    return product;

                } finally {
                    // 释放锁------但要确保是自己加的锁(生产环境建议用Lua脚本保证原子性)
                    redisTemplate.delete(lockKey);
                }

            } else {
                // 没获得锁------自旋等待
                Thread.sleep(50);
                return getProductWithMutex(productId); // 递归重试
            }

        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            return null;
        }
    }
}

互斥锁方案的核心原则:

  • 锁超时时间要短(3~5秒足够),防止死锁
  • 获得锁后要双重检查缓存,避免重复查库
  • 生产环境建议用Lua脚本保证「判断锁归属 + 删除锁」的原子性

3.3 方案二:逻辑过期(适合极致性能场景)

互斥锁有个问题------所有没抢到锁的线程都得自旋等待。如果对延迟极度敏感,可以用「逻辑过期」:

核心思想:缓存永不过期(物理上不设TTL),由后台线程异步刷新数据

java 复制代码
import com.fasterxml.jackson.databind.ObjectMapper;
import java.time.LocalDateTime;
import java.util.concurrent.Executors;
import java.util.concurrent.ThreadPoolExecutor;

@Data
@AllArgsConstructor
@NoArgsConstructor
public class CacheData<T> {
    private T data;                     // 实际数据
    private LocalDateTime expireTime;   // 逻辑过期时间
}

@Service
public class LogicalExpireService {

    private final StringRedisTemplate redisTemplate;
    private final ProductMapper productMapper;
    private final ObjectMapper objectMapper;

    // 线程池用于异步重建缓存
    private final ThreadPoolExecutor rebuildPool =
            (ThreadPoolExecutor) Executors.newFixedThreadPool(4);

    public LogicalExpireService(StringRedisTemplate redisTemplate,
                                ProductMapper productMapper,
                                ObjectMapper objectMapper) {
        this.redisTemplate = redisTemplate;
        this.productMapper = productMapper;
        this.objectMapper = objectMapper;
    }

    public Product getProductLogicalExpire(String productId) {
        String cacheKey = "hot:product:" + productId;
        String lockKey = "lock:product:" + productId;

        String cachedJson = redisTemplate.opsForValue().get(cacheKey);
        // 缓存完全不存在------说明之前从未加载过,同步查库
        if (cachedJson == null) {
            return loadAndCache(cacheKey, productId);
        }

        CacheData<Product> cacheData = JSON.parseObject(cachedJson,
                new TypeReference<CacheData<Product>>() {});

        Product product = cacheData.getData();
        LocalDateTime expireTime = cacheData.getExpireTime();

        // 判断是否逻辑过期
        if (expireTime.isAfter(LocalDateTime.now())) {
            // 未过期,直接返回
            return product;
        }

        // 逻辑过期了,但数据还能用------先返回旧数据
        // 同时异步重建缓存(抢锁来决定谁去重建)
        Boolean locked = redisTemplate.opsForValue()
                .setIfAbsent(lockKey, "1", 5, TimeUnit.SECONDS);

        if (Boolean.TRUE.equals(locked)) {
            rebuildPool.execute(() -> {
                try {
                    // 重建缓存,设置新的逻辑过期时间
                    Product freshProduct = productMapper.selectById(productId);
                    CacheData<Product> freshData = new CacheData<>(
                            freshProduct,
                            LocalDateTime.now().plusMinutes(30) // 逻辑过期30分钟
                    );
                    String json = JSON.toJSONString(freshData);
                    redisTemplate.opsForValue().set(cacheKey, json); // 注意:不设TTL
                } finally {
                    redisTemplate.delete(lockKey);
                }
            });
        }

        // 不管重建有没有完成,先返回旧数据(保证用户体验)
        return product;
    }

    private Product loadAndCache(String cacheKey, String productId) {
        Product product = productMapper.selectById(productId);
        if (product != null) {
            CacheData<Product> cacheData = new CacheData<>(
                    product,
                    LocalDateTime.now().plusMinutes(30)
            );
            redisTemplate.opsForValue().set(cacheKey, JSON.toJSONString(cacheData));
        }
        return product;
    }
}

逻辑过期的核心优势:请求进来时永远不需要等待------数据存在就返回,哪怕已经逻辑过期了也是返回旧数据,由后台线程异步更新。这对高并发场景(双11秒杀、排行榜)是降维打击。

对比维度 互斥锁 逻辑过期
一致性 强一致性(拿到的一定是最新数据) 最终一致性(可能有短暂旧数据)
性能 抢不到锁的线程会等待 所有线程无等待
复杂度 简单 中等(需要额外的线程池管理)
适用场景 一般热点数据 极热点数据,允许短暂不一致

四、缓存雪崩:多级缓存 + 随机化过期 + 熔断降级

4.1 问题场景

凌晨0点,运维批量导入了10万条商品缓存,TTL全部设为1小时。凌晨1点01分,这些缓存全部同时过期,瞬时流量洪峰全部砸向MySQL。这就是雪崩。

4.2 解决方案详解

① 随机化过期时间(基础防御)

java 复制代码
// 别这么写------所有缓存的TTL完全一样
redisTemplate.opsForValue().set(key, value, 3600, TimeUnit.SECONDS);

// 应该加随机偏移量------让缓存散列过期
int baseTtl = 3600;
int randomOffset = ThreadLocalRandom.current().nextInt(300); // 0~300秒随机偏移
redisTemplate.opsForValue().set(key, value, baseTtl + randomOffset, TimeUnit.SECONDS);

② 多级缓存架构(纵深防御)

java 复制代码
/**
 * 二级缓存:Caffeine(JVM本地) + Redis(分布式)
 * 查询链路:Caffeine → Redis → MySQL
 *
 * 依赖版本:
 * Spring Boot 3.2 + Spring Cache + Caffeine
 * Caffeine 3.1.8
 */
@Configuration
@EnableCaching
public class MultiLevelCacheConfig {

    // 一级缓存:Caffeine(JVM本地,LRU + 时间窗口,10秒过期)
    @Bean
    public CacheManager caffeineCacheManager() {
        CaffeineCacheManager cacheManager = new CaffeineCacheManager();
        cacheManager.setCaffeine(Caffeine.newBuilder()
                .initialCapacity(100)
                .maximumSize(10_000)           // 最多1万条
                .expireAfterWrite(10, TimeUnit.SECONDS) // 本地10秒过期
                .recordStats());               // 开启统计
        return cacheManager;
    }

    // 二级缓存:Redis(分布式,30分钟,但加随机偏移)
    @Bean
    public CacheManager redisCacheManager(RedisConnectionFactory factory) {
        RedisCacheConfiguration config = RedisCacheConfiguration.defaultCacheConfig()
                .entryTtl(Duration.ofMinutes(30))
                .serializeKeysWith(RedisSerializationContext.SerializationPair
                        .fromSerializer(new StringRedisSerializer()))
                .serializeValuesWith(RedisSerializationContext.SerializationPair
                        .fromSerializer(new GenericJackson2JsonRedisSerializer()));
        return RedisCacheManager.builder(factory)
                .cacheDefaults(config)
                .build();
    }
}

@Service
public class MultiLevelProductService {

    // 一级缓存:Caffeine本地(@Cacheable打在方法上即可)
    @Cacheable(value = "productLocal", key = "#productId",
               cacheManager = "caffeineCacheManager")
    @Cacheable(value = "productRedis", key = "#productId",
               cacheManager = "redisCacheManager")
    public Product getProductMultiLevel(String productId) {
        // 只有两级缓存都miss时才查数据库
        return productMapper.selectById(productId);
    }
}

多级缓存的好处:即使Redis全部挂了,Caffeine本地缓存还能扛10秒。这10秒足够运维恢复Redis,或者触发熔断机制。

③ Redis高可用 + 熔断降级(最后防线)

java 复制代码
/**
 * 使用Sentinel实现Redis的熔断降级
 * 当Redis不可用时,快速失败返回兜底数据,避免雪崩
 *
 * alibaba sentinel 1.8.6
 */
import com.alibaba.csp.sentinel.annotation.SentinelResource;
import com.alibaba.csp.sentinel.slots.block.BlockException;

@Service
public class ResilientProductService {

    private final RedisTemplate<String, Object> redisTemplate;
    private final ProductMapper productMapper;

    public ResilientProductService(RedisTemplate<String, Object> redisTemplate,
                                   ProductMapper productMapper) {
        this.redisTemplate = redisTemplate;
        this.productMapper = productMapper;
    }

    @SentinelResource(value = "getProduct",
            fallback = "getProductFallback",   // 异常降级
            blockHandler = "getProductBlockHandler") // 限流降级
    public Product getProduct(String productId) {
        String cacheKey = "product:" + productId;
        Product product = (Product) redisTemplate.opsForValue().get(cacheKey);
        if (product != null) return product;

        product = productMapper.selectById(productId);
        if (product != null) {
            int ttl = 1800 + ThreadLocalRandom.current().nextInt(300);
            redisTemplate.opsForValue().set(cacheKey, product, ttl, TimeUnit.SECONDS);
        }
        return product;
    }

    // 异常降级方法:Redis挂了或DB挂了时返回兜底数据
    public Product getProductFallback(String productId, Throwable e) {
        log.error("query product failed, id={}, use fallback", productId, e);
        // 返回一个兜底对象(前端可以识别并展示"数据暂不可用")
        return Product.fallback(productId);
    }

    // 限流降级方法
    public Product getProductBlockHandler(String productId, BlockException e) {
        log.warn("query product blocked, id={}", productId);
        return Product.fallback(productId);
    }
}

五、三条实战建议

第一条:不要一上来就上全套方案。 你的项目大概率没有10万QPS,一个"布隆过滤器 + 互斥锁 + 随机过期TTL"的组合就能解决95%的问题。多级缓存和逻辑过期是给真正的高并发场景准备的,先评估你的真实流量再决定。

第二条:上线前做一次缓存击穿演练。 挑一个业务低峰期,手动删除某个热点key的缓存,观察:

  • 数据库QPS是否激增?
  • 互斥锁机制是否正常工作?
  • 如果有多个实例,锁是否是分布式的?

这些在本地环境测不出来,只能到生产环境的低峰时段去验证。

第三条:监控比方案更重要。 在Prometheus/Grafana里建三个大盘:

  • cache_hit_rate(缓存命中率------低于80%要预警)
  • cache_penetration_count(穿透次数------突然暴涨说明有攻击)
  • db_active_connections(数据库活跃连接数------这是最后的防线)

没有监控的缓存方案,就是盲人骑瞎马。


下篇预告 :《Redis分布式锁:Redisson源码剖析与正确使用姿势》------你以为 lock.lock() 就万事大吉了?看门狗续期、红锁争议、与ZooKeeper锁的终极对比,下篇讲。

相关推荐
会编程的土豆1 小时前
功能详解 01 · 用户注册:从表单到数据库一行
开发语言·数据库·后端·mysql·golang
夏贰四1 小时前
数据资产平台如何落地企业数据分级合规管控?数据资产平台怎样满足行业数据监管审查要求?
大数据·数据库·人工智能
吴声子夜歌1 小时前
MongoDB 4.2——聚合框架
数据库·mongodb
墨神谕1 小时前
认识一下Unicode(统一码)
android·java·数据库
xywww1682 小时前
Claude Opus 5 API 接入实战:国内项目上线前的网络、Key、限流和排错清单
大数据·linux·网络·数据库·云计算·aws
yuezhilangniao2 小时前
某专科医院老数据库迁移实战记录-Oracle10g sqlserver2008等
数据库
白猫不黑2 小时前
SQL注入实战:手工注入全流程详解
网络·数据库·sql·web安全·网络安全·信息安全
咩咩啃树皮11 小时前
第43篇:Vue3计算属性(computed)完全精讲——缓存机制、依赖计算、业务最优解
前端·vue.js·缓存
kirs_ur12 小时前
ECC & LDPC — SSD 的数据卫士
服务器·数据库·性能优化