缓存击穿
在现代高并发系统中,缓存是提升性能的核心组件之一。然而,随着系统的复杂性和流量的增加,缓存相关的问题也逐渐显现,其中 缓存击穿 是一个非常典型且棘手的问题。
======= 🌟 青柠来相伴,代码更简单。🌟 =======
📚 本文所有内容,我都整理在了 青柠合集 里。👇
🎯 搜索关注【青柠代码录】,即可查看所有合集文章 ~
======= 🌟 ================ 🌟 =======
热点数据和冷数据
热点数据,缓存才有价值
对于冷数据而言,大部分数据可能还没有再次访问到,就已经被挤出内存,不仅占用内存,而且价值不大。
频繁修改的数据,看情况考虑使用缓存
对于热点数据,比如我们的某IM产品,生日祝福模块,当天的寿星列表,缓存以后可能读取数十万次。
再举个例子,某导航产品,我们将导航信息,缓存以后可能读取数百万次。
数据更新前至少读取两次,缓存才有意义。这个是最基本的策略,如果缓存还没有起作用就失效了,那就没有太大价值了。
那存不存在,修改频率很高,但是又不得不考虑缓存的场景呢?
有!比如,这个读取接口对数据库的压力很大,但是又是热点数据,这个时候就需要考虑通过缓存手段,减少数据库的压力。
比如我们的某助手产品的,点赞数,收藏数,分享数等是非常典型的热点数据,但是又不断变化,此时就需要将数据同步保存到Redis缓存,减少数据库压力。
缓存热点key
缓存中的一个Key(比如一个促销商品),在某个时间点过期的时候,恰好在这个时间点对这个Key有大量的并发请求过来,这些请求发现缓存过期一般都会从后端DB加载数据并回设到缓存,这个时候大并发的请求可能会瞬间把后端DB压垮。
解决方案
对缓存查询加锁,如果KEY不存在,就加锁,然后查DB入缓存,然后解锁;其他进程如果发现有锁就等待,然后等解锁后返回数据或者进入DB查询
什么是缓存击穿?
缓存击穿是指 热点数据(key)在缓存中失效时,大量请求同时访问该 key,导致这些请求直接打到后端数据库,从而引发数据库压力剧增甚至崩溃 的现象。
简单来说,就是当某个热点 key 突然失效时,会导致大量请求直接冲击 MySQL 数据库。
img
缓存击穿 vs 缓存穿透
需要注意的是,缓存击穿与缓存穿透是完全不同的问题:
- 缓存击穿:热点 key 失效,请求回源到数据库。
- 缓存穿透:查询不存在的数据(既不在缓存也不在数据库),导致恶意或无效请求绕过缓存。
缓存击穿的危害
- 数据库压力剧增:大量请求直接访问数据库,可能导致数据库连接池耗尽、响应变慢甚至宕机。
- 用户体验下降:由于数据库负载过高,用户请求延迟显著增加。
- 系统稳定性风险:一旦数据库崩溃,整个系统可能陷入不可用状态。
因此,在设计高并发系统时,必须充分考虑如何预防和解决缓存击穿问题。
解决方案与最佳实践
针对缓存击穿问题,业界提出了多种解决方案。下面我们将逐一分析每种方案的工作原理、优缺点以及适用场景,并通过代码示例展示其实现方式。
1️⃣ 预加载热门数据
原理
在缓存失效之前,提前更新缓存中的热点数据,避免出现空窗期。
实现步骤
- 提前识别热点数据。在redis高峰访问之前,把一些热门数据,提前存入到redis里面,加大这些热门数据key的时长。
- 在缓存即将到期时,主动刷新其内容。
- 延长热点数据的过期时间,降低失效频率。
示例代码
@Service
@Slf4j
public class CachePreloader {
@Autowired
private RedisTemplate<String, Object> redisTemplate;
public static final String HOT_KEY = "hot_data";
// 定时任务预加载热点数据
@Scheduled(fixedRate = 60000) // 每分钟执行一次
public void preloadHotData() {
log.info("开始预加载热点数据...");
try {
// 模拟从数据库获取最新数据
List<String> hotData = fetchDataFromDatabase();
// 更新缓存,设置较长的过期时间
redisTemplate.opsForValue().set(HOT_KEY, hotData, 3600, TimeUnit.SECONDS);
log.info("热点数据已成功预加载到缓存中");
} catch (Exception e) {
log.error("预加载热点数据失败", e);
}
}
private List<String> fetchDataFromDatabase() {
// 模拟从数据库加载数据
return Arrays.asList("data1", "data2", "data3");
}
}
应用场景
适合能够提前预测热点数据的场景,例如电商促销活动、新闻热点等。
2️⃣ 使用互斥锁(双检加锁)
原理
在缓存失效时,使用分布式锁,确保只有一个线程去加载数据,其他线程等待缓存被重新填充后、再读取。
多个线程同时去查询数据库的这条数据,那么我们可以在第一个查询数据的请求上,使用一个互斥锁来锁住它。其他的线程走到这一步拿不到锁就等着,等第一个线程查询到了数据,然后做缓存。后面的线程进来发现已经有缓存了,就直接走缓存。
img
实现步骤
- 当发现缓存为空时,尝试获取分布式锁。
- 如果获取成功,加载数据并写入缓存。
- 如果获取失败,等待一段时间后重试。
示例代码
@Service
@Slf4j
public class MutexCacheService {
@Autowired
private RedisTemplate<String, Object> redisTemplate;
public static final String LOCK_PREFIX = "lock:";
public String getData(String key) {
// 尝试从缓存中获取数据
String data = (String) redisTemplate.opsForValue().get(key);
if (data != null) {
return data;
}
// 缓存为空,尝试获取分布式锁
String lockKey = LOCK_PREFIX + key;
boolean locked = false;
try {
locked = redisTemplate.opsForValue().setIfAbsent(lockKey, "LOCKED", 10, TimeUnit.SECONDS);
if (locked) {
log.info("获取分布式锁成功,开始加载数据...");
// 模拟从数据库加载数据
data = fetchDataFromDatabase(key);
// 写入缓存
redisTemplate.opsForValue().set(key, data, 300, TimeUnit.SECONDS);
log.info("数据已加载并写入缓存");
} else {
// 等待一段时间后重试
Thread.sleep(100);
return getData(key);
}
} catch (InterruptedException e) {
log.error("线程中断异常", e);
} finally {
if (locked) {
// 释放分布式锁
redisTemplate.delete(lockKey);
}
}
return data;
}
private String fetchDataFromDatabase(String key) throws InterruptedException {
// 模拟从数据库加载数据
Thread.sleep(200); // 模拟延迟
return "data_from_db_" + key;
}
}
应用场景
适合需要严格控制并发访问的场景,例如秒杀活动、抢购等。
3️⃣ 差异化过期时间
原理
为热点数据设置随机的过期时间,避免多个 key 同时失效。
实现步骤
- 在设置缓存时,为每个 key ,添加一个随机的过期时间偏移量。
- 确保热点数据,不会在同一时刻全部失效。
示例代码
@Service
@Slf4j
public class RandomExpirationService {
@Autowired
private RedisTemplate<String, Object> redisTemplate;
public void setCacheWithRandomExpiration(String key, Object value, long baseTTL) {
// 生成随机偏移量(例如 0-300 秒)
long randomOffset = new Random().nextInt(300);
long ttl = baseTTL + randomOffset;
// 设置缓存及过期时间
redisTemplate.opsForValue().set(key, value, ttl, TimeUnit.SECONDS);
log.info("缓存已设置,key={}, TTL={}秒", key, ttl);
}
}
应用场景
适合无法完全预知热点数据,但希望分散失效时间的场景。
4️⃣ 双缓存策略
原理
维护两套缓存(A 和 B),当 A 缓存失效时,切换到 B 缓存,确保服务不中断。
实现步骤
- 初始化时,同时加载 A 和 B 缓存。
- A 缓存作为主缓存,B 缓存作为备用缓存。
- 当 A 缓存失效时,立即切换到 B 缓存,并重新加载 A 缓存。
示例代码
参考文章下面的 JHSTaskService 和 JHSTaskController 示例。
实战案例:高并发聚划算业务
我们以聚划算案例为例,结合上述解决方案,进一步优化其实现方式。
img
优化后的代码结构
- 预加载热门商品:通过定时任务定期更新缓存。
- 互斥锁防止击穿:在缓存失效时,使用分布式锁保护数据库。
- 差异失效时间:为商品列表设置随机过期时间。
- 双缓存策略:引入 A/B 缓存,确保服务平稳运行。
img
实体类 **Product**
首先定义一个实体类Product,用于表示参与聚划算活动的商品信息。
package com.luojia.redis7_study.entities;
import io.swagger.annotations.ApiModel;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@AllArgsConstructor
@NoArgsConstructor
@ApiModel(value="聚划算活动Product信息")
public class Product {
// 产品id
private Long id;
// 产品名称
private String name;
// 产品价格
private Integer price;
// 产品详情
private String detail;
}
缓存服务 JHSTaskService
接下来是JHSTaskService,它负责将商品数据,从数据库加载到Redis中,并设置了不同的过期时间来防止缓存击穿。
package com.luojia.redis7_study.service;
import com.luojia.redis7_study.entities.Product;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service;
import javax.annotation.PostConstruct;
import java.util.ArrayList;
import java.util.List;
import java.util.Random;
import java.util.concurrent.TimeUnit;
@Service
@Slf4j
public class JHSTaskService {
public static final String JHS_KEY_A = "jhs:a";
public static final String JHS_KEY_B = "jhs:b";
@Autowired
private RedisTemplate<String, Object> redisTemplate;
private List<Product> getProductsFromMysql() {
ArrayList<Product> list = new ArrayList<>();
for (int i = 0; i < 20; i++) {
Random random = new Random();
int id = random.nextInt(10000);
Product product = new Product((long) id, "product" + i, i, "detail");
list.add(product);
}
return list;
}
// 双缓存
@PostConstruct
public void initJHSAB() {
log.info("模拟定时任务,从数据库中不断获取参加聚划算的商品");
// 1 用多线程模拟定时任务,将商品从数据库刷新到redis
new Thread(() -> {
while(true) {
// 2 模拟从数据库查询数据
List<Product> list = this.getProductsFromMysql();
// 3 先更新B缓存且让B缓存过期时间超过A缓存,如果突然失效还有B兜底,防止击穿
// 更新B缓存并设置较长的过期时间
redisTemplate.delete(JHS_KEY_B);
redisTemplate.opsForList().leftPushAll(JHS_KEY_B, list);
// 设置过期时间为1天+10秒
redisTemplate.expire(JHS_KEY_B, 86410L, TimeUnit.SECONDS);
// 4 在更新缓存A
// 更新A缓存并设置正常的过期时间
redisTemplate.delete(JHS_KEY_A);
redisTemplate.opsForList().leftPushAll(JHS_KEY_A, list);
redisTemplate.expire(JHS_KEY_A, 86400L, TimeUnit.SECONDS);
// 5 暂停1分钟,模拟聚划算参加商品下架上新等操作
try {
Thread.sleep(60000); // 每分钟更新一次
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}, "t1").start();
}
}
img
delete命令执行的一瞬间有空隙,其他请求线程继续找redis,但是结果为null,请求直接打到redis,暴击数据库
控制器 JHSTaskController
控制器JHSTaskController负责处理来自客户端的请求,并提供分页功能。
如果主缓存(A)失效,则尝试从备用缓存(B)读取数据。
package com.luojia.redis7_study.controller;
import com.luojia.redis7_study.entities.Product;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.util.CollectionUtils;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
@Api(tags = "模拟聚划算商品上下架")
@RestController
@Slf4j
public class JHSTaskController {
public static final String JHS_KEY_A = "jhs:a";
public static final String JHS_KEY_B = "jhs:b";
@Autowired
private RedisTemplate<String, Object> redisTemplate;
@ApiOperation("聚划算案例,AB双缓存,防止热key突然失效")
@GetMapping("/product/findab")
public List<Product> findAB(int page, int size) {
List<Product> list = null;
long start = (page - 1) * size;
long end = start + size - 1;
// 尝试从A缓存中获取数据
list = redisTemplate.opsForList().range(JHS_KEY_A, start, end);
if (CollectionUtils.isEmpty(list)) {
log.info("A缓存已经失效或活动已经结束");
// 如果A缓存为空,则尝试从B缓存中获取数据
list = redisTemplate.opsForList().range(JHS_KEY_B, start, end);
if (CollectionUtils.isEmpty(list)) {
// 如果B缓存也为空,则需要去数据库查询
// todo: 这里应该添加逻辑,从数据库中加载数据并写入缓存
}
}
log.info("参加活动的商家: {}", list);
return list;
}
}
在这个案例中,我们通过设置两个缓存(A 和 B),并在它们之间轮流切换,确保即使其中一个缓存失效,另一个缓存仍然可以提供服务,从而有效避免了缓存击穿的问题。同时,我们还设置了不同的过期时间,进一步减少了所有缓存同时失效的风险。
面试题
什么是缓存击穿 ? 怎么解决 ?
缓存击穿的意思是对于设置了过期时间的key,缓存在某个时间点过期的时候,恰好这时间点对这个Key有大量的并发请求过来,这些请求发现缓存过期,一般都会从后端 DB 加载数据,并回设到缓存,这个时候大并发的请求可能会瞬间把 DB 压垮。
img
解决方案有两种方式:
一、使用互斥锁
第一可以使用互斥锁:当缓存失效时,不立即去load db,先使用如 Redis 的 setnx 去设置一个互斥锁,当操作成功返回时,再进行 load db的操作,并回设缓存,否则重试get缓存的方法
img
二、逻辑过期
第二种方案可以设置当前key逻辑过期,大概是思路如下:
①:在设置key的时候,设置一个过期时间字段一块存入缓存中,不给当前key设置过期时间
②:当查询的时候,从redis取出数据后判断时间是否过期
③:如果过期,则开通另外一个线程进行数据同步,当前线程正常返回数据,这个数据不是最新
img
当然两种方案各有利弊:
如果选择数据的强一致性,建议使用分布式锁的方案,性能上可能没那么高,锁需要等,也有可能产生死锁的问题
如果选择key的逻辑删除,则优先考虑的高可用性,性能比较高,但是数据同步这块做不到强一致。
本文由mdnice多平台发布