缓存穿透
缓存穿透是指客户端请求的数据在缓存中和数据库都不存在,这样缓存永远不存在,这些请求都会打到数据库。
攻击者可以开多个线程连续不断避开缓存去请求数据库,其实就是一直请求数据库中不存在的数据,这样Redis不会缓存,数据库就会被高量的请求一直攻击,最终就有可能数据库服务器崩掉。
常见的解决缓存穿透的方法:
-
缓存空对象(更常见)
即请求数据库中不存在的数据时,给Redis设置缓存空对象,这样攻击再次发生时就能直接命中缓存了。
优点:实现简单,维护简单。
缺点:额外的内存消耗;可能造成短期的不一致。
-
布隆过滤
在客户端请求和缓存之间再加一层布隆过滤器,客户端请求数据之前会先询问布隆过滤器数据是否存在,不存在则直接拒绝,存在则放行给Redis,缓存命中则返回,缓存未命中则查询数据库。
布隆过滤器不是直接重复存入数据,而是将数据映射为二进制标识存入过滤器中。
优点:内存占用较少,没有多余key。
缺点:实现复杂;存在误判可能。
解决缓存穿透的实现
解决缓存穿透前:
public Result queryById(Long id) {
String key = "cache:shop:" + id;
// 1.从Redis中查询商铺缓存
String shopJson = stringRedisTemplate.opsForValue().get(key);
// 2.判断是否存在
if (StrUtil.isNotBlank(shopJson)) {
// 3.存在,直接返回
Shop shop = JSONUtil.toBean(shopJson, Shop.class);
return Result.ok(shop);
}
// 4.不存在,根据id查询数据库
Shop shop = getById(id);
// 5.不存在,返回错误
if (shop == null) {
return Result.fail("店铺不存在");
}
// 6.存在,写入Redis
stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(shop), CACHE_SHOP_TTL, TimeUnit.MINUTES);
return Result.ok(shop);
}
解决后:
public Result queryById(Long id) {
String key = "cache:shop:" + id;
// 1.从Redis中查询商铺缓存
String shopJson = stringRedisTemplate.opsForValue().get(key);
// 2.判断是否存在
if (StrUtil.isNotBlank(shopJson)) {
// 3.存在,直接返回
Shop shop = JSONUtil.toBean(shopJson, Shop.class);
return Result.ok(shop);
}
//缓存命中空值
if (shopJson != null) {
//返回错误信息
return Result.fail("店铺信息不存在!");
}
// 4.不存在,根据id查询数据库
Shop shop = getById(id);
// 5.不存在,返回错误
if (shop == null) {
//缓存空值
stringRedisTemplate.opsForValue().set(key, "", CACHE_NULL_TTL, TimeUnit.MINUTES);
return Result.fail("店铺不存在");
}
// 6.存在,写入Redis
stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(shop), CACHE_SHOP_TTL, TimeUnit.MINUTES);
return Result.ok(shop);
}
当然以上“缓存null值”和“布隆过滤”都是被动解决的方式,我们还可以通过增强id的复杂度,避免被猜测id规律、并在此基础上做好数据的基础格式校验,没有通过校验就拦截请求。
缓存雪崩
缓存雪崩是指同一时段大量的缓存key同时是失效或者Redis服务宕机,导致大量请求到达数据库,带来巨大压力。
常见的应对缓存雪崩的的方法:
-
给不同的Key的TTL(过期时间)添加随机值
针对的是同一时段大量的缓存key同时失效的情况。什么时候会出现同一时段大量的缓存key同时失效的情况?如缓存预热时存入了相同过期时间的大量的缓存key,时间一到就会发生这种情况。
-
利用Redis集群提高服务的高可用性
在Redis集群中实现主从服务器,当主Redis服务器宕机,Redis哨兵机制可以从主从集群中迅速提挑选从服务器替代主服务器,并且从服务器上也同步了数据,这样就可以在很大程度上保证Redis的高可用性。
-
给缓存业务添加降级限流策略
更极端的情况,如果整个Redis服务集群都同时宕机了,我们需要及时地做“服务降级”(如快速失败、拒绝服务),而不是继续将请求压到数据库中,这样就可以通过牺牲一定服务去保护数据库。
-
给业务添加多级缓存
即除了Redis等缓存外,还可以在nginx和jvm中建立缓存。
缓存击穿
缓存击穿问题也叫热点key问题,就是一个被高并发访问并且缓存重建业务较复杂的key突然失效了,无数的请求访问会在瞬间给数据库带来巨大的冲击。
常见的应对缓存击穿的方法:
-
互斥锁
通过时序图,可以看出互斥锁最大的问题就是:大量线程等待。这样会使得性能下降。
-
逻辑过期
逻辑过期并非真的过期,而实际上是永不过期。逻辑过期应对热点key的缓存问题时,是设置一个较长的且可以通过计算进行逻辑判断的过期时间,如果有某个线程查询缓存时发现逻辑时间已过期,那该线程会尝试获取互斥锁,如果获取成功就另开新线程进行缓存的更新操作,而该线程可以直接返回过期数据。如果获取互斥锁失败,就说明此时已经有线程在执行缓存的更新操作了,也可以直接返回过期数据。
两种方案的对比:
利用互斥锁解决缓存击穿问题
下面的逻辑中,最重要的部分就是“获取互斥锁”,不同于synchronized和lock,我们这里需要实现自定义锁,就需要用到redis的string数据类型的“setnx”,setnx为key赋值当且仅当key不存在,这样就可以实现互斥的效果,如果大量线程一起执行setnx操作,有且只能有一个线程能够设置成功。
获取锁:setnx key
释放锁:删除key
避免死锁:设置锁的有效期
/**
* 尝试获取锁
* @param key 请求传入key
* @return 是否成功
*/
private boolean tryLock(String key) {
Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", 10, TimeUnit.SECONDS);
return BooleanUtil.isTrue(flag);
}
/**
* 释放锁
* @param key
*/
private void unLock(String key) {
stringRedisTemplate.delete(key);
}
/**
* 互斥锁应对缓存穿透
* @param id
* @return
*/
private Shop queryWithMutex(Long id) {
String key = "cache:shop:" + id;
// 1.从Redis中查询商铺缓存
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:" + id;
Shop shop = null;
try {
boolean isLock = tryLock(lockKey);
// 4.2.判断获取锁是否成功
if (!isLock){
// 4.3.获取失败,则休眠一段时间后重试
Thread.sleep(50);
return queryWithMutex(id);
}
// 4.4.成功,则根据id查询数据库并重建缓存
shop = getById(id);
// 5.不存在,返回错误
if (shop == null) {
//缓存空值
stringRedisTemplate.opsForValue().set(key, "", CACHE_NULL_TTL, TimeUnit.MINUTES);
return null;
}
// 6.存在,写入Redis
stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(shop), CACHE_SHOP_TTL, TimeUnit.MINUTES);
} catch (InterruptedException e) {
throw new RuntimeException(e);
} finally {
// 7.释放互斥锁
unLock(lockKey);
}
return shop;
}
利用逻辑过期解决缓存击穿问题
涉及的实体类:
@Data
public class RedisData {
private LocalDateTime expireTime;
private Object data;
}
@Data
@EqualsAndHashCode(callSuper = false)
@Accessors(chain = true)
@TableName("tb_shop")
public class Shop implements Serializable {
private static final long serialVersionUID = 1L;
/**
* 主键
*/
@TableId(value = "id", type = IdType.AUTO)
private Long id;
/**
* 商铺名称
*/
private String name;
/**
* 商铺类型的id
*/
private Long typeId;
/**
* 商铺图片,多个图片以','隔开
*/
private String images;
/**
* 商圈,例如陆家嘴
*/
private String area;
/**
* 地址
*/
private String address;
/**
* 经度
*/
private Double x;
/**
* 维度
*/
private Double y;
/**
* 均价,取整数
*/
private Long avgPrice;
/**
* 销量
*/
private Integer sold;
/**
* 评论数量
*/
private Integer comments;
/**
* 评分,1~5分,乘10保存,避免小数
*/
private Integer score;
/**
* 营业时间,例如 10:00-22:00
*/
private String openHours;
/**
* 创建时间
*/
private LocalDateTime createTime;
/**
* 更新时间
*/
private LocalDateTime updateTime;
@TableField(exist = false)
private Double distance;
}
封装重建缓存的方法,方法内封装了逻辑过期时间
/**
* 封装重建缓存设置逻辑过期时间的方法
* @param id
* @param expireSeconds
*/
private void saveShop2Redis(Long id, Long expireSeconds) {
// 1.查询店铺数据
Shop shop = getById(id);
// 2.封装逻辑过期时间
RedisData redisData = new RedisData();
redisData.setData(shop);
redisData.setExpireTime(LocalDateTime.now().plusSeconds(expireSeconds));
// 3.写入Redis
stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY_PREFIX + id, JSONUtil.toJsonStr(redisData));
}
声明线程池
/**
* 声明线程池
*/
private static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10);
/**
* 逻辑过期应对缓存击穿
* @param id
* @return
*/
public Shop queryWithLogicExpire(Long id) {
String key = "cache:shop:" + id;
// 1.从Redis中查询商铺缓存
String shopJson = stringRedisTemplate.opsForValue().get(key);
// 2.判断是否存在
if (StrUtil.isBlank(shopJson)) {
// 3.缓存未命中,直接返回
return null;
}
//缓存命中,先取出value反序列化为对象
RedisData redisData = JSONUtil.toBean(shopJson, RedisData.class);
JSONObject data = (JSONObject) redisData.getData();
Shop shop = JSONUtil.toBean(data, Shop.class);
LocalDateTime expireTime = redisData.getExpireTime();
//判断是否过期
if(expireTime.isAfter(LocalDateTime.now())) {
// 未过期,返回店铺信息
return shop;
}
// 已过期,需要重建缓存
// 获取互斥锁
String lockKey = LOCK_SHOP_KEY_PREFIX + id;
boolean isLock = tryLock(lockKey);
// 判断是否获取互斥锁成功
if (isLock) {// 获取成功
// 判断缓存是否过期
String shopJson_ = stringRedisTemplate.opsForValue().get(key);
if (StringUtils.isBlank(shopJson_)) {
return null;
}
RedisData redisData_ = JSONUtil.toBean(shopJson_, RedisData.class);
Shop shop_ = JSONUtil.toBean((JSONObject) redisData_.getData(), Shop.class);
LocalDateTime exireTime_ = redisData_.getExpireTime();
if (exireTime_.isAfter(LocalDateTime.now())){ //如果在获取锁的时候发现数据更新了则直接返回
return shop_;
}
// 上面都不满足,则开启独立线程,实现缓存重建
CACHE_REBUILD_EXECUTOR.submit(() -> {
try {
// 重建缓存
this.saveShop2Redis(id, 20L);
} catch (Exception e) {
throw new RuntimeException(e);
} finally {
unLock(lockKey);
}
});
}
return shop;
}
封装工具类解决以上问题
@Component
public class CacheClient {
private final StringRedisTemplate stringRedisTemplate;
private static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10);
public CacheClient(StringRedisTemplate stringRedisTemplate) {
this.stringRedisTemplate = stringRedisTemplate;
}
/**
* 写入redis
* @param key
* @param value
* @param time
* @param unit
*/
public void set(String key, Object value, Long time, TimeUnit unit) {
stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(value), time, unit);
}
/**
* 设置逻辑过期时间
* @param key 键
* @param value 值
* @param time 逻辑过期时间
* @param unit 时间单位
*/
public void setWithLogicalExpire(String key, Object value, Long time, TimeUnit unit) {
RedisData redisData = new RedisData();
redisData.setData(value);
redisData.setExpireTime(LocalDateTime.now().plusSeconds(unit.toSeconds(time)));
stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(redisData));
}
public <R, ID> R queryWithPassThrouth(
String keyPrefix, ID id, Class<R> type, Function<ID, R> dbCallBack, Long time, TimeUnit unit) {
String key = keyPrefix + id;
// 1.从Redis中查询商铺缓存
String json = stringRedisTemplate.opsForValue().get(key);
// 2.判断是否存在
if (StrUtil.isNotBlank(json)) {
// 3.存在,直接返回
return JSONUtil.toBean(json, type);
}
//缓存命中空值
if (json != null) {
//返回错误信息
return null;
}
// 4.不存在,根据id查询数据库
R r = dbCallBack.apply(id);
// 5.不存在,返回错误
if (r == null) {
//缓存空值
stringRedisTemplate.opsForValue().set(key, "", CACHE_NULL_TTL, TimeUnit.MINUTES);
return null;
}
// 6.存在,写入Redis
this.set(key, r, time, unit);
return r;
}
/**
* 逻辑过期应对缓存击穿
* @param id
* @return
*/
public <R, ID> R queryWithLogicExpire(
String keyPrefix, ID id, Class<R> type, Function<ID, R> dbCallBack, Long expireSeconds) {
String key = keyPrefix + id;
// 1.从Redis中查询商铺缓存
String json = stringRedisTemplate.opsForValue().get(key);
// 2.判断是否存在
if (StrUtil.isBlank(json)) {
// 3.缓存未命中,直接返回
return null;
}
//缓存命中,先取出value反序列化为对象
RedisData redisData = JSONUtil.toBean(json, RedisData.class);
R r = JSONUtil.toBean((JSONObject) redisData.getData(), type);
LocalDateTime expireTime = redisData.getExpireTime();
//判断是否过期
if(expireTime.isAfter(LocalDateTime.now())) {
// 未过期,返回店铺信息
return r;
}
// 已过期,需要重建缓存
// 获取互斥锁
String lockKey = LOCK_SHOP_KEY_PREFIX + id;
boolean isLock = tryLock(lockKey);
// 判断是否获取互斥锁成功
if (isLock) {// 获取成功
// 判断缓存是否过期
String json_ = stringRedisTemplate.opsForValue().get(key);
if (StringUtils.isBlank(json_)) {
return null;
}
RedisData redisData_ = JSONUtil.toBean(json_, RedisData.class);
R r_ = JSONUtil.toBean((JSONObject) redisData_.getData(), type);
LocalDateTime exireTime_ = redisData_.getExpireTime();
if (exireTime_.isAfter(LocalDateTime.now())){ //如果在获取锁的时候发现数据更新了则直接返回
return r_;
}
// 上面都不满足,则开启独立线程,实现缓存重建
CACHE_REBUILD_EXECUTOR.submit(() -> {
try {
// 重建缓存
this.saveShop2Redis(keyPrefix, id, expireSeconds, dbCallBack);
} catch (Exception e) {
throw new RuntimeException(e);
} finally {
unLock(lockKey);
}
});
}
return r;
}
/**
* 尝试获取锁
* @param key 请求传入key
* @return 是否成功
*/
private boolean tryLock(String key) {
Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", 10, TimeUnit.SECONDS);
return BooleanUtil.isTrue(flag);
}
/**
* 释放锁
* @param key
*/
private void unLock(String key) {
stringRedisTemplate.delete(key);
}
/**
* 封装重建缓存设置逻辑过期时间的方法
* @param id
* @param expireSeconds
*/
public <R, IDTYPE> void saveShop2Redis(String keyPrefix, IDTYPE id, Long expireSeconds, Function<IDTYPE, R> dbCallback) {
// 1.查询店铺数据
R r = dbCallback.apply(id);
// 2.封装逻辑过期时间
try {
Thread.sleep(200);
} catch (InterruptedException e) {
e.printStackTrace();
}
RedisData redisData = new RedisData();
redisData.setData(r);
redisData.setExpireTime(LocalDateTime.now().plusSeconds(expireSeconds));
// 3.写入Redis
stringRedisTemplate.opsForValue().set(keyPrefix + id, JSONUtil.toJsonStr(redisData));
}
}