【Redis笔记】缓存穿透与缓存击穿以及应对方法
一、缓存穿透
1. 缓存穿透概念
缓存穿透指查询不存在的数据时,不会命中缓存(缓存层),便会向数据库(持久层)发送查询请求,但同样查不到数据。
会给数据库性能带来压力
2. 缓存穿透解决方法
持久层查询到不存在的空数据时,也将其添加至缓存,并设置过期时间
对空数据进行缓存,可以避免重复查找空数据给数据库带来过多压力
访问无效数据的请求,一般只会在短期内重复访问(误操作或恶意访问),可以设置较短的过期时间,如 5 分钟,便于节省空间
示例代码
场景:根据用户 id 查看某用户的信息
业务层 UserServiceImpl 示例如下:
@Slf4j
@Service
public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements IUserService {
@Resource
private StringRedisTemplate stringRedisTemplate;
@Override
public User getByIdTreatPenetration(Long id) {
// 在缓存中查找用户
String key = "cache:user:" + id;
String userStr = stringRedisTemplate.opsForValue().get(key);
// 判断缓存是否命中,命中则直接返回
if (userStr != null) {
if (userStr.equals(""))
return null;
else
return JSONUtil.toBean(userStr, User.class);
}
// 缓存未命中,从数据库查找
User user = this.getById(id);
// 将数据存入缓存
stringRedisTemplate.opsForValue().set(
key
, (user == null) ? "" : JSONUtil.toJsonStr(user) // 空数据缓存空字符串
, (user == null) ? 5 : 60 // 空数据缓存 5 分钟,非空缓存 60 分钟
, TimeUnit.MINUTES
);
return user;
}
}
二、缓存击穿
1. 缓存击穿概念
缓存击穿是指,当某个热点数据的缓存过期后,服务器同时收到大量查询此数据的请求,此时新的缓存尚未建立,就会向数据库发送大量查询请求
2. 缓存击穿解决方法
方法一:互斥锁
缓存未命中时获取锁,建立新的缓存,其它线程等待锁释放后再查找数据
如此可以达到 同一时刻对同一数据只有一个请求在访问数据库
【互斥锁特点】
- 缓存正在被建立时,其它请求会等待建立完成,速度可能较慢
- 用户查到的数据严格与数据库一致
示例代码
同样根据用户 id 查询某用户的信息
锁的操作使用了 Redisson,在处理缓存穿透的基础上,为未命中缓存的部分补充锁的部分即可
@Slf4j
@Service
public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements IUserService {
@Resource
private StringRedisTemplate stringRedisTemplate;
@Override
public User getByIdTreatBreakdownWithLock(Long id) {
// 在缓存中查找用户
String key = "user:" + id;
String userStr = stringRedisTemplate.opsForValue().get(key);
// 判断缓存是否命中,命中则直接返回
if (userStr != null) {
if (userStr.equals(""))
return null;
else
return JSONUtil.toBean(userStr, User.class);
}
// 缓存未命中,获取锁,查询数据库并建立缓存
// 获取锁 Redisson 对象
String lockName = "lock:user" + id;
Config config = new Config();
config.useSingleServer()
.setAddress("redis://192.168.100.103:6379")
.setPassword("123456")
.setDatabase(0);
RLock lock = Redisson.create(config).getLock(lockName);
try {
// 尝试获取锁
boolean flag = lock.tryLock();
if (!flag) {
// 获取锁失败,锁已被占用,线程睡眠 50 毫秒,然后递归调用此方法查询数据
Thread.sleep(50);
return this.getByIdTreatBreakdownWithLock(id);
}
// 从数据库查询
User user = this.getById(id);
// 将数据存入缓存
stringRedisTemplate.opsForValue().set(
key
, (user == null) ? "" : JSONUtil.toJsonStr(user) // 空数据缓存空字符串
, (user == null) ? 5 : 60 // 空数据缓存 5 分钟,非空缓存 60 分钟
, TimeUnit.MINUTES
);
return user;
} catch (Exception e) {
throw new RuntimeException(e.getMessage());
} finally {
// 释放锁
lock.unlock();
}
}
}
方法二:设置逻辑过期时间
前提:数据被建立时就加入缓存,并设置逻辑过期时间(实际缓存过期时间为 -1)
缓存未命中时,即说明数据库中不存在此数据,返回空
缓存命中时,查看逻辑过期时间是否过期,若已过期,返回已过期的数据,并在新线程中重新建立缓存并设置逻辑过期时间
【逻辑过期特点】
- 数据被建立时就必须加入缓存
- 异步建立缓存,不影响其它请求,速度较快
- 由于逻辑过期依然返回已过期的数据,用户查到的数据可能与数据库一致
示例代码
根据用户 id 查询某用户的信息
由于要设置逻辑过期时间,可以将实际数据与过期时间包装成类,示例如下:
@Data
public RedisData getRedisData(String key) {
String jsonStr = get(key);
if (jsonStr == null) {
return null;
}
return JSONUtil.toBean(jsonStr, RedisData.class);
}
业务层示例如下:
@Slf4j
@Service
public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements IUserService {
// 线程池
private static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10);
@Resource
private StringRedisTemplate stringRedisTemplate;
@Resource
private RedissonClient redissonClient;
@Override
public User getByIdTreatBreakdownWithLogicExpire(Long id) {
String key = "user:" + id;
// 查询缓存
String jsonStr = stringRedisTemplate.opsForValue().get(key);
// 缓存未命中,即代表数据不存在,返回空
if (redisData == null) {
return null;
}
// 将缓存字符串装换为对象
RedisData redisData = JSONUtil.toBean(jsonStr, RedisData.class);
JSONObject jsonObject = (JSONObject) redisData.getData();
User user = jsonObject.toBean(User.class);
LocalDateTime expirationTime = redisData.getExpirationTime();
// 未到期,直接返回
if (LocalDateTime.now().isBefore(expirationTime)) {
return user;
}
// 已到期,重建缓存
String lockName = "lock:user:" + id;
RLock lock = redissonClient.getLock(lockName);
boolean flag = lock.tryLock();
if (flag) {
// 获取锁后进行二次判断,现有缓存是否过期
jsonStr = stringRedisTemplate.opsForValue().get(key);
redisData = JSONUtil.toBean(jsonStr, StringRedisCacheUtil.RedisData.class);
expirationTime = redisData.getExpirationTime();
// 第二次判断未到期,说明缓存已被别的线程重建,返回结果
if (LocalDateTime.now().isBefore(expirationTime)) {
return user;
}
// 新线程中重建缓存
CACHE_REBUILD_EXECUTOR.submit(() -> {
User user1 = this.getById(id);
StringRedisCacheUtil.RedisData redisData1 = new StringRedisCacheUtil.RedisData();
redisData1.setData(user1);
redisData1.setExpirationTime(LocalDateTime.now().plusMinutes(60));
stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(redisData1));
lock.unlock();
});
}
return user;
}
}
三、工具类示例
@Component
public class StringRedisCacheUtil {
private static StringRedisTemplate stringRedisTemplate;
private static RedissonClient redissonClient;
private static ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10);
@Data
public static class RedisData {
private Object data;
private LocalDateTime expirationTime;
}
private static Long NULL_CACHE_TTL = 5L;
private static TimeUnit NULL_CACHE_TIMEUNIT = TimeUnit.MINUTES;
// Spring 容器启动时,为静态变量注入对象
// ----------------------------------------------------
@Autowired
private StringRedisTemplate redisTemplate;
@Autowired
private RedissonClient client;
@PostConstruct
private void init() {
stringRedisTemplate = this.redisTemplate;
redissonClient = client;
}
private StringRedisCacheUtil() {
}
// ----------------------------------------------------
// 根据键获取值
public static String get(String key) {
return stringRedisTemplate.opsForValue().get(key);
}
// 根据键获取带逻辑时间的值
public static RedisData getRedisData(String key) {
String jsonStr = get(key);
if (jsonStr == null) {
return null;
}
return JSONUtil.toBean(jsonStr, RedisData.class);
}
// 判断是否超过逻辑过期时间
public static boolean isExpired(RedisData redisData) {
return redisData == null || LocalDateTime.now().isAfter(redisData.getExpirationTime());
}
// 判断是否还没有逻辑过期时间
public static boolean isNotExpired(RedisData redisData) {
return !isExpired(redisData);
}
// 设置缓存
public static void set(String key, String value, Long timeout, TimeUnit unit) {
stringRedisTemplate.opsForValue().set(key, value, timeout, unit);
}
// 为空数据设置缓存
public static void setNull(String key) {
set(key, "", NULL_CACHE_TTL, NULL_CACHE_TIMEUNIT);
}
// 设置缓存并带有逻辑过期时间
public static void setWithLogicalExpire(String key, Object value, Long timeout, TimeUnit unit) {
RedisData redisData = new RedisData();
redisData.setData(value);
LocalDateTime now = LocalDateTime.now();
redisData.setExpirationTime(now.plusMinutes(unit.toSeconds(timeout)));
stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(redisData));
}
/**
* 根据 id 查询数据,并解决缓存穿透问题(不解决击穿问题)
*
* @param keyPrefix 键前缀
* @param id 主键 id
* @param resultType 返回类型的 Class
* @param dbCallbackFunc 缓存未命中时,查询数据库所用的回调函数
* @param timeOut 过期时间
* @param unit 时间单位
* @param <R> 返回类型
* @param <ID> 主键 id 类型
* @return 根据 id 查询到的数据或 null
*/
public static <R, ID> R getByIdTreatPenetration(String keyPrefix, ID id, Class<R> resultType,
Function<ID, R> dbCallbackFunc, Long timeOut, TimeUnit unit) {
String key = keyPrefix + id.toString();
String JSONStr = get(key);
if (JSONStr != null) {
if (JSONStr.equals("")) return null;
return JSONUtil.toBean(JSONStr, resultType);
}
R result = dbCallbackFunc.apply(id);
if (result == null) setNull(key);
else set(key, JSONUtil.toJsonStr(result), timeOut, unit);
return result;
}
/**
* 根据 id 查询数据,利用互斥锁解决缓存击穿问题
*
* @param keyPrefix 键前缀
* @param lockNamePrefix 锁名称前缀
* @param id 主键 id
* @param resultType 返回类型的 Class
* @param dbCallbackFunc 缓存未命中时,查询数据库所用的回调函数
* @param timeOut 过期寿命
* @param unit 时间单位
* @param <R> 返回类型
* @param <ID> 主键 id 类型
* @return 根据 id 查询到的数据或 null
*/
public static <R, ID> R getByIdTreatBreakdownWithLock(String keyPrefix, String lockNamePrefix, ID id, Class<R> resultType,
Function<ID, R> dbCallbackFunc, Long timeOut, TimeUnit unit) {
String key = keyPrefix + id.toString();
String JSONStr = get(key);
if (JSONStr != null) {
if (JSONStr.equals("")) return null;
return JSONUtil.toBean(JSONStr, resultType);
}
String lockName = lockNamePrefix + id;
RLock lock = redissonClient.getLock(lockName);
try {
boolean flag = lock.tryLock();
if (!flag) {
Thread.sleep(50);
return getByIdTreatBreakdownWithLock(keyPrefix, lockNamePrefix, id, resultType, dbCallbackFunc, timeOut, unit);
}
R result = dbCallbackFunc.apply(id);
if (result == null) setNull(key);
else set(key, JSONUtil.toJsonStr(result), timeOut, unit);
return result;
} catch (Exception e) {
throw new RuntimeException(e.getMessage());
} finally {
// 释放锁
lock.unlock();
}
}
/**
* 根据 id 查询数据,利用逻辑过期解决缓存击穿问题(缓存未命中则代表不存在数据,不考虑穿透问题)
*
* @param keyPrefix 键前缀
* @param lockNamePrefix 锁名称前缀
* @param id 主键 id
* @param resultType 返回类型的 Class
* @param dbCallbackFunc 缓存未命中时,查询数据库所用的回调函数
* @param timeOut 逻辑过期寿命
* @param unit 时间单位
* @param <R> 返回类型
* @param <ID> 主键 id 类型
* @return 根据 id 查询到的数据
*/
public static <R, ID> R getByIdTreatBreakdownWithLogicExpire(String keyPrefix, String lockNamePrefix, ID id, Class<R> resultType,
Function<ID, R> dbCallbackFunc, Long timeOut, TimeUnit unit) {
String key = keyPrefix + id;
RedisData redisData = getRedisData(key);
if (redisData == null) {
return null;
}
JSONObject jsonObject = (JSONObject) redisData.getData();
R result = JSONUtil.toBean(jsonObject, resultType);
if (isNotExpired(redisData)) {
return result;
}
String lockName = lockNamePrefix + id.toString();
RLock lock = redissonClient.getLock(lockName);
boolean flag = lock.tryLock();
if (flag && isExpired(getRedisData(key))) {
CACHE_REBUILD_EXECUTOR.submit(() -> {
R data = dbCallbackFunc.apply(id);
setWithLogicalExpire(key, data, timeOut, unit);
lock.unlock();
});
}
return result;
}
}