面试官最爱问的缓存三兄弟:穿透、击穿、雪崩,一次讲清楚

缓存是系统性能优化的一把利剑,但使用不当反而会“伤及自身”。今天我们就通过真实场景,彻底搞懂缓存穿透、击穿和雪崩这三个面试必考题。

先来看一个真实的电商场景

假设我们在开发一个电商网站,商品信息缓存在Redis中,数据库存储原始数据。

// 典型的缓存查询逻辑
public Product getProductById(Long id) {
    // 1. 先从缓存查询
    Product product = redis.get("product:" + id);
    if (product != null) {
        return product;
    }
    
    // 2. 缓存不存在,查询数据库
    product = db.query("SELECT * FROM products WHERE id = ?", id);
    if (product != null) {
        // 3. 将查询结果写入缓存
        redis.setex("product:" + id, 3600, product);
    }
    
    return product;
}

这个看似完美的流程,实则暗藏玄机!

一、缓存穿透:幽灵请求的攻击

场景还原

晚上10点,你正准备下班,突然收到数据库CPU 100%的告警。查看日志发现,大量请求在查询一个不存在的商品ID:-1。

// 恶意请求示例
for (int i = 0; i < 10000; i++) {
    // 请求不存在的数据
    http.get("/product/" + UUID.randomUUID());
}

问题分析

缓存穿透:查询一个根本不存在的数据,缓存和数据库都没有,每次请求都直接打到数据库。

危害程度:

  • 数据库压力暴增,可能被压垮
  • 系统响应变慢,正常请求受影响

解决方案

方案1:布隆过滤器(推荐)

// 初始化布隆过滤器
BloomFilter<String> bloomFilter = BloomFilter.create(
    Funnels.stringFunnel(Charset.defaultCharset()), 
    1000000,  // 预期数据量
    0.01      // 误判率
);

// 系统启动时加载所有存在的ID
List<Long> existIds = productDao.getAllIds();
for (Long id : existIds) {
    bloomFilter.put("product:" + id);
}

// 查询时先检查布隆过滤器
public Product getProductById(Long id) {
    String key = "product:" + id;
    
    // 布隆过滤器判断
    if (!bloomFilter.mightContain(key)) {
        return null; // 肯定不存在,直接返回
    }
    
    // 后续查询逻辑...
    return product;
}

方案2:缓存空对象

public Product getProductById(Long id) {
    Product product = redis.get("product:" + id);
    if (product != null) {
        // 特殊标记的空对象
        if (product.isEmptyObject()) {
            return null;
        }
        return product;
    }
    
    product = db.query("SELECT * FROM products WHERE id = ?", id);
    if (product != null) {
        redis.setex("product:" + id, 3600, product);
    } else {
        // 缓存空对象,设置较短过期时间
        redis.setex("product:" + id, 300, EmptyObject);
    }
    
    return product;
}

二、缓存击穿:热点数据的突然死亡

场景还原

某网红商品晚上8点做秒杀活动,缓存刚好在这个时间点过期。瞬间几万个请求同时涌向数据库:

// 并发场景下的问题
public class ProductService {
    public Product getProduct(Long id) {
        // 缓存刚好过期,大量请求同时到达
        return getProductById(id);
    }
}

问题分析

缓存击穿:某个热点key过期时,大量并发请求同时访问这个key,瞬间击穿缓存,直接访问数据库。

危害程度:

  • 数据库瞬间压力巨大
  • 可能引起连锁反应

解决方案

方案1:互斥锁(Mutex Lock)

public Product getProductWithLock(Long id) {
    String cacheKey = "product:" + id;
    Product product = redis.get(cacheKey);
    
    if (product != null) {
        return product;
    }
    
    // 获取分布式锁
    String lockKey = "lock:product:" + id;
    try {
        if (redis.setnx(lockKey, "1", 10)) { // 获取锁
            // 查询数据库
            product = db.query("SELECT * FROM products WHERE id = ?", id);
            if (product != null) {
                redis.setex(cacheKey, 3600, product);
            }
            return product;
        } else {
            // 未获取到锁,稍后重试
            Thread.sleep(50);
            return getProductWithLock(id);
        }
    } finally {
        redis.delete(lockKey); // 释放锁
    }
}

方案2:逻辑过期时间

// 缓存数据包装类
@Data
public class RedisData {
    private Object data;
    private LocalDateTime expireTime; // 逻辑过期时间
}

public Product getProductWithLogicalExpire(Long id) {
    String cacheKey = "product:" + id;
    RedisData redisData = redis.get(cacheKey);
    
    // 判断逻辑过期时间
    if (redisData == null) {
        // 缓存不存在,重新加载
        return loadProductToCache(id);
    }
    
    if (redisData.getExpireTime().isAfter(LocalDateTime.now())) {
        // 未过期,直接返回
        return (Product) redisData.getData();
    } else {
        // 已过期,异步更新
        CompletableFuture.runAsync(() -> {
            updateProductCache(id);
        });
        return (Product) redisData.getData(); // 返回旧数据
    }
}

三、缓存雪崩:系统的多米诺骨牌

场景还原

凌晨2点,缓存集群中大量key同时过期,数据库连接池被占满,整个系统陷入瘫痪:

// 问题代码:同时设置一样的过期时间
public void initCache() {
    List<Product> products = getAllProducts();
    for (Product product : products) {
        // 所有缓存设置一样的过期时间
        redis.setex("product:" + product.getId(), 7200, product);
    }
}

问题分析

缓存雪崩:大量缓存key在同一时间过期,导致所有请求直接访问数据库,引起数据库压力过大而宕机。

危害程度:

  • 系统完全不可用
  • 恢复困难,可能形成恶性循环

解决方案

方案1:随机过期时间

public void setProductCache(Product product) {
    String key = "product:" + product.getId();
    int baseExpire = 7200; // 基础过期时间2小时
    int randomExpire = baseExpire + new Random().nextInt(1800); // 增加随机时间
    redis.setex(key, randomExpire, product);
}

方案2:缓存永不过期 + 后台更新

// 缓存永不过期,通过后台线程更新
public void initCache() {
    List<Product> products = getAllProducts();
    for (Product product : products) {
        redis.set("product:" + product.getId(), product); // 不设置过期时间
    }
    
    // 启动后台线程,定时更新缓存
    ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(1);
    scheduler.scheduleAtFixedRate(() -> {
        updateAllProductCache();
    }, 1, 1, TimeUnit.HOURS); // 每小时更新一次
}

方案3:多级缓存架构

public class MultiLevelCacheService {
    // 本地缓存
    private Cache<Long, Product> localCache = 
        Caffeine.newBuilder().expireAfterWrite(10, TimeUnit.MINUTES).build();
    
    // Redis缓存
    private RedisTemplate redisTemplate;
    
    public Product getProduct(Long id) {
        // 1. 查询本地缓存
        Product product = localCache.getIfPresent(id);
        if (product != null) {
            return product;
        }
        
        // 2. 查询Redis缓存
        product = (Product) redisTemplate.opsForValue().get("product:" + id);
        if (product != null) {
            localCache.put(id, product);
            return product;
        }
        
        // 3. 查询数据库
        product = db.query("SELECT * FROM products WHERE id = ?", id);
        if (product != null) {
            redisTemplate.opsForValue().set("product:" + id, product, 3600, TimeUnit.SECONDS);
            localCache.put(id, product);
        }
        
        return product;
    }
}

实战:综合防御方案

在实际项目中,我们需要综合运用多种方案:

@Component
public class CacheService {
    
    @Autowired
    private RedisTemplate redisTemplate;
    
    @Autowired
    private ProductDao productDao;
    
    // 布隆过滤器
    private BloomFilter<String> bloomFilter;
    
    @PostConstruct
    public void init() {
        // 初始化布隆过滤器
        bloomFilter = BloomFilter.create(
            Funnels.stringFunnel(Charset.defaultCharset()), 1000000, 0.01);
        
        // 加载存在的键
        loadExistKeys();
    }
    
    public Product getProductSafely(Long id) {
        String key = "product:" + id;
        
        // 1. 布隆过滤器校验
        if (!bloomFilter.mightContain(key)) {
            return null;
        }
        
        // 2. 查询缓存
        Product product = (Product) redisTemplate.opsForValue().get(key);
        if (product != null) {
            if (product instanceof EmptyProduct) {
                return null; // 空对象
            }
            return product;
        }
        
        // 3. 获取分布式锁
        String lockKey = "lock:" + key;
        try {
            if (tryLock(lockKey)) {
                // 4. 双重检查
                product = (Product) redisTemplate.opsForValue().get(key);
                if (product != null) {
                    return product;
                }
                
                // 5. 查询数据库
                product = productDao.findById(id);
                if (product != null) {
                    // 6. 异步设置缓存,使用随机过期时间
                    setCacheWithRandomExpire(key, product);
                } else {
                    // 缓存空对象
                    redisTemplate.opsForValue().set(key, new EmptyProduct(), 300, TimeUnit.SECONDS);
                }
                return product;
            } else {
                // 未获取到锁,重试
                Thread.sleep(100);
                return getProductSafely(id);
            }
        } finally {
            releaseLock(lockKey);
        }
    }
    
    private void setCacheWithRandomExpire(String key, Object value) {
        int expire = 3600 + new Random().nextInt(600); // 随机过期时间
        redisTemplate.opsForValue().set(key, value, expire, TimeUnit.SECONDS);
    }
}

总结对比

问题类型

根本缘由

解决方案

适用场景

缓存穿透

查询不存在数据

1. 布隆过滤器
2. 缓存空对象

恶意攻击、参数校验

缓存击穿

热点key过期

1. 互斥锁
2. 逻辑过期

热点数据、秒杀场景

缓存雪崩

大量key同时过期

1. 随机过期时间
2. 多级缓存
3. 永不过期+后台更新

缓存预热、系统初始化

写在最后

记住这三兄弟的特点:

  • 穿透:查无此”人”,布隆来挡
  • 击穿:热点”阵亡”,加锁护航
  • 雪崩:集体”下岗”,随机应变

掌握了这些解决方案,不仅能轻松应对面试,更能写出健壮的生产级代码。赶紧收藏起来,下次面试必定用得上!


思考题:你的项目中遇到过哪种缓存问题?是如何解决的呢?欢迎在评论区分享你的实战经验!

© 版权声明
THE END
如果内容对您有所帮助,就支持一下吧!
点赞0 分享
评论 共7条

请登录后发表评论