1. 什么是缓存?
数据交换的缓冲区(cache),是存贮数据的临时地方,一般读写性能比较高。
缓存可以大大降低用户访问并发量带来的服务器读写压力。
2.添加商户缓存
- 缓存模型
- 缓存流程
- 代码实现
@Service
public class ShopServiceImpl extends ServiceImpl<ShopMapper, Shop> implements IShopService {
@Resource
private StringRedisTemplate stringRedisTemplate;
@Override
public Result queryById(Long id) {
// 1.从redis查询商铺缓存(这里采用String类型,然后要确保店铺id的唯一)
String shopJson = stringRedisTemplate.opsForValue().get(SystemConstants.SHOP_KEY_PRE + id);
// 2.判断商铺是否存在
if (StrUtil.isNotBlank(shopJson)) {
// 3.存在,将从redis去到的json字符串转换成shop对象返回
Shop shop = JSONUtil.toBean(shopJson, Shop.class);
return Result.ok(shop);
}
// 4.不存在,根据id查询数据库
Shop shop = getById(id);// 这是MP中继承了ServiceImpl中有的方法
if (shop == null) {
// 5.数据库不存在商铺信息,返回错误
return Result.fail("店铺不存在!");
}
// 6.存在,将商铺信息写入到redis(要将shop对象转成json字符串)
String jsonStr = JSONUtil.toJsonStr(shop);
stringRedisTemplate.opsForValue().set(SystemConstants.SHOP_KEY_PRE + id, jsonStr);
// 7.返回
return Result.ok(shop);
}
}
3.缓存更新策略
当缓存中数据过多,需要对部分数据清除;
要实现数据库数据更新后,数据库和缓存数据一致。
- 主动更新策略
需要考虑三个问题:- 选择更新缓存?还是删除缓存?
- 如何保证更新数据库和删除缓存同时成功或失败?
- 先更新数据库还是先删除缓存?
应当是先操作数据库,再删除缓存,
第一种发生极端情况发生的概率较大。因为redis写入数据是微秒级别的,相较于数据库写入是非常快的。在更新数据库时,用时较长,这时其他线程有可能抢到CPU使用权,进行查询缓存,但是线程1已经删除了缓存,所以线程2也去查询数据库,但是线程1 更新的数据还没有提交,因此线程2 查到的数据还是旧数据,再次将旧数据写入缓存。
第二种情况发生的概率较小。因为线程2先更新数据库,然后删除缓存(redis中读写速度快,所以在删除缓存时线程1争夺CPU使用权的概率较小)。所以即使在查询数据库时,数据读取到了,但是还没写入到缓存,线程2这时抢到了CPU使用权更新数据库,删除缓存,所以线程1读到的是旧数据,但是我在写入缓存的时候设置TTL过期兜底,这样就能有效减少旧数据的影响。
- 代码实现:
@Service
public class ShopServiceImpl extends ServiceImpl<ShopMapper, Shop> implements IShopService {
@Resource
private StringRedisTemplate stringRedisTemplate;
@Override
public Result queryById(Long id) {
// 1.从redis查询商铺缓存(这里采用String类型,然后要确保店铺id的唯一)
String shopJson = stringRedisTemplate.opsForValue().get(SystemConstants.SHOP_KEY_PRE + id);
// 2.判断商铺是否存在
if (StrUtil.isNotBlank(shopJson)) {
// 3.存在,将从redis去到的json字符串转换成shop对象返回
Shop shop = JSONUtil.toBean(shopJson, Shop.class);
return Result.ok(shop);
}
// 4.不存在,根据id查询数据库
Shop shop = getById(id);
if (shop == null) {
// 5.数据库不存在商铺信息,返回错误
return Result.fail("店铺不存在!");
}
// 6.存在,将商铺信息写入到redis(要将shop对象转成json字符串)
String jsonStr = JSONUtil.toJsonStr(shop);
// 6.1设置TTL超时剔除
stringRedisTemplate.opsForValue().set(SystemConstants.SHOP_KEY_PRE + id, jsonStr, SystemConstants.SHOP_TTL, TimeUnit.MINUTES);
// 7.返回
return Result.ok(shop);
}
}
@Override
@Transactional
/**
* 缓存更新
* 先更新数据库,后删除缓存
* */
public Result saveShop(Shop shop) {
Long id = shop.getId();
if (id == null){
return Result.fail("店铺 id 不能为空!");
}
// 更新数据库
updateById(shop);
// 删除缓存
stringRedisTemplate.delete(RedisConstants.CACHE_SHOP_KEY + id);
return Result.ok(shop.getId());
}
4.缓存穿透的解决思路
缓存穿透:是指客户端请求的数据在缓存中额数据库中都不存在,这样缓存永远不会生效,这些请求都会打到数据库。所以有些不怀好意的人可能会利用该穿透,并发向服务器发送一些不存在的数据,这样可能会搞垮数据库。
解决方案:
缓存空对象;布隆过滤器
- 缓存空对象缺点详细描述:首先有可能造成大量额外的内存消耗,因为可能会有人恶意大量请求不存在的数据;第二点,有可能刚请求完返回了Redis的空数据,而这时数据库插入了该条数据,导致数据库与Redis数据的不一致。可以设置TTL,减少这种额外内存消耗和错误的影响。
- 布隆过滤器:使用二进制形式存放数据库中对数据哈希后的哈希值,但这是概率算法,如果不用过滤器拒绝,则表示数据库真的不存在该数据,但过滤器放行了,数据库不一定存在该数据,所以还是存在穿透风险。
编码解决商品查询的缓存穿透问题:
@Resource
private StringRedisTemplate stringRedisTemplate;
@Override
public Result queryById(Long id) {
// 1.从redis查询商铺缓存(这里采用String类型,然后要确保店铺id的唯一)
String shopJson = stringRedisTemplate.opsForValue().get(SystemConstants.SHOP_KEY_PRE + id);
// 2.判断商铺是否存在
if (StrUtil.isNotBlank(shopJson)) {
// 3.存在,将从redis去到的json字符串转换成shop对象返回
Shop shop = JSONUtil.toBean(shopJson, Shop.class);
return Result.ok(shop);
}
// --穿透解决,判断命中的是否是空值
if(shopJson != null){
//返回错误信息
return Result.fail("店铺信息不存在!");
}
// 4.不存在,根据id查询数据库
Shop shop = getById(id);
if (shop == null) {
// 将空值写入缓存
stringRedisTemplate.opsForValue().set(SystemConstants.SHOP_KEY_PRE + id, "", SystemConstants.SHOP_NULL_TTL, TimeUnit.MINUTES);
// 5.数据库不存在商铺信息,返回错误
return Result.fail("店铺不存在!");
}
// 6.存在,将商铺信息写入到redis(要将shop对象转成json字符串)
String jsonStr = JSONUtil.toJsonStr(shop);
// 6.1设置TTL超时剔除
stringRedisTemplate.opsForValue().set(SystemConstants.SHOP_KEY_PRE + id, jsonStr, SystemConstants.SHOP_TTL, TimeUnit.MINUTES);
// 7.返回
return Result.ok(shop);
}
5.缓存雪崩问题及解决思路
6.缓存击穿问题及解决思路
缓存击穿和缓存雪崩的区别就在于:缓存雪崩是同一时间段大量key同时失效或redis服务宕机,导致给数据库造成巨大压力;而缓存击穿是某些热点(一个可以被高并发访问并且缓存重建业务较复杂,耗时较长)的key在同一时间段突然失效了,无数的请求访问会在瞬间给数据库带来巨大的冲击。比如上述图片,缓存查询(读操作)未命中,所需时间较短,但是查询数据库(其中需要的准备)的时间较长,可能其他线程也在查询未命中的情况下查询数据库并重建缓存,导致数据库垮掉。
常见的解决方案有两种:互斥锁;逻辑过期
- 互斥锁
添加互斥锁,缓存未命中的线程只有获取到互斥锁才可以查询数据库,重建缓存数据。但需要等待线程结束后另一个线程才能获得锁查询,效率较低。
/**
* 互斥锁 获取锁
* 使用 redis 的 setnx 命令达到加锁的效果
* */
private boolean tryLock(String key){
// 使用redis中的SETNX key value digital timeUnit来模拟获得锁
Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, "1",
RedisConstants.LOCK_SHOP_TTL, TimeUnit.SECONDS);
// 不能直接返回,有可能null的时候自动装箱,然后就变成false
return BooleanUtil.isTrue(flag);
}
/**
* 互斥锁 释放锁
* */
private void unlock(String key){
stringRedisTemplate.delete(key);
}
/**
* 缓存击穿 互斥锁
* 缓存穿透 缓存 null 值
* */
public Shop queryWithMutex(Long id) {
// 从 redis 查询商铺缓存
String shopJSON = stringRedisTemplate.opsForValue().get(RedisConstants.CACHE_SHOP_KEY + id);
// 判断是否存在
if (StrUtil.isNotBlank(shopJSON)){
return JSONUtil.toBean(shopJSON, Shop.class);
}
// 判断是否命中
if (shopJSON != null){
// 此时为“”,即发生缓存穿透
return null;
}
// 数据库查询
// 获取互斥锁
String lockKey = RedisConstants.LOCK_SHOP_KEY + id;
Shop shop = null;
try {
boolean isLock = tryLock(lockKey);
if (!isLock){
// 获取锁失败,休眠后重试
Thread.sleep(50);
return queryWithMutex(id);
}
// 成功获取互斥锁,查询数据库
shop = this.getById(id);
// 数据库不存在该数据
if (shop == null){
// 为了避免缓存穿透,缓存 null 值
stringRedisTemplate.opsForValue().set(RedisConstants.CACHE_SHOP_KEY + id,
"", RedisConstants.CACHE_NULL_TTL,
TimeUnit.MINUTES);
return null;
}
// 放入缓存
String str = JSONUtil.toJsonStr(shop);
stringRedisTemplate.opsForValue().set(RedisConstants.CACHE_SHOP_KEY + id,
str, RedisConstants.CACHE_SHOP_TTL,
TimeUnit.MINUTES);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}finally {
// 释放锁
unlock(lockKey);
}
return shop;
}
@Override
/**
* 缓存穿透 缓存 null 值
* */
public Result queryById(Long id) {
Shop shop = queryWithMutex(id);
if (shop == null){
return Result.fail("店铺不存在!");
}
return Result.ok(shop);
}
- 逻辑过期
在设置缓存的时候,不设置TTL,但是在key对应的value上添加expire字段值,就是在查询到该缓存时,查看expire值,通过判断expire值来判断该缓存是否过期,如果过期表明这是旧的数据,需要更新,但是更新操作通知另一个线程操作,也需要获得互斥锁,当前线程就先返回旧数据。这样客户端用户就不会因为查询数据库,重建缓存数据这步操作而等待了。
关于在value(Shop对象)中添加过期时间如果解决:
- 在Shop对象中添加一个字段:不推荐,对原来代码需要作出修改,不友好
- 在util中新建一个RedisData,然后定义一个属性字段LocalDateTime,然后Shop对象继承LocalDateTime,但这样还是需要修改源代码
- 在util中新建一个RedisData,然后定义一个属性字段LocalDateTime,然后再添加一个Object data
@Data
public class RedisData {
private LocalDateTime expireTime;
private Object data;
}
先创建一个方法,将商铺保存至Redis中,
/**
* 缓存击穿-逻辑过期-缓存预热,将热点 key 提前放入 redis
* */
public void saveShopRedis(Long id, Long expireSecond) {
RedisData redisData = new RedisData();
redisData.setExpireTime(LocalDateTime.now().plusSeconds(expireSecond));
redisData.setData(this.getById(id));
stringRedisTemplate.opsForValue().set(RedisConstants.CACHE_SHOP_KEY+id,
JSONUtil.toJsonStr(redisData));
}
然后开启一个线程池,里面有10个线程,
/** 线程池 */
private static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10);;
逻辑过期方法,
/**
* 缓存击穿 逻辑过期
* */
public Shop queryLogicExpire(Long id){
String key = RedisConstants.CACHE_SHOP_KEY + id;
// 从 redis 缓存获取
String jsonRedisData = stringRedisTemplate.opsForValue().get(key);
// 判断缓存是否命中
if (StrUtil.isBlank(jsonRedisData)){
// 未命中
return null;
}
// 命中
RedisData redisData = JSONUtil.toBean(jsonRedisData, RedisData.class);
Shop shop = JSONUtil.toBean((JSONObject) redisData.getData(), Shop.class);
LocalDateTime expireTime = redisData.getExpireTime();
// 判断是否过期
if (expireTime.isAfter(LocalDateTime.now())){
// 未过期
return shop;
}
// 过期,进行缓存重建
// 判断是否获取锁
String lockKey = RedisConstants.LOCK_SHOP_KEY + id;
boolean isLock = tryLock(lockKey);
if (isLock){
// 开启线程重建缓存
CACHE_REBUILD_EXECUTOR.submit( () -> {
try {
this.saveShopRedis(id, 20L);
}catch (Exception e){
throw new RuntimeException(e);
}finally {
unlock(lockKey);
}
});
}
return shop;
}