Redis之缓存击穿
文章目录
- Redis之缓存击穿
- 一、什么是缓存击穿
- 二、缓存击穿常见解决方案
- 1. 互斥锁(Mutex Lock)
- 2. 永不过期 + 后台刷新
- 3. 逻辑过期(异步更新)
- 三、案例
- 1.基于互斥锁解决缓存击穿
- 2.基于逻辑过期解决缓存击穿
- 四、注意事项
- 1. 锁的选择
- 2. 递归重试风险
- 3. 锁超时时间
- 4. 缓存过期时间随机化
一、什么是缓存击穿
缓存击穿(Cache Breakdown)是指某个热点 Key 在缓存中过期后,大量并发请求同时绕过缓存直接访问数据库,导致数据库压力骤增的现象。
通常发生在以下场景:
- 某个 Key 是高频访问的「热点数据」。
- Key 的缓存过期时间到期,此时大并发请求同时到达。
- 缓存失效瞬间,所有请求都去查询数据库并重建缓存。
二、缓存击穿常见解决方案
1. 互斥锁(Mutex Lock)
- 原理:当缓存失效时,只允许一个线程去加载数据,其他线程等待缓存更新完成后再读取缓存。
- 优点: 确保数据强一致性
- 缺点:线程需要等待,可能成为性能瓶颈(锁竞争)
- 流程图:
- 伪代码:
public Object getData(String key) {Object data = cache.get(key);if (data != null) return data;// 加锁(如Redis的SETNX)String lockKey = "lock:" + key;if (redis.setnx(lockKey, "1", 10)) { // 10秒锁超时try {// 二次检查缓存(防止锁竞争期间其他线程已加载)data = cache.get(key);if (data != null) return data;data = db.query(key);cache.set(key, data);} finally {redis.del(lockKey); // 释放锁}} else {// 等待重试Thread.sleep(100);return getData(key);}return data;
}
2. 永不过期 + 后台刷新
- 原理:为缓存设置永不过期时间,同时通过后台线程主动更新缓存。
- 优点:无阻塞,适合对一致性要求低场景
- 缺点:数据可能短暂陈旧
- 流程图:
- 伪代码:
// 初始化时设置缓存永不过期
cache.set("hot_key", data)// 后台线程定期更新
public void backgroundRefresh() {while(true) {Thread.sleep(5 * 60 * 1000) // 每5分钟更新一次newData = db.query("hot_key")cache.set("hot_key", newData)}
}
3. 逻辑过期(异步更新)
- 原理:在缓存中存储数据的逻辑过期时间,即使缓存未物理过期,若逻辑过期则异步更新。
- 优点:无阻塞,兼容性强
- 缺点:不保证一致性,实现复杂度较高
- 时序图:
- 伪代码:
缓存条目类
@Data
public class CacheEntry {private final String data;private final long expireTime;
}
// 获取当前时间戳(毫秒)private static long now() {return System.currentTimeMillis();}public static String getData(String key) {CacheEntry entry = cache.get(key);// 缓存未命中if (entry == null) {String data = Database.query(key);long expireTime = now() + 300_000; // 5分钟过期(300秒 * 1000)cache.put(key, new CacheEntry(data, expireTime));return data;}// 检查逻辑过期if (entry.getExpireTime() < now()) {// 启动异步更新线程new Thread(() -> asyncUpdate(key)).start();}// 返回过期数据return entry.getData();}private static void asyncUpdate(String key) {String newData = Database.query(key);long newExpireTime = now() + 300_000;cache.put(key, new CacheEntry(newData, newExpireTime));}
三、案例
1.基于互斥锁解决缓存击穿
public Shop queryWithMutex(Long id) {// 1.从redis查询商铺缓存String key = CACHE_SHOP_KEY + id;String shopJson = stringRedisTemplate.opsForValue().get(key);// 2.判断是否存在if (StrUtil.isNotBlank(shopJson)) {// 3.存在,直接返回return JSONUtil.toBean(shopJson, Shop.class);}// 判断命中的是否是空值if(shopJson != null) {// 返回错误信息,解决缓存穿透问题return null;}// 4.实现缓存重建// 4.1 获取互斥锁String lockKey = LOCK_SHOP_KEY + id;Shop shop;try {boolean isLock = tryLock(lockKey);// 4.2 判断是否获取成功if (!isLock) {// 4.3 失败,则休眠并重试Thread.sleep(50);return queryWithMutex(id);}// 4.4 成功,根据id查询数据库,返回数据shop = getById(id);if (shop == null) {// 5.数据库不存在,将空字串写入Redis,设置过期时间,解决缓存穿透问题stringRedisTemplate.opsForValue().set(key, "", CACHE_NULL_TTL, TimeUnit.MINUTES);// 返回错误信息,解决缓存穿透问题return null;}// 6.存在,写入redisstringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(shop), CACHE_SHOP_TTL, TimeUnit.MINUTES);} catch (InterruptedException e) {throw new RuntimeException(e);} finally {// 7.释放锁unLock(lockKey);}return shop;}
2.基于逻辑过期解决缓存击穿
public Shop queryWithLogicalExpire(Long id) {// 1.从redis查询商铺缓存String key = CACHE_SHOP_KEY + id;String shopJson = stringRedisTemplate.opsForValue().get(key);// 2.判断是否存在if (StrUtil.isBlank(shopJson)) {// 3.不存在,直接返回nullreturn null;}// 4.命中,需要先把json反序列化为对象RedisData redisData = JSONUtil.toBean(shopJson, RedisData.class);JSONObject data = (JSONObject) redisData.getData();Shop shop = JSONUtil.toBean(data, Shop.class);LocalDateTime expireTime = redisData.getExpireTime();// 5.判断是否过期if (expireTime.isAfter(LocalDateTime.now())) {// 5.1 未过期,直接返回数据return shop;}// 5.2 过期,需要缓存重建// 6. 缓存重建String lockKey = LOCK_SHOP_KEY + id;// 6.1 获取互斥锁boolean isLock = tryLock(lockKey);// 6.2 判断是否获取锁成功if (isLock) {// 6.3 成功,开启独立线程,实现缓存重建CACHE_REBUILD_EXECUTOR.submit(() -> {try {this.saveShop2Redis(id, 20L);} catch (Exception e) {throw new RuntimeException(e);} finally {// 6.5 释放锁unLock(lockKey);}});}// 6.4 返回过期的商铺信息return shop;}
四、注意事项
1. 锁的选择
- 单机环境用
ReentrantLock
或synchronized
。 - 分布式环境需用 Redis 分布式锁(如 Redisson 的
RLock
)。
2. 递归重试风险
- 示例中递归调用,可能导致栈溢出,实际生产环境应改用循环重试。
3. 锁超时时间
- 分布式锁需设置合理超时时间(如 300ms),防止死锁。
4. 缓存过期时间随机化
- 可对缓存 TTL 添加随机值(如
300 + rand.nextInt(100)
),避免缓存雪崩。