redis黑马点评项目分析业务+学习笔记 含项目配置教学mac m1pro windows
- mac M1pro环境配置
- windows11 wsl2 ubuntu 环境配置
- 一.短信登录
- 1. 1发送验证码
- 1.2短信登录+注册
- 1.3登录校验拦截器
- 补缺Cookie Session Token
- 1.4基于redis+token认证实现短信登陆
- 1.5完善token认证的刷新机制
- 二.商户查询缓存
- 2.1添加商户缓存(到redis中)
- 2.2添加商户类型缓存(到redis中)
- 2.3(重点)缓存更新
- 2.4实现商铺缓存与数据库的双写一致
- 2.5(重点)缓存穿透
- 2.6缓存空对象解决缓存穿透
- 2.7(重点)缓存雪崩
- 2.8(重点)缓存击穿
- 2.9互斥锁解决缓存击穿
- 2.10逻辑过期解决缓存击穿(P45)
- 2.11(重点)封装Redis工具类(工具类的制作,泛型的使用,函数式编程)(P46)
- 三.优惠卷(类似于一个商品)
- 3.1优惠卷(商品)全局唯一ID(对比Redis和Mysql)(P48)
- 3.2Redis实现全局唯一ID(P49)
- 3.3优惠卷(商品)秒杀下单功能(P51)
- 3.4高并发情况下(更新数据时)--优惠卷(商品)超卖问题(多线程,悲观锁,乐观锁,分段锁)
- 3.5高并发情况下(插入数据时)--优惠卷(商品)一人一单问题(悲观锁上锁粒度, spring事务失效, Java基础字符串)(P54, 回看)
- 3.6集群下的线程高并发,同步问题(多个JVM分布式锁)(P55)
- 3.7完善分布式锁中A线程误删B线程锁问题(无线程锁标识导致)(P59)
- 3.8完善分布式锁中A线程误删B线程锁问题(无原子性导致)(P61)
- 3.9分布式锁Redisson框架入门指南(上述分布式锁依旧有漏洞)(P64)
- 3.10Redisson可重入锁原理(P66)
- 3.11Redisson解决锁重试和超时释放原理(消息订阅信号量机制,watchDog机制)(P67)
- 3.12Redisson解决主从一致性(multiLock)(P68)
mac M1pro环境配置
-
视频里用的是虚拟机,但我不想配虚拟机,太臃肿了,我选择docker这里我用的是OrbStack,这款软件使用的是rust编写,性能占用低(相比于dockerDesktop)
brew insall orbstack
或者去官网下载 -
docker拉取redis 和 mysql镜像,注意常规镜像pull下来run后使用的是rosetta转译,性能会下降.
因此我们拉取arm64v8/mysql和arm64v8/redis这两个镜像
pull docker pull arm64v8/mysql
pull docker pull arm64v8/redis
这里种类是Apple说明没有经过转译 -
部署mysql和redis
docker run -p 3306:3306 \--name mysql \-v mysql_data:/var/lib/mysql \-v mysql_conf:/etc/mysql/conf.d \--privileged=true \-e MYSQL_ROOT_PASSWORD=123456 \-d arm64v8/mysql
docker run -p 6379:6379 \ --name redis \ -v redis_data:/data \ -v redis_conf:/etc/redis/redis.conf \ -d arm64v8/redis \ redis-server /etc/redis/redis.conf
使用
docker ps
查看是否成功
-
进入mysql数据库中导入sql表并运行sql表语句
# 1.拷贝SQL文件到mysql容器中(在容器外,自己的sql文件所在位置,mysql是容器名) docker cp yyy.sql mysql:/hmdp.sql# 2. 创建数据库(第一个mysql是容器名,第二个mysql是程序名称 也可以/bin/bash或者bash) docker exec -it mysql mysql -uroot -p123456 mysql> create database hmdp;# 创建黑马点评数据库 mysql> use hmdp; # 使用黑马点评数据库# 3.登陆控制台执行source 命令,执行sql文件 mysql> source hmdp.sql
-
进入Datagrip软件查看一下redis 和 mysql(我用的这个可视化软件,它可以连接很多种数据库)
-
项目后端
# 1.clone git clone https://gitee.com/chiroua/black-horse-review.git # 2.切换分支, init分支就是项目最开始的代码 git checkout init # 3.修改application.yaml中mysql和redis的ip,端口,密码等,这里我redis没有设置密码直接注释了.
因为使用的是mysql8的版本
还要将数据配置文件里driver-class-name: com.mysql.jdbc.Driver修改为如下driver-class-name: com.mysql.cj.jdbc.Driver;否则启动项目有报错- 修改idea默认的maven, idea自带一个叫
已捆绑(Maven3)
的maven,默认的所在路径为/.m2
,settings和repo都在这个目录下,但是我没试过,我这里用的自己的maven仓库,记得修改一下maven仓库镜像地址,搜索关键词maven 阿里
-
项目前端
brew install nginx
- 将
/opt/homebrew/Cellar/nginx/1.25.3/
下的html夹替换成src/main/resources/nginx-1.18.0/
下的html文件夹 - 将
/opt/homebrew/etc/nginx/nginx.conf
替换成src/main/resources/nginx-1.18.0/conf/nginx.conf
接下来重新加载nginx配置文件sudo nginx -s reload
- 启动nginx
sudo nginx
ps -ef | grep nginx
查看nginx是否成功启动
- nginx成功启动后,我们启动后端,访问本地8080端口即可成功运行
windows11 wsl2 ubuntu 环境配置
-
mysql
# 安装mysql su root apt update sudo apt-get install mysql-server sudo service mysql start#一般就直接启动了不需要这一步 mysql ALTER USER 'root'@'localhost' IDENTIFIED WITH mysql_native_password BY 'password';# 修改root用户密码 exit sudo mysql_secure_installation # 使用脚本删除匿名用户和匿名用户访问的数据库## 接下来 执行sql文件,构建数据库 cp /mnt/c/.....hmdp.sql /tmp/ #每个人不一样,可以参考我复制到/tmp目录下 mysql -u root -p CREATE DATABASE hmdp; use hmdp; source /tmp/hmdp.sql
更多操作查看此博客
-
redis
# 我直接安装的 没设置密码 su root apt update sudo apt-get install redis-server # 执行redis-cli查看redis正常启动即可
-
datagrip连接redis和mysql, 其中redis指定不需要用户和密码即可
-
git clone git@gitee.com:chiroua/black-horse-review.git # 修改maven仓库的settings 将源设置成aliyun,这里我直接用idea自带的maven仓库了 # 修改applicati.yml配置文件中mysql 和 redis相关配置
因为使用的是mysql8的版本
还要将数据配置文件里driver-class-name: com.mysql.jdbc.Driver修改为如下driver-class-name: com.mysql.cj.jdbc.Driver;否则启动项目有报错 -
前端也有一处问题 niginx目录下打开终端
start nginx.exe
后访问8080无效,
查看nginx目录下的logs/error.log发现2024/01/04 21:58:41 [emerg] 37092#35300: CreateDirectory() "C:\Users\gx\Desktop\hmdp\black-horse-review\src\main\resources\nginx-1.18.0/temp/client_body_temp" failed (3: The system cannot find the path specified)
我们手动在logs的同级目录下创建一个temp/client_body_temp即可
一.短信登录
1. 1发送验证码
- 业务思路: 基本的校验->验证码和手机号保存在session中(不明白session token cookie去学),利用RandomUtil工具类生成验证码即可;session中存储的手机号和验证码是为了登录时的校验功能
@Overridepublic Result sendCode(String phone, HttpSession session) {//获得用户手机号//1.校验手机号正确性if (RegexUtils.isPhoneInvalid(phone))return Result.fail("手机号格式不正确");session.setAttribute("phone", phone);//2.生成验证码String code = RandomUtil.randomNumbers(6);//3.保存验证码session.setAttribute("code", code);//4.发送验证码log.debug("发送短信验证码成功, 验证码:{}", code);//5.返回okreturn Result.ok();}
1.2短信登录+注册
- 业务思路:controller 中 return UserService
接口
中的方法, 具体实现在实现类UserServiceImpl中去重写即可;基本的参数校验->从session中取出验证码和手机号->判断手机号前后是否一致,判断验证码是否正确,判断用户是否存在,不存在则注册一个保存到数据库中 - 说在前面: 本项目使用了mybatis-plus, 直接的好处是可以直接用save(), query等api, 原因是因为
public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements IUserService
我们观察发现 UserServiceImpl类继承了 ServiceImpl<T,T>, 同时指定了两个泛型UserMapper,和User,我们去entity下的user类可以发现,其中指明了该实体类对应的表名,这就是二者之间的联系. - 代码优化:这里我优化了一下, 应当先检查当前手机号和session中存储的手机号是否一致
- 注意代码命名规则,:比如session中的code命名为cacheCode,;类名:驼峰命名法+ 首字母大写, 方法名驼峰命名法+首字母小写
- api设计规则:设计的类名称为IShopTypeService,对应的路径名称为shop-type
- 写代码流程:先把思路捋清楚,比如1.2.3.4.每一步干什么,然后再写代码
@Overridepublic Result login(LoginFormDTO loginForm, HttpSession session) {//1.校验手机号格式是否正确String phone = loginForm.getPhone();if(RegexUtils.isPhoneInvalid(phone)) return Result.fail("手机号格式不正确!");//这里不知道为啥,不用return true代表格式不正确//2.校验手机号是否和session里面的一致String cachePhone = (String) session.getAttribute("phone");if (!cachePhone.equals(phone)) return Result.fail("前后手机号不匹配!");//3.校验验证码Object cacheCode = session.getAttribute("code");String code = loginForm.getCode();if (cacheCode == null || !cacheCode.toString().equals(code)) return Result.fail("验证码错误!");//4.判断用户是否存在User user = query().eq("phone", phone).one();//5.不存在的话直接创建一个用户if (user == null) user = createUserWithPhone(phone);//6.保存用户信息到session中session.setAttribute("user", user);return Result.ok();}//注册private User createUserWithPhone(String phone) {//1.创建用户User user = new User();user.setPhone(phone);user.setNickName(USER_NICK_NAME_PREFIX + RandomUtil.randomString(10));//2.保存用户到数据库中save(user);return user;}
1.3登录校验拦截器
- 业务思路:
- 设计拦截器的原因: 点评的不少业务都需要验证当前是否是登录状态,比如我们观察前端info.html(个人信息界面)的代码发现会发送如下请求
其中有一个Cookie, 如果每个业务都要从http request的session中取的话会很麻烦,因此我们设置一个登录拦截器LoginInterceptor类, 所有的业务都会先经过这个类最后到达controller, 我们在这个类中将当前session中的user属性存储到本地线程中,要用的时候直接从本地线程UserHolder类(封装了TreadLocal类对象)中取就行. - 防止用户隐私泄露: 我们session中一开始存的是完整的user对象(包含了user的密码等个人隐私), 我们封装一个UserDto类, 将user中的信息拷贝到userDto中, session中存userDto就行, 后续的UserHolder等cache类从session中拿到的数据也就是userDto了. 但是我们createUser方法往数据库中存储的时候还是存储完整的user对象哦
- 实现WebMvcConfigurer类中的addInterceptor方法, 并设置哪些请求路径不需要被拦截
- 设计拦截器的原因: 点评的不少业务都需要验证当前是否是登录状态,比如我们观察前端info.html(个人信息界面)的代码发现会发送如下请求
- 除了拦截器外还可以设计过滤器
- 缺: 什么是TreadLocal内存泄露,为什么afterCompletion后要removeUser(); 什么是session cookie token
- mac idea 实现方法快捷键 ^ + o
@Overridepublic Result me() {UserDTO user = UserHolder.getUser();return Result.ok(user);}
@Data
public class UserDTO {private Long id;private String nickName;private String icon;
}
public class LoginInterceptor implements HandlerInterceptor {@Overridepublic boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {//1.获取session中的用户HttpSession session = request.getSession();Object user = session.getAttribute("user");//2.用户是否存在,不存在返回false,并设置状态码if (user == null){response.setStatus(401);return false;}//3.存在就将当前用户存储在ThreadLocal中UserHolder.saveUser((UserDTO) user);return true;}@Overridepublic void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {//移除用户,防止内存泄漏UserHolder.removeUser();}
}
@Configuration
public class MyConfig implements WebMvcConfigurer {@Overridepublic void addInterceptors(InterceptorRegistry registry) {registry.addInterceptor(new LoginInterceptor()).excludePathPatterns(// 匿名对象 stream coding"/user/code","/voucher/**","/blog/hot","/shop/**","/shop-type/**","/upload/**","/user/login");}
}
补缺Cookie Session Token
因为Http是一种无状态的协议,为了记住这种状态, 引入了 session cookie token
- Cookie: 存储在前端, 数据的一种载体, 后端可以将数据包装在Cookie中发送给客户端端, 比如将session放在cookie中送到客户端, cookie跟随HTTP的每个请求发送出去
- Session: 存储在后端
- Token: 在很多地方都会用到, 只是一个通用的名词;诞生在服务器这端, 但保存在浏览器这边, 由客户端主导一切, 可以存放在Cookie或者Storage中, jwt即
json web token
是一种特殊的token,或者说token的一种 - 总结: cookie是载体,而token和session则是靠它实现的验证机制,本质就是cookie上携带的字符串;
session和token是两种认证机制
。cookie和localstorage等是存储session id或token的载体
1.4基于redis+token认证实现短信登陆
- 业务思路: 这里采用token认证机制, 不再使用session了, 因为如果是tomcat服务器集群,session存储在不同的tomcat服务器中, 假如小明的session存储在A服务器中, 第二次小明发送请求的时候经过负载均衡请求到达B服务器, 那么B服务器没有小明的session,小明又要重新登录; 但是如果让多个服务器之间数据同步的话会造成数据冗余,同时同步数据也需要一定的时间,综上我们选择redis来存储数据
- 用redis存储code, phone作为其key
- 用redis存储user信息, token作为其key
token的本质是一种验证机制, 这里token作为当前用户个人信息的key!!
- springboot内置的tomcat会帮我们维护session,这里我们自己维护redis的时候注意数据的丢失, 因为不可能让数据一直存储在redis中
- 具体实现业务代码的时候注意自己new的对象spring不会帮我们管理的
- 缺: 什么样的对象可以让spring帮我们管理?
@Resourceprivate StringRedisTemplate stringRedisTemplate;@Overridepublic Result sendCode(String phone, HttpSession session) {//获得用户手机号//1.校验手机号正确性if (RegexUtils.isPhoneInvalid(phone))return Result.fail("手机号格式不正确");//session.setAttribute("phone", phone); 使用redis后我还没想好它的key是啥//2.生成验证码String code = RandomUtil.randomNumbers(6);//3.保存验证码到redis中并设置缓存时间stringRedisTemplate.opsForValue().set(LOGIN_USER_KEY + phone, code, LOGIN_CODE_TTL, TimeUnit.MINUTES);//stream流//4.发送验证码log.debug("发送短信验证码成功, 验证码:{}", code);//5.返回okreturn Result.ok();}@Overridepublic Result login(LoginFormDTO loginForm, HttpSession session) {//1.校验手机号格式是否正确String phone = loginForm.getPhone();if(RegexUtils.isPhoneInvalid(phone)) return Result.fail("手机号格式不正确!");//这里不知道为啥,不用return true代表格式不正确
// //2.校验手机号是否和session里面的一致
// String cachePhone = (String) session.getAttribute("phone");
// if (!cachePhone.equals(phone)) return Result.fail("前后手机号不匹配!");//3.校验验证码String cacheCode = stringRedisTemplate.opsForValue().get(LOGIN_USER_KEY + phone);String code = loginForm.getCode();if (cacheCode == null || !cacheCode.equals(code)) return Result.fail("验证码错误!");//4.判断用户是否存在User user = query().eq("phone", phone).one();//5.不存在的话直接创建一个用户if (user == null) user = createUserWithPhone(phone);//6.保存用户信息到redis中, token作为key并设置缓存时间String token = UUID.randomUUID().toString(true);String tokenKey = LOGIN_USER_KEY + token;Map<String, Object> userMap = BeanUtil.beanToMap(user, new HashMap<>(),CopyOptions.create().setIgnoreNullValue(true).setFieldValueEditor((fieldName, fieldValue) -> fieldValue.toString()));stringRedisTemplate.opsForHash().putAll(tokenKey, userMap);//设置token有效期stringRedisTemplate.expire(tokenKey, LOGIN_USER_TTL, TimeUnit.MINUTES);//7.返回tokenreturn Result.ok(token);}
public class LoginInterceptor implements HandlerInterceptor {private StringRedisTemplate stringRedisTemplate;//用来接住外部传过来的StringRedisTemplatepublic LoginInterceptor(StringRedisTemplate stringRedisTemplate){this.stringRedisTemplate = stringRedisTemplate;}@Overridepublic boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {//1.获得token, 判断token是否为空String token = request.getHeader("authorization");if (StrUtil.isBlank(token)) return false;//2.通过token获取redis中的用户信息,并判断同户是否存在,不存在返回false,并设置状态码String tokenKey = LOGIN_USER_KEY + token;Map<Object, Object> userMap = stringRedisTemplate.opsForHash().entries(tokenKey);if (userMap.isEmpty()){//及时为空也会被包装成一个对象,因此这里不能判断null而是isEmptyresponse.setStatus(401);return false;}//3.将map变成userDtoUserDTO userDTO = BeanUtil.fillBeanWithMap(userMap, new UserDTO(), false);//4.存在就将当前用户存储在ThreadLocal中UserHolder.saveUser(userDTO);//5.每拦截一次, redis中存储的用户信息刷新一次stringRedisTemplate.expire(tokenKey, LOGIN_USER_TTL, TimeUnit.MINUTES);return true;}@Overridepublic void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {//移除用户,防止内存泄漏UserHolder.removeUser();}
}
@Configuration
public class MyConfig implements WebMvcConfigurer {@Resourceprivate StringRedisTemplate stringRedisTemplate;//该类中有一个@Configuration注解说明该类可以由spring来帮我们管理对象@Overridepublic void addInterceptors(InterceptorRegistry registry) {//再该类中让spring帮我们管理对象然后传给LoginInterceptorregistry.addInterceptor(new LoginInterceptor(stringRedisTemplate)).excludePathPatterns(// 匿名对象 stream coding"/user/code","/voucher/**","/blog/hot","/shop/**","/shop-type/**","/upload/**","/user/login");}
}
1.5完善token认证的刷新机制
- 业务思路: 因为之前的一层拦截器只能拦截部分请求, 假如用户一直在浏览index页面30min,那么再访问需要user信息的页面的时候就会需要重新登录, 因此这里再多设置一层拦截器拦截所有请求
- 第一层拦截器: 拦截所有请求 + 将当前用户信息存储到ThreadLocal中;判断是否有用户的任务交给下一层,本层的任务是存储user到当前thread+刷新token(不判断token是否为空, 若无token代表无user信息, 无user信息的情况给第二层拦截器处理)
- 第二层拦截器: 拦截部分需要user信息的页面的请求, 若从ThreadLocal中取出来的数据为空则直接return false 即可
- 注意注册拦截器的时候拦截器有优先级顺序, 默认情况下都是0, 谁先注册谁先执行, 或者我们手动更改拦截器的优先级(优先级越小越先执行)
public class RefreshTokenInterceptor implements HandlerInterceptor {private StringRedisTemplate stringRedisTemplate;//用来接住外部传过来的StringRedisTemplatepublic RefreshTokenInterceptor(StringRedisTemplate stringRedisTemplate){this.stringRedisTemplate = stringRedisTemplate;}@Overridepublic boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {//1.获得token, 判断token是否为空String token = request.getHeader("authorization");if (StrUtil.isBlank(token)) {return true;//均return true, 判断是否有用户的任务交给下一层,本层的任务是存储user到当前thread+刷新token}//2.通过token获取redis中的用户信息,并判断同户是否存在,不存在返回false,并设置状态码String tokenKey = LOGIN_USER_KEY + token;Map<Object, Object> userMap = stringRedisTemplate.opsForHash().entries(tokenKey);if (userMap.isEmpty()){//及时为空也会被包装成一个对象,因此这里不能判断null而是isEmptyreturn true;}//3.将map变成userDtoUserDTO userDTO = BeanUtil.fillBeanWithMap(userMap, new UserDTO(), false);//4.存在就将当前用户存储在ThreadLocal中UserHolder.saveUser(userDTO);//5.每拦截一次, redis中存储的用户信息刷新一次stringRedisTemplate.expire(tokenKey, LOGIN_USER_TTL, TimeUnit.MINUTES);return true;}@Overridepublic void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {//移除用户,防止内存泄漏UserHolder.removeUser();}
}
public class LoginInterceptor implements HandlerInterceptor {@Overridepublic boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {//1.从UserHolder获得用户UserDTO userDto = UserHolder.getUser();//2.判断是否有user用户if (userDto == null) {response.setStatus(401);return false;}return true;}//不用重写afterCompletion方法, 第一层拦截器负责重写拦截器执行顺序:preHandle: 拦截器1 -> 拦截器2afterCompletion: 拦截器2 - > 拦截器1
}
@Configuration
public class MyConfig implements WebMvcConfigurer {@Resourceprivate StringRedisTemplate stringRedisTemplate;//该类中有一个@Configuration注解说明该类可以由spring来帮我们管理对象@Overridepublic void addInterceptors(InterceptorRegistry registry) {//再该类中让spring帮我们管理对象然后传给LoginInterceptorregistry.addInterceptor(new LoginInterceptor()).excludePathPatterns(// 匿名对象 stream coding"/user/code","/voucher/**","/blog/hot","/shop/**","/shop-type/**","/upload/**","/user/login").order(1);registry.addInterceptor(new RefreshTokenInterceptor(stringRedisTemplate)).order(0);}
}
二.商户查询缓存
从本节开始我会说明这个业务的原因和业务的思路是如何的
2.1添加商户缓存(到redis中)
- 业务原因
- 业务思路
查看网络请求时间验证是否快了很多
2.2添加商户类型缓存(到redis中)
2.3(重点)缓存更新
- 业务原因: 如果数据库中的数据更新了, redis中的缓存如何更新?更新选择什么样的更新策略才能保证效率, 数据的一致性等问题?
- 业务思路:
- 首先我们看下缓存有哪些更细策略,对应的维护方法是如何的
首页的商户类型就属于低一致性需求 - 上面我们知道了内存淘汰机制和超时剔除机制是如何实现的, 接下来重点介绍
主动更新
,主动更新分以下三种, 最终我们考虑到数据的可靠性,我们还是选择第一种.
- 上面我们已经考虑完选择Cache Aside这种数据库和缓存同时更新的策略,接下来思考细节问题:
-
- 更新缓存采用什么方式?结论:删除缓存的方式
-
- 如何保证原子性?结论:用事务机制
-
- (重点)先操作数据库还是缓存? 这两种方案各自会有何问题(考虑微观上操作系统中线程是抢占式的,及时代码中写了先xx在yy,但我们更新xx后再更新yy时, xx可能还没更新完或者一系列问题)
- 接下来我们探讨上面遗留下来的问题
先操作数据库还是缓存?
我们观察得知,其实发生下面这两种异常情况的主要原因是,删除缓存
更新数据库
查询缓存未命中,查询数据库
写入缓存
这四步因为线程执行顺序问题导致的一系列的后果; 观察下图的时候主要观察删除缓存
更新数据库
这两步
先删除缓存再操作数据库:
- 首先我们看下缓存有哪些更细策略,对应的维护方法是如何的
先操作数据库再删除缓存
方案二先操作数据库再删除缓存这种方案出现异常情况的概率比较低, 因为2.更新数据库
这一步操作时间一般比较长, 想让4.写入缓存
在2.更新数据库
后面发生的概率比较低;
要么查询缓存
和写入缓存
都在更新数据库
前面,那么查询缓存和写入缓存的都是旧数据之后靠淘汰机制兜底;
要么就都在更新数据库后面, 这样你未命中缓存后查数据库也是查的新数据.
- 总结就一句话:
先干时间长的, 再干时间短的
这种同步问题不要绕弯子, 太多细节会把自己弄晕,身为初学者, 直接先更新数据库再删除缓存就完事了; - 因为数据库的IO速度慢,时间长,如果先更新数据库那么在当前线程执行
更新数据库操作
到删除缓存
这期间很容易发生幺蛾子, 比如其他线程的查询操作来了,这样查询的和缓存的都是旧数据
; - 但如果
先删除缓存
, 再更新数据库
就会好很多,因为缓存的操作很快的, 你如果再缓存到更新数据库期间发生幺蛾子(那么大不了查询到的是旧数据,但起码缓存中存储的是新数据呀
)
2.4实现商铺缓存与数据库的双写一致
- 业务原因: 2.3中叙述了
- 业务思路: 读操作设定TTL时间, 写操作先写数据库再删除缓存, 同时增加事务机制
注意: 本节代码用postman或者apifox测试即可, 前端没有接口
2.5(重点)缓存穿透
缓存穿透定义: 缓存和数据库中没有数据x, 但是有人恶意发请求一直查询,这些请求最终都会穿过缓存打到数据库上,数据库可能宕机,这就是内存穿透
备注!!!
:上面布隆过滤存在误判可能写错了: 布隆过滤使用bitmap, 但是hash算法有hash冲突问题, 即1的话不代表真的有,但是0一定没有
, 解决办法就是0的话直接返回错误给客户端就行
综上所述, 我们选择缓存空对象的方式解决缓存穿透
2.6缓存空对象解决缓存穿透
- 业务原因: 解决缓存穿透
- 业务思路: 这里用的是StringRedisTemplate, 缓存空对象缓存的就是空字符串
""
,- 查询shop的时候查到的是空字符串就返回店铺不存在
- 数据库查询到null的时候, 也要往缓存中存一个空字符串
""
- 注意: 这里还没有实现 新增数据的时候更新后者添加该数据在redis中的缓存,也就是说现在可能存在短期的数据不一致
2.7(重点)缓存雪崩
2.8(重点)缓存击穿
和缓存雪崩的区别
是, 缓存击穿是一个被高并发访问
且缓存重建业务比较复杂
(这个key的值的计算可能要查询多个数据库)的key失效了;而缓存雪崩是大量的缓存key失效
或Redis服务宕机
两种方案:互斥锁和逻辑过期, 根据自己业务场景, 更看重数据的一致性
还是服务的高可用性
进行抉择
2.9互斥锁解决缓存击穿
- 业务原因:解决redis缓存击穿(或者热点key问题)
- 业务思路:利用redis自己的
setnx lock
(对应到stringRedisTemplate类中就是setIfAbsent
)设计一个自己的互斥锁, 因此java自带的互斥锁一旦没有拿到锁就会等待,我们想要的是自己设计线程在等待时的逻辑 - 代码细节
- 回忆Java基本数据类型的拆箱装箱注意
拆箱操作
可能会造成空指针 - 特定的功能进行封装: 比如获取互斥锁和释放互斥锁
- control(cmd) + alt + m代码抽取
- 缓存穿透和缓存击穿不能同时防止? 为啥要提取出来
- 即使此时获取互斥锁成功后也应该做一下doublecheck
- control(cmd) + alt + t 代码包围
- apifox测压
- 回忆Java基本数据类型的拆箱装箱注意
- 缺:Java互斥锁的api不会
2.10逻辑过期解决缓存击穿(P45)
- 业务原因: 解决redis缓存击穿问题
- 业务思路:
- 代码细节
- 装饰器设计模式: 因为我们要给每个shop对象缓存的时候加上expire字段判断是否过期, 现在有三种做法: 1. 直接在Shop类中加上expire属性 2. 让Shop类继承RedisData,RedisData类中封装一个expire 3.Redis直接在shop类的基础上封装一个expire(这时可以给RedisData加上一个泛型);第三种就是装饰器设计模式
- 双击shift 查找文件
- 获取锁成功后做doublecheck
- json反序列化后得到的是JsonObject,还需要BeanUtil
- final定义的函数名要大写
- Java线程池的api
2.11(重点)封装Redis工具类(工具类的制作,泛型的使用,函数式编程)(P46)
-
业务原因:聚合设计模式, 将解决缓存击穿的两个方法(互斥锁,逻辑过期)聚合成一个类, 一个小技巧
-
业务思路:
-
代码细节:
- 反射相关知识,.class就是字节码
三.优惠卷(类似于一个商品)
3.1优惠卷(商品)全局唯一ID(对比Redis和Mysql)(P48)
传统设置ID自增长带来的问题:
- ID的规律性太明显, 用户可能会通过ID猜测到一些信息, 安全性不好
- 一张表能存储的数据是有限的, 如果多张表同时自增长那么会出现ID重复的现象
因此我们选择Redis生成全局唯一ID,但Redis的解决办法并不是唯一的生成全局唯一ID的方法
PS:
- 为啥不用UUID?
因为UUID采用的是十六进制的方法, 返回的是一个字符串的类型, 并且生成的ID也不是自增的形式, 虽然它可以作ID,但相比于Redis并不是特别的友好, 也没有满足我们所说的那几个特性 - 雪花算法:也可以, 后续了解一下
- 数据库自增:单独建一个自增的表, ID严格来说并不是自增的,而是从这张专门做自增的表来获取,但是他的性能肯定不如Redis
- 缺雪花算法
3.2Redis实现全局唯一ID(P49)
- 业务原因:上面说了
- 业务思路:
- key的设计:“icr:”:keyPrefix(不同业务不同前缀)+“:”+20240107(年月日)
- value(ID)的设计:
- 总结:
3.3优惠卷(商品)秒杀下单功能(P51)
注意:
- 本节应当关注数据库中表的设计, 秒杀类的优惠卷另开一张表, 并且其
主键
是优惠卷的ID关联到另一张优惠卷的表;即优惠卷包含秒杀优惠卷, 秒杀优惠卷另开一张表相当于是一种字段扩展
, 比如优惠卷表中记录普通优惠卷和秒杀优惠卷共同的字段
, 秒杀优惠券表中记录秒杀优惠卷特有的字段
,比如库存含量等信息- CRUD时思考是哪几个表受到影响, 比如本节就是秒杀型优惠卷表中做查询和更新, 秒杀卷订单表中做新增
- 业务原因:模拟商品下单的流程, 包括库存量, 商品允许开始抢购的时间
- 业务思路
3.4高并发情况下(更新数据时)–优惠卷(商品)超卖问题(多线程,悲观锁,乐观锁,分段锁)
- 业务原因: 避免商品发生超卖, 导致商家损失
- 业务思路: 更新商品信息的时候使用乐观锁
- 发生超卖问题的全过程(超卖是指,我现在只有100个库存,但是
多线程并发导致
最后卖了>100个,这给商家会带来很大损失)
- 引入悲观锁(真实锁),乐观锁(逻辑锁)
我个人觉得 悲观锁像是互斥信号量, 乐观锁像是同步信号量
经过上面介绍悲观锁就是常规的上锁操作, 背api和背业务代码就行;这里使用乐观锁的方式-
版本号法(代码实现的时候通过SQL语句判断)
-
CAS法(代码实现的时候通过SQL语句判断)
-
优化: 判断的时候直接判断当前库存是否>0即可, 而不是判断
更新时库存值
和之前查询的库存值
不一致就直接不修改了, 并且Stock > 0
也更符合业务需求
-
-
缺点:虽然加锁可以防止超卖问题, 但是这些查询还是实打实的到达了数据库, 在高并发场景下还是不够的, 因此后续我们要继续改进(盲猜用缓存?)
-
总结
3.5高并发情况下(插入数据时)–优惠卷(商品)一人一单问题(悲观锁上锁粒度, spring事务失效, Java基础字符串)(P54, 回看)
一人一单问题业务的最终实现就是保证 不同用户并行
, 但是同一用户的线程串行执行
;
- 业务原因: 防止一个人将优惠商品全部买光, 其他用户无法享受到优惠, 导致宣传力度下降
- 业务思路: 对同一个用户的
创建订单
上悲观锁, 防止一个人发起多个请求, 多线程的时候发生一个人下了很多单
注意:
这节代码实现细节比较多建议重新回放视频
包括内容有:
- 在哪里上锁比较合适(
上锁的粒度
), 对性能的影响最小?:
- 直接对方法上锁的话, 那么不仅相同用户要等待锁,不同用户执行这个方法也要等待,程序就变成串行的了
- 对userId上锁: 完美解决
同一用户串行执行
的需求, 并且上锁的粒度也仅仅是创建订单这个方法
, 同一用户请求时其他方法的内容还是并行执行的,仅仅是创建订单这个方法是串行
- 代码细节问题:
(1)对userId上锁 :原生userId是一个Long类型
, 即使值一样但是Java中都是对象,对象是不同的
, 上锁时判断还是不同的用户
;
(2)对userId.toString()上锁: 我们观察toString()的源码发现,其最终还是return new String(
),还是返回一个全新的字符串对象
, 上锁的时候又不能重写synchronised方法用equals()进行判断
(3)使用userId.toString().intern()
, intern()会去字符串常量池
中寻找有没有和当前字符串对象equals()
的对象,有就直接返回这个对象
, 而不是new一个对象
- 如何保证释放锁在事务结束之后: 先上锁, 然后
锁的方法体中
执行方法创建订单(该方法是被@Transaction标注的)- spring事务可能会失效: 获得
代理对象
执行创建订单的方法, 否则默认是目标对象
执行创建订单方法, 然而目标对象不属于spring管理,事务会失效
3.6集群下的线程高并发,同步问题(多个JVM分布式锁)(P55)
- 写在前面: 如何配置一个服务的集群
-
cmd + d或ctrl + d复制一下微服务
2. -
打开nginx中的conf文件,修改一下配置
改完上述配置后, 终端在nginx目录下, nginx -s reload重新加载配置文件; mac直接终端就行
-
注意: 用apifox测试的时候, 不同请求的token要设置成同一个, 这样才能保证线程从redis缓存中存储的是同一个用户
- 业务原因:一台服务器的性能是有限的, 因此我们
一个服务
会分布式部署在多台服务器
上形成集群,前端nginx负载均衡后请求会分流到不同服务器上;但是synchronized只能保证单个JVM中多个线程之间的互斥,而没办法保证集群下的多个JVM之间线程的互斥 - 业务思路:
- 首先看一下当前存在的问题如下图
- 什么是分布式锁
- 实现分布式锁的几种方式
- Redis实现分布式锁: 利用setnx(实现互斥) + expire(保证不会死锁, 安全性)
为了保证原子性
, 要将这两个命令变成一个事务,但是redis自己支持将两句写成一句
的这种写法SETNX lock thread1 NX EX 10
- 获取锁成功: 执行之前写的单个JVM的创建订单即可
- 获取锁失败:说明当前用户在恶意发送多个请求,直接返回false即可(其实看到这句话的时候
我突然觉得这个业务就是适合非阻塞的分布式锁
, 因为写清楚了一人一单
,如果获取失败说明当前用户就在恶意请求,直接false就行了)
3.7完善分布式锁中A线程误删B线程锁问题(无线程锁标识导致)(P59)
- 业务原因:(极限情况下)假设现在如下场景, A线程拿到锁后
阻塞
了, 然后A线程锁过期
了;之后处理同一个用户请求的B线程
拿到了分布式锁, 刚准备执行逻辑的时候A线程醒了
A线程把该用户在Redis中的分布式锁Del
掉了;此时处理同一个用户请求的C线程
来了因此拿到了这个分布式锁,那么在这时处理同一个用户的线程ABC
都拿到过分布式锁
, 一人一单还是没能得到满足, 我们就是要解决这个问题A只能删A的锁不能删其他线程的锁
- 业务思路:
-
- 每个线程在删除它拿到的分布式锁时,要注意判断这个锁是不是自己的, 是自己的才能删除
Del这个KEY
- 每个线程在删除它拿到的分布式锁时,要注意判断这个锁是不是自己的, 是自己的才能删除
-
- 给
分布式锁的KEY
在Redis中存储的Value
加上当前线程的Id帮助线程删除锁的时候判断是否是自己的锁
- 给
-
- 因为有多个JVM, 不同JVM中的线程的Id依旧可能会冲突, 因此我们给线程Id加上一个前缀用来区分当前的JVM(视频老师是用UUID实现的)
-
3.8完善分布式锁中A线程误删B线程锁问题(无原子性导致)(P61)
- 业务原因:
- (极限情况下)假设现在如下场景, A线程拿到锁后处理完业务
要释放锁时
(已经判断完当前锁(KEY)对应的Value–线程标识是当前JVM的线程的) 然后阻塞
了(此时还未释放锁
), 然后A线程锁过期
了; - (后面和3.7一样)之后
处理同一个用户请求的B线程
拿到了分布式锁, 刚准备执行逻辑的时候A线程醒了
A线程把该用户在Redis中的分布式锁Del
掉了;此时处理同一个用户请求的C线程
来了因此拿到了这个分布式锁,那么在这时处理同一个用户的线程ABC
都拿到过分布式锁
, 一人一单还是没能得到满足, - 我们就是要解决这个问题:
A线程判断当前锁(KEY)对应的Value--线程标识
这个操作和释放锁
这个操作,这俩操作的原子性,要么一起执行要么都不执行; - 备注: 为什么A判断完当前锁是不是自己的之后会阻塞呢? 这是因为
JVM做fgc垃圾回收
的时候会阻塞所有的代码
- (极限情况下)假设现在如下场景, A线程拿到锁后处理完业务
- 业务思路:
- 因为Redis自带的事务只能保证原子性
不能保证事务的一致性
, Redis的事务是一个批处理,查询KEY(分布式锁标识)对应的Value(线程标识)
和Del KEY
这两个操作会一起处理, 但是我们需要先
查到到标识,判断这个锁是自己的之后才能
Del KEY, 这也就意味着我们在if判断的时候是无法拿到Value(线程标识)的; 所以我们可以将一个乐观锁判断当前锁的Value是否被修改过+Del KEY
变成一个事务, 但是这样又加了一个乐观锁实现起来比较麻烦,最终决定使用LUA
脚本的方式,LUA脚本的执行具有原子性
一致性指事务结束后系统的数据依然保证一致。就是说读能马上读到事务操作更新之后的数据(强一致性)
- Lua脚本语法: 基本的变量定义, if else , 循环等去菜鸟文档看,这里介绍Lua中如何调用Redis的API
- Redis是C写的, 因此Redis中可以调用Lua脚本
- 因为Redis自带的事务只能保证原子性
3.9分布式锁Redisson框架入门指南(上述分布式锁依旧有漏洞)(P64)
- 白学警告: 之前学习的分布式锁的视线方式有很多漏洞如下,虽然可重入锁可以学习JAVA的实现去改造,但是后面这三个问题要解决还是很麻烦的,因此我们
直接
用成熟的分布式锁的框架Redisson(之前学习的那些都是为了帮助我们更好的理解分布式锁可能会出现哪些问题,有利于拷打面试官
) - Redisson如何引入项目:
- 第一种: 通过Redisson和springboot整合的starter来引入,但是他会替代spring官方对Redis的配置和实现,因此推荐第二种
- 第二种: maven只引入Redisson的依赖 + 自己配置Redisson(配置Redisson的方式有两种)
-
- 通过写在yaml文件中配置
-
- 通过JAVA代码配置Redis配置类来实现(工厂设计模式)
-
- Redisson的使用指南
- 业务思路: 直接使用Redisson的API(配置Redisson
->
创建Redisson类->
创建Redisson对象->
调用RedissonAPI结合自己业务实际情况
在合适的时机获取锁释,放锁即可)
3.10Redisson可重入锁原理(P66)
- 业务原因:万变不离其宗, Redisson框架的可重入锁是模拟JDK的ReEntryLock的,这里也是学习如何ReEntryLock的设计
- 业务思路:
- 数据结构的选择:想重入锁,就要记录下当前线程获取锁的次数, 不然单纯考一个线程标识是无法释放锁的; 思考Redis中什么数据结构可以在一个KEY中存两个值呢?, 答案是Redis的
Hash结构
一个KEY存储的是一个对象,这个对象也有自己的键值对,这样就可以一个KEY存储两个值了 获取锁
的Lua脚本(使用Lua脚本的原因之前说了,防止JVM的垃圾回收使得当前线程阻塞导致判断…和释放锁原子性被破坏)释放锁
的Lua脚本
实际上Redisson的源码中获取锁和释放锁的思路与上述相同, 可以去看Redisson的源码,就是将Lua脚本以字符串的形式写死在代码中了
- 数据结构的选择:想重入锁,就要记录下当前线程获取锁的次数, 不然单纯考一个线程标识是无法释放锁的; 思考Redis中什么数据结构可以在一个KEY中存两个值呢?, 答案是Redis的
3.11Redisson解决锁重试和超时释放原理(消息订阅信号量机制,watchDog机制)(P67)
- 锁重试: 利用消息订阅+信号量机制(这个信号量就是被订阅的信号量), 这个信号量可以直接设置成
userId
- 超时释放:
没有设置锁的leaseTime时
超时释放用watchDog机制()解决, 防止业务还未完成然后锁没了的情况
设置了锁的leaseTime时
没有超时释放问题了,因为这是程序员根据业务自己决定的
- 业务原因:解决锁重试和超时释放问题
- 业务思路:
- 消息订阅+信号量机制:
- 同一个用户的其他线程不是一直占用CPU进行等待, 而是获取
该用户锁的剩余时间ttl
和系统设置的最大等待时间time
- ttl < time: 等待ttl时间即可,获取该锁的线程会发布释放锁的消息的, 此时再尝试重新获取锁
- time<ttl: 等待time时间即可, 如果在此期间一直没有收到信号量表明锁此时被释放了,那么就返回false, 因为time一到就不允许再等待了
- 同一个用户的其他线程不是一直占用CPU进行等待, 而是获取
- watchDog机制:
- 实现思路:在定时任务池中设置一个定时任务, 定时任务的内容是重置当前锁的有效期,并不断递归调用自己,达到超时续约(永久锁)的效果;
- 解除思路: 释放锁的时候去定时任务池中取消watchDog的任务
- 消息订阅+信号量机制:
3.12Redisson解决主从一致性(multiLock)(P68)
- 业务原因: 常规Redis集群分为主节点和从节点,
主节点
负责写操作
,从节点
从主节点这同步数据负责读操作
, 但是如果主节点宕机了怎么办呢? - 业务思路: 用Redisson自带的multiBlock可以保证
已经获得过锁的同一用户的请求的线程无法再次获得该锁
- 具体代码实现:
-
- 搭建Redis集群
-
- 配置类配置多个Redis节点的信息
-
- 注册多个RedissonClient和获取多把锁
-
- 注册MultiLock
后续使用起来和单个Redisson的分布式锁lock是一样的, 代码都不用改
- 注册MultiLock
-
- 备注: 本视频的后半部分是对MultiLock源码的探索, 包括该类的tryLock函数unLock函数等源码的分析, 我实力太浅,着实总结不好,也不太能看懂,大家想了解的可以去看P68