介绍一下Redis的缓存穿透击穿雪崩

缓存穿透 🔍

定义:请求的数据既不在 Redis 缓存,也不在数据库,导致每次请求都穿透缓存直接打到数据库,数据库压力骤增。

产生原因:

  • 恶意攻击:黑客构造大量不存在的 ID 请求(如 ID=-1、ID=999999)
  • 业务 bug:参数校验不严格,传入非法值
  • 数据未同步:数据库已删除但缓存未清理

核心解决方案:

  1. 布隆过滤器(首选):将所有存在的 key 预存入布隆过滤器,请求先过过滤器,不存在直接拦截
  2. 空值缓存:对数据库查询为空的 key,缓存一个空值并设置短过期时间(如 5 分钟)
  3. 接口层校验:对入参做合法性校验(如 ID>0、格式校验)

缓存击穿 💥

定义:单个热点 key 突然过期,瞬间有大量并发请求打到数据库,数据库瞬间被打垮。

产生原因:

  • 秒杀商品、热门文章、爆款视频等超高访问量的 key
  • 热点 key 设置了过期时间,到期时恰好有大量请求

核心解决方案:

  1. 互斥锁(保证一致性):使用 Redis 的 SETNX 加锁,只有一个请求去查数据库并更新缓存,其他请求等待重试
  2. 逻辑过期(高性能):热点 key 不设置物理过期时间,在 value 中存过期时间,后台异步线程更新缓存
  3. 热点 key 永不过期:对于极端热点 key,直接不设置过期时间,后台定时更新

缓存雪崩 🌋

定义:大量 key 同时过期,或者Redis 集群整体宕机,导致所有请求都打到数据库,数据库雪崩式崩溃。

产生原因:

  • 批量导入数据时,给所有 key 设置了相同的过期时间
  • Redis 主从切换、集群故障导致服务不可用
  • 缓存服务器资源耗尽宕机

核心解决方案:

  1. 过期时间加随机值:给每个 key 的过期时间加上一个随机偏移量(如 1-5 分钟),避免同时过期
  2. Redis 高可用集群:搭建主从 + 哨兵、Redis Cluster 集群,保证服务可用性
  3. 服务熔断与降级:使用 Hystrix/Sentinel 做熔断,数据库压力过大时暂时关闭非核心接口
  4. 多级缓存:本地缓存(Caffeine)+ Redis 缓存 + 数据库,层层拦截请求

三大问题核心对比表 📊

问题类型 核心特征 典型触发场景 核心解决方案
缓存穿透 不存在的数据 恶意攻击、参数非法 布隆过滤器、空值缓存
缓存击穿 单个热点 key过期 秒杀、爆款商品、热搜 互斥锁、逻辑过期
缓存雪崩 大量 key同时过期 / Redis 宕机 批量导入数据、集群故障 随机过期时间、高可用集群、熔断降级

缓存读写流程与异常路径图 🗺️

image

面试加分项 ✨

  1. 实际项目中我遇到过缓存雪崩问题:当时做 618 活动页时,批量导入了 12 万条商品数据,都设置了 1 小时过期时间,导致整点时数据库 CPU 直接打满到 100%。后来给每个 key 的过期时间加上了 0-300 秒的随机偏移量,问题彻底解决。
  2. 对于缓存击穿,我更推荐使用逻辑过期方案,因为互斥锁在高并发下会有大量线程阻塞,而逻辑过期只需要一个后台线程更新,性能更好,适合秒杀这种对响应时间要求极高的场景。
  3. 布隆过滤器存在误判率(约 0.1%-1%),所以不能完全替代空值缓存,两者结合使用效果最佳。

核心代码实现(技术亮点版)💻

1 缓存穿透:布隆过滤器(Guava 实现)

import com.google.common.hash.BloomFilter;
import com.google.common.hash.Funnels;
import org.springframework.stereotype.Component;
import javax.annotation.PostConstruct;
import java.nio.charset.StandardCharsets;
import java.util.List;

@Component
public class ProductBloomFilter {
    // 预期插入数据量100万,误判率0.01%
    private static final int EXPECTED_INSERTIONS = 1000000;
    private static final double FPP = 0.0001;
    
    private BloomFilter bloomFilter;
    
    @PostConstruct
    public void initBloomFilter() {
        // 初始化布隆过滤器
        bloomFilter = BloomFilter.create(
            Funnels.stringFunnel(StandardCharsets.UTF_8),
            EXPECTED_INSERTIONS,
            FPP
        );
        
        // 项目启动时预加载所有存在的商品ID到布隆过滤器
        List allProductIds = productMapper.selectAllProductIds();
        for (String productId : allProductIds) {
            bloomFilter.put(productId);
        }
    }
    
    // 判断商品ID是否可能存在
    public boolean mightContain(String productId) {
        return bloomFilter.mightContain(productId);
    }
}

技术亮点

  • 精确控制误判率和内存占用(100 万数据 + 0.01% 误判率仅需约 1.7MB 内存)
  • 项目启动时预加载,避免运行时动态构建的性能损耗

2 缓存击穿:互斥锁方案(Redis SETNX)

import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;
import java.util.concurrent.TimeUnit;

@Service
public class ProductService {
    @Autowired
    private StringRedisTemplate redisTemplate;
    @Autowired
    private ProductMapper productMapper;
    
    private static final String CACHE_KEY_PREFIX = "product:";
    private static final String LOCK_KEY_PREFIX = "lock:product:";
    private static final int LOCK_EXPIRE_TIME = 10; // 锁过期时间10秒
    private static final int CACHE_EXPIRE_TIME = 30; // 缓存过期时间30分钟
    
    public Product getProductById(String productId) {
        String cacheKey = CACHE_KEY_PREFIX + productId;
        
        // 1. 先查缓存
        String productJson = redisTemplate.opsForValue().get(cacheKey);
        if (productJson != null) {
            return JSON.parseObject(productJson, Product.class);
        }
        
        // 2. 缓存未命中,加互斥锁
        String lockKey = LOCK_KEY_PREFIX + productId;
        boolean lockAcquired = redisTemplate.opsForValue()
            .setIfAbsent(lockKey, "1", LOCK_EXPIRE_TIME, TimeUnit.SECONDS);
        
        if (lockAcquired) {
            try {
                // 3. 拿到锁后,再次查询缓存(双重检查)
                productJson = redisTemplate.opsForValue().get(cacheKey);
                if (productJson != null) {
                    return JSON.parseObject(productJson, Product.class);
                }
                
                // 4. 查询数据库
                Product product = productMapper.selectById(productId);
                if (product == null) {
                    // 空值缓存,防止缓存穿透
                    redisTemplate.opsForValue()
                        .set(cacheKey, "", 5, TimeUnit.MINUTES);
                    return null;
                }
                
                // 5. 更新缓存
                redisTemplate.opsForValue()
                    .set(cacheKey, JSON.toJSONString(product), 
                         CACHE_EXPIRE_TIME, TimeUnit.MINUTES);
                
                return product;
            } finally {
                // 6. 释放锁(必须在finally中执行)
                redisTemplate.delete(lockKey);
            }
        } else {
            // 7. 没拿到锁,等待50ms后重试
            try {
                Thread.sleep(50);
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
            return getProductById(productId); // 递归重试
        }
    }
}

技术亮点

  • 双重检查锁机制,避免重复查询数据库
  • 锁设置过期时间,防止死锁
  • finally 块保证锁一定会释放
  • 空值缓存与互斥锁结合,同时解决穿透和击穿问题

3 缓存击穿:逻辑过期方案(高性能版)

import com.alibaba.fastjson.JSON;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;

@Service
public class HotProductService {
    @Autowired
    private StringRedisTemplate redisTemplate;
    @Autowired
    private ProductMapper productMapper;
    
    private static final String CACHE_KEY_PREFIX = "hot:product:";
    private static final int LOGIC_EXPIRE_TIME = 30 * 60; // 逻辑过期时间30分钟
    
    // 线程池,用于异步更新缓存
    private static final ExecutorService CACHE_REFRESH_EXECUTOR = 
        Executors.newFixedThreadPool(10);
    
    // 缓存数据对象,包含逻辑过期时间
    private static class CacheData {
        private T data;
        private Long expireTime; // 逻辑过期时间戳
        
        public CacheData(T data, Long expireTime) {
            this.data = data;
            this.expireTime = expireTime;
        }
        
        // 判断是否过期
        public boolean isExpired() {
            return System.currentTimeMillis() > expireTime;
        }
    }
    
    public Product getHotProductById(String productId) {
        String cacheKey = CACHE_KEY_PREFIX + productId;
        
        // 1. 查询缓存
        String cacheJson = redisTemplate.opsForValue().get(cacheKey);
        if (cacheJson == null) {
            return null; // 热点key提前预热,理论上不会走到这里
        }
        
        CacheData cacheData = JSON.parseObject(cacheJson, 
            new TypeReference>() {});
        Product product = cacheData.getData();
        
        // 2. 判断是否逻辑过期
        if (!cacheData.isExpired()) {
            return product; // 未过期,直接返回
        }
        
        // 3. 已过期,异步更新缓存
        String lockKey = "lock:hot:product:" + productId;
        boolean lockAcquired = redisTemplate.opsForValue()
            .setIfAbsent(lockKey, "1", 10, TimeUnit.SECONDS);
        
        if (lockAcquired) {
            // 提交异步任务更新缓存
            CACHE_REFRESH_EXECUTOR.submit(() -> {
                try {
                    // 查询数据库
                    Product newProduct = productMapper.selectById(productId);
                    // 更新缓存,设置新的逻辑过期时间
                    CacheData newCacheData = new CacheData<>(
                        newProduct,
                        System.currentTimeMillis() + LOGIC_EXPIRE_TIME * 1000L
                    );
                    redisTemplate.opsForValue().set(cacheKey, 
                        JSON.toJSONString(newCacheData));
                } finally {
                    redisTemplate.delete(lockKey);
                }
            });
        }
        
        // 4. 无论是否拿到锁,都返回旧数据
        return product;
    }
}

技术亮点

  • 无阻塞:所有请求都能立即返回数据,不会有线程等待
  • 异步更新:使用独立线程池处理缓存更新,不影响主线程
  • 热点 key 提前预热:项目启动时将热点数据加载到缓存,避免冷启动问题

4 缓存雪崩:随机过期时间设置

// 给每个key的过期时间加上0-300秒的随机偏移量
int baseExpireTime = 30 * 60; // 基础过期时间30分钟
int randomOffset = new Random().nextInt(300); // 随机偏移量0-5分钟
int finalExpireTime = baseExpireTime + randomOffset;

redisTemplate.opsForValue().set(
    cacheKey,
    JSON.toJSONString(product),
    finalExpireTime,
    TimeUnit.SECONDS
);

技术难点与解决方案汇总 🎯

技术难点 问题本质 解决方案 最佳实践
布隆过滤器误判 哈希冲突导致不存在的 key 被误判为存在 1. 降低误判率(增加哈希函数数量、扩大位图大小)2. 结合空值缓存兜底 误判率设置为 0.01%-0.1%,定期重建布隆过滤器
互斥锁死锁 服务宕机导致锁未释放 1. 给锁设置过期时间2. 使用 Redis Lua 脚本实现原子性锁释放 锁过期时间设置为数据库查询时间的 2-3 倍
互斥锁惊群效应 大量线程同时等待锁释放,瞬间并发请求数据库 1. 增加重试间隔时间2. 使用分段锁3. 优先使用逻辑过期方案 重试间隔设置为 50-100ms,避免频繁重试
逻辑过期缓存不一致 异步更新期间,用户可能读到旧数据 1. 缩短逻辑过期时间2. 关键业务使用互斥锁保证强一致性 非核心业务(如商品详情、文章)用逻辑过期,核心业务(如订单)用互斥锁
Redis 集群宕机雪崩 缓存层完全失效,所有请求打到数据库 1. 搭建 Redis Cluster 集群 + 哨兵2. 本地缓存兜底3. 服务熔断降级 本地缓存使用 Caffeine,设置最大容量和过期时间
批量导入数据雪崩 大量 key 同时过期 1. 过期时间加随机偏移量2. 分批次导入数据3. 低峰期导入数据 随机偏移量设置为基础过期时间的 10%-20%
缓存预热冷启动 系统刚启动时缓存为空,大量请求打到数据库 1. 项目启动时预加载热点数据2. 流量逐步切入3. 使用降级机制 提前统计热点数据,启动时异步加载到缓存

真实面试模拟

真实面试模拟

面试官 👨‍💻:

“同学,前面基础知识聊得不错。现在来个场景设计题:介绍一下 Redis 的缓存穿透、击穿、雪崩,最好能说清楚它们的区别和解决方案。你可以开始了。”

候选人 🧑‍💻:

“好的面试官。这三个问题本质上都是缓存没挡住流量,导致大量请求砸到数据库,但触发原因不一样,解决方案侧重点也不同。

我先快速区分一下:

  • 🔍 穿透:查的是数据库里根本没有的数据,缓存自然也没法存,请求直接‘穿透’到底。
  • 💥 击穿:某个热点 key 刚好过期了,那一瞬间大量请求同时去查库重建缓存,把数据库‘击穿’。
  • ❄️ 雪崩大量 key 在同一时间段内过期,或者 Redis 直接挂了,请求像雪崩一样全压到数据库上。

我可以结合画图,逐个说一下原理和落地方法吗?”

面试官 👨‍💻:

“可以,你边画边讲。”

候选人 🧑‍💻:

“好的,先说 🔍 缓存穿透

比如有人恶意用 id = -1 不断请求,这个数据库里根本没有,缓存里也不会有,每次请求都打到库上。

image

解决思路是两个:

  1. 布隆过滤器:把所有合法 id 提前存进去。请求来的时候先过一遍过滤器,不存在的直接挡掉,相当于门禁 🚪。
  2. 缓存空对象:就算数据库返回 null,也往 Redis 里存一个短期的‘空标记’,比如 "",过期时间 30 秒。下次同样请求就能在缓存层拦住了。

生产上,我一般布隆过滤器前置拦截 + 空值兜底,双保险。”

面试官 👨‍💻:

“嗯,逻辑清晰。那击穿呢?”

候选人 🧑‍💻:

“再说 💥 缓存击穿

最典型的是秒杀商品,key 为 hot_item:123。一旦这个热点 key 失效,刚好又赶上高并发,瞬间所有请求都奔向数据库去抢着重建缓存。

image

应对方法就一个核心:避免并发重建。

  1. 互斥锁(Mutex):用 Redis 的 SETNX 抢一个轻量级锁,只允许一个线程去查库,其他就 sleep 几十毫秒再重试拿缓存。
  2. 逻辑过期:热点 key 不设 TTL,在 value 里存逻辑过期时间。发现数据逻辑过期后,先返回旧值,后台异步开线程去更新。这是高性能场景的王道方案 🤖。

伪代码大概这样:

public String getHotData(String key) {
    String value = redis.get(key);
    if (value == null) {
        if (redis.setnx(lockKey, "1", 10, TimeUnit.SECONDS)) {
            try {
                value = db.query();
                redis.set(key, value, 3600);
            } finally {
                redis.del(lockKey);
            }
        } else {
            Thread.sleep(50);
            return getHotData(key);
        }
    }
    return value;
}

面试官 👨‍💻:

“那如果用逻辑过期,value 里怎么存?”

候选人 🧑‍💻:

“我会包装一层对象,比如 HotDataWrapper,里面两个字段:dataexpireAt(逻辑过期时间戳)。取出来先判断 expireAt < now,如果过期了,先用旧数据返回,同时开一个线程去抢锁重建。”

面试官 👨‍💻:

“可以,思路到位。最后说说雪崩吧。”

候选人 🧑‍💻:

“最后是 ❄️ 缓存雪崩,这个破坏力最大。

一般是两种场景叠加:大量 key 在同一时间过期,或者 Redis 突然挂掉。

image

解决是多管齐下:

  1. 过期时间加随机盐:在基础 TTL 上随机加个 1~5 分钟,让 key 分散失效。这是最简单也最有效的 😊。
  2. 高可用架构:Redis 主从 + 哨兵/集群,保证缓存层别单点挂。
  3. 熔断降级:用 Sentinel 或 Hystrix,当数据库负载过高,就直接返回降级内容(本地缓存、默认值、‘系统繁忙’),并配合限流。
  4. 多级缓存:本地 Caffeine + Redis 二级缓存,Redis 挂了还有本地兜底。”

面试官 👨‍💻:

“总结对比一下?”

候选人 🧑‍💻:

“好的,我用一个表格串一下:

类型 诱因 核心思路 一句话
🔍 穿透 查不存在的数据 布隆过滤器 / 空对象 防不存在
💥 击穿 热点 key 过期 互斥锁 / 逻辑过期 防抢重建
❄️ 雪崩 大量 key 同时过期或宕机 过期随机化 / 高可用 / 降级 防同时挂

面试官 👨‍💻:

“不错,整体框架很清晰,方案能落地。我追问一个点:你用互斥锁的时候,如果业务是集群部署,SETNX 锁要是业务执行超时或者节点宕机了,会不会死锁?怎么解决?”

候选人 🧑‍💻:

“确实有死锁风险。我会给锁加一个过期时间,并且用 Redisson 的看门狗机制自动续期——只要线程还活着,锁就不会超时释放;如果节点真挂了,锁也会在到期后自动放开。不会出现死锁解不掉的情况。”

面试官 👨‍💻:

“嗯,方案都说得挺清楚。那你能不能贴几段核心代码,展示下技术亮点?然后把这一整套场景中的技术难点和解决方案整理一下?”

候选人 🧑‍💻:

“没问题,面试官。我按场景来贴代码,顺便把难点和亮点都说明白。😄”

🔍 缓存穿透 — 核心代码

技术亮点:布隆过滤器 + 空值缓存双保险,兼顾性能和防穿透。

// 1. 布隆过滤器初始化(基于Redisson,启动时执行)
RBloomFilter bloomFilter = redissonClient.getBloomFilter("userIdFilter");
// 预计插入100万,误判率0.01%
bloomFilter.tryInit(1_000_000L, 0.0001);

// 全量ID预热,可从DB分批加载
List allUserIds = userMapper.getAllIds();
allUserIds.forEach(id -> bloomFilter.add("user:" + id));

// 2. 查询逻辑
public User getUserById(Long id) {
    String key = "user:" + id;
    
    // 布隆过滤器拦截
    if (!bloomFilter.contains(key)) {
        return null; // 直接返回,不查库
    }
    
    // 查缓存
    String cacheValue = redisTemplate.opsForValue().get(key);
    if (cacheValue != null) {
        // 判断是否为空对象占位符
        if ("NULL_PLACEHOLDER".equals(cacheValue)) {
            return null;
        }
        return JSON.parseObject(cacheValue, User.class);
    }
    
    // 查数据库
    User user = userMapper.selectById(id);
    if (user == null) {
        // 缓存空对象,过期时间30秒,防止占用空间
        redisTemplate.opsForValue().set(key, "NULL_PLACEHOLDER", 30, TimeUnit.SECONDS);
    } else {
        redisTemplate.opsForValue().set(key, JSON.toJSONString(user), 1, TimeUnit.HOURS);
    }
    return user;
}

💥 缓存击穿 — 核心代码

技术亮点:使用 Redisson 分布式锁 + 看门狗自动续期,避免死锁,保障高并发。

public Product getHotProduct(Long id) {
    String key = "hot_product:" + id;
    String cacheData = redisTemplate.opsForValue().get(key);
    if (cacheData != null) {
        return JSON.parseObject(cacheData, Product.class);
    }
    
    // 分布式锁key
    String lockKey = "lock:product:" + id;
    RLock lock = redissonClient.getLock(lockKey);
    try {
        // 尝试加锁,最多等待10秒,锁超时时间(看门狗会续期,这里设为30秒兜底)
        if (lock.tryLock(10, 30, TimeUnit.SECONDS)) {
            // 双重检查
            cacheData = redisTemplate.opsForValue().get(key);
            if (cacheData != null) {
                return JSON.parseObject(cacheData, Product.class);
            }
            // 查询数据库
            Product product = productMapper.selectById(id);
            if (product != null) {
                // 设置缓存,加随机过期时间(300~420秒),防止雪崩
                redisTemplate.opsForValue().set(key, JSON.toJSONString(product), 
                        300 + ThreadLocalRandom.current().nextInt(120), TimeUnit.SECONDS);
            } else {
                // 空值缓存,防止穿透
                redisTemplate.opsForValue().set(key, "NULL_PLACEHOLDER", 30, TimeUnit.SECONDS);
            }
            return product;
        } else {
            // 获取锁失败,休眠后重试
            Thread.sleep(50);
            return getHotProduct(id); // 递归重试
        }
    } catch (InterruptedException e) {
        Thread.currentThread().interrupt();
        return null;
    } finally {
        if (lock.isHeldByCurrentThread()) {
            lock.unlock();
        }
    }
}

❄️ 缓存雪崩 — 核心代码

技术亮点:过期时间加随机盐 + 多级缓存兜底(Caffeine)。

// 本地缓存配置(Caffeine)
Cache localCache = Caffeine.newBuilder()
        .maximumSize(1000)
        .expireAfterWrite(60, TimeUnit.SECONDS)
        .build();

public Object getDataWithMultiLevel(Long id) {
    String key = "data:" + id;
    
    // 1. 一级:本地缓存
    Object localVal = localCache.getIfPresent(id);
    if (localVal != null) {
        return localVal;
    }
    
    // 2. 二级:Redis
    String cacheVal = redisTemplate.opsForValue().get(key);
    if (cacheVal != null) {
        localCache.put(id, JSON.parse(cacheVal)); // 回填本地
        return cacheVal;
    }
    
    // 3. 查库并回填(带锁防止击穿)
    Object dbVal = queryFromDbWithLock(id);
    if (dbVal != null) {
        // 设置Redis过期时间,基础1小时 + 随机0~600秒
        long expireSec = 3600 + ThreadLocalRandom.current().nextInt(600);
        redisTemplate.opsForValue().set(key, JSON.toJSONString(dbVal), expireSec, TimeUnit.SECONDS);
        localCache.put(id, dbVal);
    }
    return dbVal;
}

🧩 此场景技术难点 & 解决方案

场景 技术难点 解决方案 经验要点 💡
🔍 穿透 1. 布隆过滤器无法删除,数据新增/删除后需重建。2. 误判率会放过少量无效请求。3. 空值缓存占用内存,可能被恶意构造不同key。 1. 使用支持扩容的布隆过滤器(RedisBloom插件)或定时重建。2. 误判率设定0.01%以下,并靠空值缓存兜底。3. 空值缓存设置极短TTL,并对请求限流。 实际误判率要压测,不能光看理论值。
💥 击穿 1. 锁的粒度与性能平衡:热点越多,锁竞争越严重。2. 逻辑过期存在最终一致性问题,异步更新可能返回旧数据。3. 分布式锁死锁风险 1. 单机ConcurrentHashMap加轻量锁,集群用Redisson细粒度锁。2. 逻辑过期时,保证旧值仍可用,提前预加载或后台刷新。3. 使用Redisson看门狗自动续期 + finally解锁。 逻辑过期适合读多写少且能容忍短暂不一致的业务。
❄️ 雪崩 1. 随机过期算法仍可能出现局部热点集中失效。2. 多级缓存同步问题,本地缓存与Redis数据可能不一致。3. 熔断降级后,服务恢复时的“脉冲式”压力。 1. 不仅随机,还要结合业务预热,提前加载高频key。2. 本地缓存短TTL,消息队列通知更新。3. 半开状态逐步放量,采用令牌桶或预热算法。 生产必做多级缓存,核心接口要压测极限QPS并设定好阈值。 👍

面试官 👨‍💻:

“很好,代码细节到位,难点拆解得也清晰。你这种把代码和方案能对应讲出来的习惯,是面试加分项。这场缓存三兄弟的考察,给你 Strong Hire ✅。”


原文地址: https://www.cveoy.top/t/topic/qHgo 著作权归作者所有。请勿转载和采集!

免费AI点我,无需注册和登录