面试Redis篇-深入理解Redis缓存击穿
Java开发者必备:深入理解Redis缓存击穿
一、 什么是缓存击穿 (Cache Breakdown)?
1. 核心定义
缓存击穿,也叫“热点Key问题”,指的是某一个访问极其频繁的热点Key,在它过期失效的那一瞬间,有海量的并发请求同时涌入。由于缓存中已无此Key,这些请求会全部“击穿”缓存层,直接打向后端的数据库,导致数据库压力瞬间剧增,甚至崩溃。
它的核心特征是:单一热点Key + 高并发 + 精准地发生在失效的瞬间。
2. Java项目中的场景比喻
想象一下,你正在为某款爆款秒杀商品(例如,最新款的iPhone)的详情页做缓存。这个商品是全网关注的焦点(热点Key),它的缓存你设置了10分钟过期。
在晚上8点整,秒杀活动开始,成千上万的用户疯狂刷新商品详情页。很不巧,在8:05:00,这个商品的缓存正好过期了。在8:05:01这一秒内,可能有数千个请求(高并发)同时到达你的服务器。
- 你的
ProductServiceImpl
发现Redis中没有这个productId
的缓存。 - 于是,这数千个线程都去执行
productMapper.selectById(productId)
,向数据库发起了数千次完全相同的查询。 - 数据库瞬间被这一个SQL查询打爆,CPU飙升,连接池耗尽,导致整个商品服务不可用。
缓存击穿就像用一把高能激光(高并发请求)精准地击穿了盔甲(缓存)上一个最薄弱的点(过期的热点Key)。
二、 与缓存雪崩、缓存穿透的清晰辨析
这三个概念非常容易混淆,面试时清晰地辨别它们是加分项。
特性 | 缓存击穿 (Breakdown) | 缓存雪崩 (Avalanche) | 缓存穿透 (Penetration) |
---|---|---|---|
影响范围 | 单个 Key | 大量 Key | 大量不存在的 Key |
根本原因 | 单个热点Key过期 | 大量Key同时过期或Redis宕机 | 查询不存在的数据 |
攻击对象 | 数据库中存在的数据 | 数据库中存在的数据 | 数据库中不存在的数据 |
形象比喻 | 一束激光,精准打击一点 | 大坝决堤,全面冲击 | 散弹枪,漫无目的扫射 |
三、 缓存击穿的解决方案
解决缓存击穿的核心思路是:避免在高并发下,让多个线程同时去重建同一个热点Key的缓存。
方案一:使用互斥锁/分布式锁 (Mutex Lock / Distributed Lock)
思路:
这是最经典、最常用的解决方案。当缓存失效时,只允许第一个获取到锁的线程去查询数据库和重建缓存,其他线程则等待或直接返回。
Java 实现示例 (使用 Redisson 分布式锁):
在分布式环境下,必须使用分布式锁,而不是Java的synchronized或ReentrantLock。Redisson是优秀的实现。
@Autowired
private RedissonClient redissonClient;
@Autowired
private RedisTemplate<String, Object> redisTemplate;
@Autowired
private ProductMapper productMapper;public Product getProductById(Long id) {String key = "product:" + id;// 1. 先查缓存Object cachedProduct = redisTemplate.opsForValue().get(key);if (cachedProduct != null) {return (Product) cachedProduct;}// 2. 缓存未命中,获取分布式锁RLock lock = redissonClient.getLock("lock:product:" + id);try {// 尝试获取锁,最多等待10秒if (lock.tryLock(10, TimeUnit.SECONDS)) {// 3. 【双重检查】成功获取锁后,再次检查缓存// 防止在高并发下,等待锁的过程中,已有其他线程完成了缓存重建cachedProduct = redisTemplate.opsForValue().get(key);if (cachedProduct != null) {return (Product) cachedProduct;}// 4. 真正去数据库查询并重建缓存Product productFromDb = productMapper.selectById(id);if (productFromDb != null) {redisTemplate.opsForValue().set(key, productFromDb, 30, TimeUnit.MINUTES);} else {// 防止缓存穿透,可以缓存一个空值redisTemplate.opsForValue().set(key, "", 5, TimeUnit.MINUTES);}return productFromDb;} else {// 未获取到锁的线程,可以稍等后重试Thread.sleep(100);return getProductById(id); // 递归调用重试}} catch (InterruptedException e) {Thread.currentThread().interrupt(); // 恢复中断状态return null;} finally {// 5. 确保释放锁if (lock.isLocked() && lock.isHeldByCurrentThread()) {lock.unlock();}}
}
优点:
- 强一致性:保证了只有一个线程更新缓存,数据一致性高。
- 思路清晰:逻辑简单明了。
缺点:
- 性能影响:引入了锁,未获取到锁的线程需要等待,系统吞吐量会下降。
- 可能死锁:如果锁的实现或使用不当,有死锁风险。
方案二:热点数据永不过期(逻辑过期)
思路:
这是一种以空间换时间、以可用性为先的策略。我们不给Redis中的热点Key设置物理过期时间(TTL),而是在缓存的值中包含一个“逻辑过期时间”字段。
- 当请求线程发现数据的逻辑时间已过期时,它不会阻塞。
- 它会先立即返回旧的(但可用的)数据给用户。
- 同时,它会尝试获取一个锁,并派发一个异步线程去执行缓存重建任务。
Java 实现示例:
首先定义一个包含逻辑过期时间的包装类。
class CacheData<T> {T data;LocalDateTime logicalExpireTime;
}
然后是查询逻辑。
// 假设有一个专门执行刷新任务的线程池
private static final ExecutorService CACHE_REFRESH_EXECUTOR = Executors.newFixedThreadPool(10);public Product getProductWithLogicalExpire(Long id) {String key = "product:" + id;// 1. 从Redis获取数据CacheData<Product> cacheData = (CacheData<Product>) redisTemplate.opsForValue().get(key);// 2. 缓存未命中(说明非热点或首次访问),直接返回null(或查询数据库)if (cacheData == null) {return null; }// 3. 命中缓存,判断是否逻辑过期if (cacheData.getLogicalExpireTime().isAfter(LocalDateTime.now())) {// 3.1 未过期,直接返回数据return cacheData.getData();}// 4. 已逻辑过期,需要重建缓存String lockKey = "lock:product:" + id;// 尝试获取分布式锁if (redisTemplate.opsForValue().setIfAbsent(lockKey, "1", 10, TimeUnit.SECONDS)) {// 获取锁成功,开启一个独立线程去重建缓存CACHE_REFRESH_EXECUTOR.submit(() -> {try {// 查询数据库,创建新的CacheData对象,并写入RedisProduct newProduct = productMapper.selectById(id);CacheData<Product> newCacheData = new CacheData<>();newCacheData.setData(newProduct);newCacheData.setLogicalExpireTime(LocalDateTime.now().plusMinutes(30));redisTemplate.opsForValue().set(key, newCacheData);} finally {// 释放锁redisTemplate.delete(lockKey);}});}// 5. 无论是否获取到锁,都立刻返回旧的数据,保证用户体验return cacheData.getData();
}
优点:
- 高可用性:用户的请求线程几乎不会被阻塞,总能立即拿到数据(即使是旧的)。
- 性能好:没有大量线程因等待锁而挂起。
缺点:
- 数据不一致:在缓存重建完成前,用户会拿到旧数据。
- 实现复杂:需要额外的逻辑和线程池来管理。
四、 Java面试中如何回答“缓存击穿”
面试官您好,对于缓存击穿,我的理解如下:
首先,它的定义和特点是, 缓存击穿是指一个热点Key,在它过期的一瞬间,有大量并发请求过来,导致这些请求都穿透了缓存,直接打到数据库上。它和雪崩(大量Key过期)以及穿透(查询不存在的Key)的关键区别在于,它的攻击目标是单一的、存在的、高热度的Key。
其次,针对这个问题,我了解两种主流的解决方案:
第一种是“互斥锁”方案。 这是最经典的解决方案。当缓存失效后,我们不是让所有请求都去查数据库,而是先用一个分布式锁(比如基于Redis的
SETNX
或使用像Redisson这样的客户端)来保证只有一个线程能去执行数据库查询和缓存重建的任务。其他线程则等待。为了优化性能,获取锁的线程在执行任务前会进行一次双重检查,看缓存是否已经被其他线程重建好了,避免重复工作。这个方案的优点是数据一致性强,缺点是会牺牲一些吞吐量。第二种是“逻辑过期”方案。 这是一个更侧重于高可用性的高级方案。我们不给热点Key设置物理TTL,而是在Value中存一个逻辑过期时间。当请求发现数据逻辑过期时,它会先立即返回旧数据给用户,保证用户请求不被阻塞,然后异步地派发一个后台线程去真正地更新缓存。这个方案的优点是用户体验极好,几乎无感知,但缺点是在更新期间会存在短暂的数据不一致。
最后,关于方案的选择, 我认为需要根据业务场景来权衡。如果业务对数据一致性要求极高,比如金融领域的交易数据,那么“互斥锁”方案更合适。但如果业务场景是像新闻、商品详情页这种,用户体验和高可用性优先,并且能容忍短暂的数据不一致,那么“逻辑过期”方案是更好的选择。