目录
1 前言
1.1什么是缓存?
1.2 缓存的作用及成本
1.3 Redis缓存模型
2 给商户信息添加缓存
3 缓存更新策略
3.1 更新策略介绍
3.2 主动更新策略
3.3 主动更新策略练习
4 缓存穿透及其解决方案
4.1 缓存穿透的概念
4.2 解决方案及实现
5 缓存雪崩的概念及其解决方案
6 缓存击穿及解决方案
6.1什么是缓存击穿?
6.2 缓存击穿解决方法
6.2.1 互斥锁
6.2.2 逻辑过期
缓存就是数据交换的缓冲区(称作Cache [ kæʃ ] ),是存贮数据的临时地方,一般读写性能较高。
缓存有很多中实现场景:对于web开发,常见的有如下几种:
作用:毫无疑问,就是提高读写的效率,有效降低后端服务器的负载,有效降低响应时间。
成本:任何东西都有两面性,缓存在带来高效的读写效率的同时,也有着对应的从成本。
比如:数据一致性成本、代码维护成本、运维成本等。
如下图
原本的模型应该是客户端发送请求给数据库,数据库返回数据给客户端,而Reids的缓存模型就是在原有的基础上,在中间加上一层Redis(经典的中间件思想~) 用户每次都会先去redis中查找数据,如果未命中才会去数据库中查找数据,并写入Reis当中,这么一来,用于下次需要相同的数据的时候,就可以在Reis当中进行获取,又因为Redis的高读写效率,实现了缓存的效果~
基于上述的Redis缓存模型,我们可以得出下面的缓存添加逻辑:
代码实现:(直接看Service层实现)
每次查询到商品用户信息后,添加缓存。
package com.hmdp.service.impl;
import cn.hutool.core.util.StrUtil;
import cn.hutool.json.JSONUtil;
import com.hmdp.dto.Result;
import com.hmdp.entity.Shop;
import com.hmdp.mapper.ShopMapper;
import com.hmdp.service.IShopService;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import javax.annotation.Resource;
import java.util.concurrent.TimeUnit;
import static com.hmdp.utils.RedisConstants.CACHE_SHOP_KEY;
import static com.hmdp.utils.RedisConstants.CACHE_SHOP_TTL;
/**
*
* 服务实现类
*
*
* @author 虎哥
* @since 2021-12-22
*/
@Service
public class ShopServiceImpl extends ServiceImpl implements IShopService {
@Resource
private StringRedisTemplate stringRedisTemplate;
@Override
public Result queryShopById(Long id) {
//1.去redis中查询商品是否存在
String key = CACHE_SHOP_KEY+id;
String shopJson = stringRedisTemplate.opsForValue().get(key);
if (StrUtil.isNotBlank(shopJson)){
//2.存在,直接返回给用户
Shop shop = JSONUtil.toBean(shopJson, Shop.class);
return Result.ok(shop);
}
//3.不存在,带着id去数据库查询是否存在商品
Shop shop = getById(id);
if (shop == null){
//4.不存在,返回错误信息
Result.fail("商品信息不存在!");
}
//5.存在,存入redis
stringRedisTemplate.opsForValue().set(key,JSONUtil.toJsonStr(shop));
//6.返回商品信息
return Result.ok(shop);
}
}
使用Redis做缓存时,缓存还是要及时更新的,不然就会出现数据库和缓存数据不一致的情况,也是我们常说的Redis成本——数据一致性成本
缓存大致有以下三种更新策略:
1. 内存淘汰策略:这种策略没有维护成本,这是利用Redis的内存淘汰机制,当内存不足的时候自动淘汰,下次请求时再继续存入数据。
这种策略模型优点在于没有维护成本,但是内存不足这种无法预定的情况就导致了缓存中会有很多旧的数据,数据一致性差。
2. 超时剔除:这种策略就是比较实用的,就是给缓存添加TTL存活时间,下次查询是更新缓存。
这种策略数据一致性一般,维护成本有但是较低,一般用于兜底方案~
3.主动更新策略:就是我们程序员手动的进行数据库和缓存之间的更新,但数据库更新时,缓存也进行更新。
这种策略数据一致性就是最高的(毕竟自己动手,丰衣足食),但同时维护成本也是最高的。
我的觉得应该是根据业务场景不同来选择不同的更新策略:
当数据一致性要求低时:l使用内存淘汰机制。例如店铺类型的查询缓存。
当有高一致性需求:使用主动更新,并以超时剔除作为兜底方案。例如店铺详情查询的缓存
上述提到的主动更新策略,无疑是维护成本最高的,但具体又有哪些维护方式呢?怎么去做主动更新维护呢?
如下图:主动更新主要分为下面三种:
第一种:Cache Aside Pattern,由缓存调用者进行操作,就是在我们数据库进行更新时,对缓存也进行更新。
这又引出了好几个问题了~
1. 缓存更新?是更新缓存还是直接删除缓存?
2. 如何保证数据库更新和缓存更新同时成功或失败?
3. 操作时应该先操作缓存还是先操作数据库?
第一个问题:
我想说,缓存更新如果是数据更新的话,每次更新数据库都要对缓存数据进行更新,有太多无效的读写操作,所以操作缓存时,选择删除缓存~
第二个问题:
要做到两个操作一致性,第一想到的就应该是事务。
解决方案:当我们是单体系统时,将缓存和数据库操作放在同一个事务里。
当我们是分布式系统时,利用TTC等分布式事务方案
最后一个问题:先操作数据库还是先操作缓存?
就只有下面两种情况:
1. 先删除缓存,再操作数据库 2. 先操作数据库,再删除缓存
我们可以两种情况都来看看~
第一种情况,先删除缓存的情况,我们想的正常的不会出现问题的操作流程(左边)和操作会出现问题的流程 (右边)
补充一下:这边两个线程最初的数据库和缓存数据都假定为10~
出现问题的情况为我们线程1先删除缓存,线程2未命中缓存,直接查到数据库中数据为10并写入缓存中,而线程1在线程2后写入缓存后,把数据库更新成了20,这样最后数据库数据为20,而缓存中的数据为10,这就导致数据不一致了。
2.先操作数据库的情况:
正确情况(左边)错误情况(右边),数据库和缓存最初数据也都还是10~
第二种先操作数据库的方式,它出现的错误情况主要是,线程1线程查询了缓存,未命中后去查询数据库的同时,线程2更新了数据库,并且删除了缓存,然后线程1才把数据库中查出来的10写入缓存,导致缓存为10,数据库为20。
终上所述,第一种失败的情况是比第二种失败的情况多的,因为第一种情况出现错误的时间是在删除缓存并更新数据库后,线程2有着充足的时间在这段时间内写入缓存,而对于第二种情况来说,出现问题发生在查完数据库到写入缓存这段时间内,这段时间几乎是毫秒级别的,线程2在这段时间内更新数据库并删除缓存,显然几率是很低的(除了高高高并发的状况下),所以我们选择先操作数据库在操作缓存~
总结:
缓存更新策略的最佳实践方案:
1.低一致性需求:使用Redis自带的内存淘汰机制
2.高一致性需求:主动更新,并以超时剔除作为兜底方案
主动更新时
进行读操作:
进行写操作:
修改商品缓存
1. 根据id查询店铺时,如果缓存未命中,则查询数据库,将数据库结果写入缓存,并设置超时时间
2. 根据id修改店铺时,先修改数据库,再删除缓存
实现代码如下(Service层实现):
package com.hmdp.service.impl;
import cn.hutool.core.util.StrUtil;
import cn.hutool.json.JSONUtil;
import com.hmdp.dto.Result;
import com.hmdp.entity.Shop;
import com.hmdp.mapper.ShopMapper;
import com.hmdp.service.IShopService;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import javax.annotation.Resource;
import java.util.concurrent.TimeUnit;
import static com.hmdp.utils.RedisConstants.CACHE_SHOP_KEY;
import static com.hmdp.utils.RedisConstants.CACHE_SHOP_TTL;
/**
*
* 服务实现类
*
*
* @author 虎哥
* @since 2021-12-22
*/
@Service
public class ShopServiceImpl extends ServiceImpl implements IShopService {
@Resource
private StringRedisTemplate stringRedisTemplate;
@Override
public Result queryShopById(Long id) {
//1.去redis中查询商品是否存在
String key = CACHE_SHOP_KEY+id;
String shopJson = stringRedisTemplate.opsForValue().get(key);
if (StrUtil.isNotBlank(shopJson)){
//2.存在,直接返回给用户
Shop shop = JSONUtil.toBean(shopJson, Shop.class);
return Result.ok(shop);
}
//3.不存在,带着id去数据库查询是否存在商品
Shop shop = getById(id);
if (shop == null){
//4.不存在,返回错误信息
Result.fail("商品信息不存在!");
}
//5.存在,存入redis(并设置超时时间)
stringRedisTemplate.opsForValue().set(key,JSONUtil.toJsonStr(shop),CACHE_SHOP_TTL, TimeUnit.MINUTES);
//6.返回商品信息
return Result.ok(shop);
}
@Override
@Transactional
public Result updateShop(Shop shop) {
//1.先更新数据库
updateById(shop);
//2.删除redis缓存
String key = CACHE_SHOP_KEY+shop.getId();
stringRedisTemplate.delete(key);
return null;
}
}
这样就能达到基本的主动缓存更新啦~
缓存穿透是指客户端请求的数据在缓存中和数据库中都不存在,这样缓存永远不会生效,这些请求都会打到数据库。
通俗的说,就是请求的数据在数据库和缓存中都没有,最后请求都到了数据库。这个时候,如果有一个恶意的程序员,通过某种方式不断请求一个不存在的数据,这样就会给数据库带来巨大的压力。(就怕用户懂代码)
常见的缓存穿透的解决方案有两种:
1.就是给redis缓存一个空对象并设置TTL存活时间
这种方式优点在于实现简单,维护方便,但也带来了额外的内存消耗和可能的短期的数据不一致。
小知识:这里的数据不一致发生在用户刚从redis中拿到null值恰好数据插入了这个请求需要的值而导致的数据库Redis数据不一致。
2. 就是利用布隆过滤
通俗的说,就是中间件~
这种方式 优点在于内存消耗较小,没有多余的key,缺点就在于实现复杂,而且布隆过滤器有误判的可能...
代码实现:这里实现第一种
就拿上述的写入商品信息为例:
我们只需要在数据库获取数据时,如果取到空值,不直接返回404,而是将空值也存入redis中,并且在判断缓存是否命中时,判断命中的值是不是我们传入的空值。如果是,直接结束,不是就返回商铺信息
package com.hmdp.service.impl;
import cn.hutool.core.util.StrUtil;
import cn.hutool.json.JSONUtil;
import com.hmdp.dto.Result;
import com.hmdp.entity.Shop;
import com.hmdp.mapper.ShopMapper;
import com.hmdp.service.IShopService;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import javax.annotation.Resource;
import java.util.concurrent.TimeUnit;
import static com.hmdp.utils.RedisConstants.*;
/**
*
* 服务实现类
*
*
* @author 虎哥
* @since 2021-12-22
*/
@Service
public class ShopServiceImpl extends ServiceImpl implements IShopService {
@Resource
private StringRedisTemplate stringRedisTemplate;
@Override
public Result queryShopById(Long id) {
//1.去redis中查询商品是否存在
String key = CACHE_SHOP_KEY+id;
String shopJson = stringRedisTemplate.opsForValue().get(key);
if (StrUtil.isNotBlank(shopJson)){
//2.存在,直接返回给用户
Shop shop = JSONUtil.toBean(shopJson, Shop.class);
return Result.ok(shop);
}
//判断命中的是否为空字符串
/**
* (这里重点讲一下为什么是不等于:因为我们获取到的shopJson为null是,上面的isNotBlank方法会返回false,导致成为了不命中的效果)
*/
if (shopJson != null){
return Result.fail("商品信息不存在!");
}
//3.不存在,带着id去数据库查询是否存在商品
Shop shop = getById(id);
if (shop == null){
// 4.将空值存入redis
stringRedisTemplate.opsForValue().set(key,"",CACHE_NULL_TTL,TimeUnit.MINUTES);
//5.不存在,返回错误信息
Result.fail("商品信息不存在!");
}
//6.存在,存入redis(并设置超时时间)
stringRedisTemplate.opsForValue().set(key,JSONUtil.toJsonStr(shop),CACHE_SHOP_TTL, TimeUnit.MINUTES);
//7.返回商品信息
return Result.ok(shop);
}
}
总结:
缓存穿透产生的原因是什么?
用户请求的数据在缓存中和数据库中都不存在,不断发起这样的请求,给数据库带来巨大压力。
缓存穿透的解决方案有哪些?(3-6点为扩展~)
1.缓存null值
2.布隆过滤
3.增强id的复杂度,避免被猜测id规律
4.做好数据的基础格式校验
5.加强用户权限校验
6.做好热点参数的限流
缓存雪崩是指在同一时段大量的缓存key同时失效或者Redis服务宕机,导致大量请求到达数据库,带来巨大压力。
就是说,一群设置了有效期的key同时消失了,或者说redis罢工了,导致所有的或者说大量的请求会给数据库带来巨大压力叫做缓存雪崩~
解决方式也比较的简单~
1. 给不同的key添加随机的TTL存活时间(这种就是最简单的,设置存货时间随机各不相同)
2. 利用Redis集群(这种针对与Redis出现宕机的情况)
3. 给缓存业务添加降级限流策略(SpringCloud知识点)
4. 给业务添加多级缓存
缓存击穿问题也叫热点Key问题,就是一个被高并发访问并且缓存重建业务较复杂的key突然失效了,无数的请求访问会在瞬间给数据库带来巨大的冲击。
大概的奔溃流程是这样子的:
第一个线程,查询redis发现未命中,然后去数据库查询并重建缓存,这个时候因为在缓存重建业务较为复杂的情况下,重建时间较久,又因为高并发的环境下,在线程1重建缓存的时间内,会有其他的大量的其他线程进来,发现查找缓存仍未命中,导致继续重建,如此死循环。
常见的解决方案有两种:
互斥锁的解决方案如下:
就是当线程查询缓存未命中时,尝试去获取互斥锁,然后在重建缓存数据,在这段时间里,其他线程也会去尝试获取互斥锁,如果失败就休眠一段时间,并继续,不断重试,等到数据重建成功,其他线程就可以命中数据了。这样就不会导致缓存击穿。这个方案数据一致性是绝对的,但是相对来说会牺牲性能。
实现方法:
1.获取互斥锁和释放锁的实现:
这里我们获取互斥锁可以使用redis中string类型中的setnx方法 ,因为setnx方法是在key不存在的情况下才可以创建成功的,所以我们重建缓存时,使用setnx来将锁的数据加入到redis中,并且通过判断这个锁的key是否存在,如果存在就是获取锁成功,失败就是获取失败,这样刚好可以实现互斥锁的效果。
而释放锁就更简单了,直接删除我们存入的锁的key来释放锁。
//获取锁
public Boolean tryLock(String key){
Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", 10, TimeUnit.SECONDS);
return BooleanUtil.isTrue(flag);
}
//释放锁方法
public void unlock(String key){
stringRedisTemplate.delete(key);
}
2.实现代码:
具体操作流程如下图:
实现代码如下:
package com.hmdp.service.impl;
import cn.hutool.core.util.BooleanUtil;
import cn.hutool.core.util.StrUtil;
import cn.hutool.json.JSONObject;
import cn.hutool.json.JSONUtil;
import com.hmdp.dto.Result;
import com.hmdp.entity.Shop;
import com.hmdp.mapper.ShopMapper;
import com.hmdp.service.IShopService;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.hmdp.utils.RedisData;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import javax.annotation.Resource;
import java.time.LocalDateTime;
import java.util.concurrent.Executor;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import static com.hmdp.utils.RedisConstants.*;
/**
*
* 服务实现类
*
*
* @author 虎哥
* @since 2021-12-22
*/
@Service
public class ShopServiceImpl extends ServiceImpl implements IShopService {
@Resource
private StringRedisTemplate stringRedisTemplate;
@Override
public Result queryShopById(Long id) {
//互斥锁缓存击穿
Shop shop = queryWithMutex(id);
if (shop == null){
Result.fail("商品信息不存在!");
}
//8.返回商品信息
return Result.ok(shop);
}
// 缓存穿透解决——互斥锁
public Shop queryWithMutex(Long id){
//1.去redis中查询商品是否存在
String key = CACHE_SHOP_KEY+id;
String shopJson = stringRedisTemplate.opsForValue().get(key);
if (StrUtil.isNotBlank(shopJson)){
//2.存在,直接返回给用户
Shop shop = JSONUtil.toBean(shopJson, Shop.class);
return shop;
}
//判断命中的是否为空字符串
if (shopJson != null){
return null;
}
Shop shop = null;
String lockKey = LOCK_SHOP_KEY+id;
try {
//3.缓存重建
//3.1尝试获取锁
Boolean isLock = tryLock(lockKey);
//3.2判断获取锁是否成功,如果休眠重试
if (!isLock){
//休眠
Thread.sleep(50);
//重试用递归
queryWithMutex(id);
}
//3.3如果成功,去数据库查找数据
shop = getById(id);
if (shop == null){
// 4.将空值存入redis
stringRedisTemplate.opsForValue().set(key,"",CACHE_NULL_TTL,TimeUnit.MINUTES);
//5.不存在,返回错误信息
Result.fail("商品信息不存在!");
}
//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);
}
//8.返回商品信息
return shop;
}
//获取锁
public Boolean tryLock(String key){
Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", 10, TimeUnit.SECONDS);
return BooleanUtil.isTrue(flag);
}
//释放锁方法
public void unlock(String key){
stringRedisTemplate.delete(key);
}
}
逻辑过期的处理方法主要为:
给redis缓存字段中添加一个过期时间,然后当线程查询数据库的时候,先判断是否已经过期,如果过期,就获取获取互斥锁,并开启一个子线程进行缓存重建任务,直到子线程完成任务后,释放锁。在这段时间内,其他线程获取互斥锁失败后,并不是继续等待重试,而是直接返回旧数据。这个方法虽然性能较好,但也牺牲了数据一致性。
实现方法:
1.获取互斥锁和释放锁如上
2.给redis数据添加一个过期时间(创建一个RedisData类,并封装数据)
RedisData类:
package com.hmdp.utils;
import lombok.Data;
import java.time.LocalDateTime;
@Data
public class RedisData {
private LocalDateTime expireTime;
private Object data;
}
//给商品信息添加一个过期时间字段,并存入redis当中
public void saveShop2Redis(Long id,Long expireSeconds){
Shop shop = getById(id);
RedisData redisData = new RedisData();
redisData.setData(shop);
redisData.setExpireTime(LocalDateTime.now().plusSeconds(expireSeconds));
stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY+id,JSONUtil.toJsonStr(redisData));
}
然后就是业务实现了:
具体代码如下:
package com.hmdp.service.impl;
import cn.hutool.core.util.BooleanUtil;
import cn.hutool.core.util.StrUtil;
import cn.hutool.json.JSONObject;
import cn.hutool.json.JSONUtil;
import com.hmdp.dto.Result;
import com.hmdp.entity.Shop;
import com.hmdp.mapper.ShopMapper;
import com.hmdp.service.IShopService;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.hmdp.utils.RedisData;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import javax.annotation.Resource;
import java.time.LocalDateTime;
import java.util.concurrent.Executor;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import static com.hmdp.utils.RedisConstants.*;
/**
*
* 服务实现类
*
*
* @author 虎哥
* @since 2021-12-22
*/
@Service
public class ShopServiceImpl extends ServiceImpl implements IShopService {
@Resource
private StringRedisTemplate stringRedisTemplate;
@Override
public Result queryShopById(Long id) {
//逻辑过期解决缓存击穿问题
// Shop shop = queryWithLogicalExpire(id);
if (shop == null){
Result.fail("商品信息不存在!");
}
//8.返回商品信息
return Result.ok(shop);
}
private static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10);
public Shop queryWithLogicalExpire(Long id){
//1.去redis中查询商品是否存在
String key = CACHE_SHOP_KEY+id;
String shopJson = stringRedisTemplate.opsForValue().get(key);
//2.判断是否命中
//3.未命中
if (StrUtil.isBlank(shopJson)){
return null;
}
//4.命中,先把redis中的数据反序列化成java对象
RedisData redisData = JSONUtil.toBean(shopJson, RedisData.class);
//4.1获取过期时间
LocalDateTime expireTime = redisData.getExpireTime();
//4.2获取商品对象
JSONObject data = (JSONObject) redisData.getData();
Shop shop = JSONUtil.toBean(data, Shop.class);
//5.判断是否过期
if (expireTime.isAfter(LocalDateTime.now())){
//未过期,直接返回shop
return shop;
}
//6.过期,重建缓存
//6.1尝试获取锁,并判断
String lockKey = LOCK_SHOP_KEY + id;
Boolean isLock = tryLock(lockKey);
if (isLock){
//5.2 如果成功,开启一个独立的线程,重建缓存
CACHE_REBUILD_EXECUTOR.submit(()->{
try {
//重建缓存
this.saveShop2Redis(id,20L);
} catch (Exception e) {
throw new RuntimeException(e);
} finally {
//释放锁
unlock(lockKey);
}
});
}
//6.2返回旧的商品信息
return shop;
}
//获取锁
public Boolean tryLock(String key){
Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", 10, TimeUnit.SECONDS);
return BooleanUtil.isTrue(flag);
}
//释放锁方法
public void unlock(String key){
stringRedisTemplate.delete(key);
}
//给商品信息添加一个过期时间字段,并存入redis当中
public void saveShop2Redis(Long id,Long expireSeconds){
Shop shop = getById(id);
RedisData redisData = new RedisData();
redisData.setData(shop);
redisData.setExpireTime(LocalDateTime.now().plusSeconds(expireSeconds));
stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY+id,JSONUtil.toJsonStr(redisData));
}
}
缓存知识结束。