黑马点评学习

Day1

在idea使用运行配置启动项目

  • 添加Service到底栏
    img

  • 添加运行配置类型springboot
    img

  • 通过这里启动
    img

  • 相比于启动类的好处:
    img

使用git控制项目
VCS:版本控制系统

  • git忽略文件,git上传时忽略这些文件,自动生成
    img

  • VCS菜单下:
    img

  • 选中项目根目录,自动创建本地git仓库

  • 在gitee创建仓库,复制地址
    img

  • idea里点击推送,需要关联远程仓库
    img

  • img

todo:mybatisPlus速通一下

  • img

todo:controller的session参数是自动注入的吗

todo:slf4j怎么配置的

XxxMapping路径不论是否加/ spring都能正确处理
img

使用反向校验而不是正向校验,防止if嵌套过深。

UserServiceImpl的login方法,判断String类型code是否相等,不能用 != 而必须用 .equal
img
img
img

IDEA的数据库表查看,默认只显示500条数据

通过拦截器进行登入校验
由于对于每个controller都需要登录校验,需要将此逻辑抽取出,在所有controller执行之前校验————拦截器。
后续操作可能需要user信息,所以通过ThreadLocal来保存User信息。
img
这里可以只实现拦截器接口部分方法,是因为接口中为这些方法声明了默认方法。
img

写完拦截器之后需要通过配置类注册,通常在MvcConfig中

登录校验优化
session是tomcat服务器的内存空间,应该避免浪费
login的service类中,直接存入DTO而不是user
img

Ctrl i 效果与 ALT Insert效果相同

构建器Builder的使用
苍穹外卖使用的是基于lombok的,想要使用builder,需要在类上添加注解,它主要依赖构造函数。
img

属性拷贝函数是spring框架自带的
img

使用Redis优化Session存储的code和User
使用session时,若服务器是集群,一个浏览器请求,先后被分配的服务器可能不同,不同服务器之间session不共享

存User使用Hash类型,支持对单个字段的CRUD,并且占用内存更少

使用随机字符串(UUID)作为存储User的key,因为token会被保存在前端,
作为登入凭证,只要请求携带token,后端可以从redis查到user,则认为是登入成功状态。
直接选用手机号作为token,不安全。

短信登录注册,需要将Token返回给前端,因为登录状态验证时:
原来只需要尝试从session中获取user,request自动携带Jsessionid作为登录凭证
使用Redis只能由前端请求时自己携带,故login需要返回token给前端

修改代码:

  • sentcode修改
    img
  • login函数修改:
    从redis中获取code
    使用UUID创建token
    使用BeanUtil将userDTO转化成map,使用hash格式,将user存入redis,设置有效期
    img
    img
  • 拦截器函数:
    注意给User设置的30分组有效期应该是30分钟未访问则删除,所以应该在用户请求时,不断更新有效期。
    每个请求都会经过拦截器的校验。
  • 类型转换异常:使用的是StringRedisTemplate,要求Key和Value均为String类型,但是使用BeanToMap时候,id是long类型的。
    img
  • 对于访问非拦截路径,则不会刷新登录状态,需要改进:
    多加一层拦截器,拦截一切路径,将下个拦截器,保存到ThreadLocal和刷新token功能迁移到此。
    下个拦截器只需要从threadlocal获取即可
    注册拦截器优先级,order小的先执行
    img
  • 关于拦截器的post和after方法,由于前后端分离开发模式下,后端不需要渲染view,所以其功能十分有限,无法修改json数据,
    after函数可以为json添加公共字段,处理资源释放,并且不论是否异常,均执行

关于工具类BeanUtil和BeanUtils
苍穹外卖中使用的是BeanUtils,是org.springframework.beans包下的工具类
而黑马点评使用的是BeanUtil,是Hutool工具类下面的,同样有copyProperties方法

Redis拦截器总结
用户登入,login接口验证用户的验证码正确后,保证数据库user表中存在该号码的用户后,为用户生成token,并且以token为key,将user信息存入redis,将token返回给浏览器。
浏览器的每个请求都携带token,请求被拦截器refresh拦截,从redis中读出User对象存入Threadlocal,放行。
在login拦截器中校验是否登入,通过尝试从threadlocal中获取user对象。
问题:
为什么要用threadlocal?
:因为token放在Request头中,除了controller层获取不到,没有token就无法查redis。

是否会登录一次生成一个token呢?
:是的,因为login接口会直接创建新的token。

token的有效范围是?如果新开一个标签页就没用了吗?token存在哪里?
:token是以cookie形式存储的,如果新开一个标签页,发现token信息丢失。

todo:为什么login请求看不到返回值

todo:涉及的redis操作总结

Day2:商户查询缓存问题解决

缓存是数据交换的缓冲区
web开发的每个阶段都可以应用缓存
好处:
降低后端负载
提高读写效率,降低响应时间
成本:
数据一致性成本
代码维护成本:为了解决问题,代码复杂
运维成本:往往需要搭建成集群模式,增加成本

todo:springCache
查询商户,添加缓存

isBlank方法:
判断一个对象是否为空,或者只包含空白字符。

为什么要转化成对象再返回,消息转换器不也是返回json格式吗?
img
经测试,似乎有问题。
img

这是返回对象,的响应体
img
img

这是返回JSON,的响应体
img
img

原因:
RestController注解,在处理返回值时候,会自动把对象类型的数据,转化为json格式传输,但是对于基本类型,如String等,会以text格式传输。
如果封装在Result对象中传输,若Result的属性是对象类型,则会被自动序列化json类型。若是基本类型,则会作为json的一个value处理。
如果我将json串作为data传给result,data此时的类型是String,spring的Jackson序列化器会对String类型的data处理(对 " 转义为 /" ,保证json格式正确),传送处理后的string,如上图。前端也不会以json格式解析这串。
标准json格式:{"key":value,"key":value},引号会破坏json格式,所以会被转义。

todo:商户查询缓存03作业没做

缓存更新策略
解决不一致性问题
内存淘汰策略:redis用于解决内存不足问题,redis自动触发,淘汰数据。一定程度能维护数据一致性:淘汰的数据需要重新从数据库查询获得。
超时剔除:利用expire给数据添加ttl,自动删除。一致性取决于设置存活时间的长短。
主动更新:自己编写逻辑,更新数据时,主动更新redis。较好维护一致性,维护成本高。

高一致性场景,采用主动更新加超时剔除。

主动更新策略:
Cache aside pattern:由缓存调用者自己编码,更新数据库的同时更新缓存。(常用)
Read Write through pattern:缓存与数据库整合为一个服务,对操作者透明,由服务统一维护一致性。调用者不关心数据源是数据库还是redis。
write behind caching pattern:调用者只操作缓存,由另一个线程负责定时异步地将缓存同步到数据库。

cache aside pattern:
更新缓存or删除缓存:
更新缓存:每次更新数据库都更新缓存,若多次更新期间无人查询,则产生无效写操作。
删除缓存:更新数据是删除缓存,查询时再更新。

保证缓存与数据库操作同时成功或失败:(原子性)
单体:放在一个事务中。
分布式:利用TCC分布式事务方案。

先操作缓存还是先操作数据库:(线程安全)
只有缓存失效,才会写入缓存。两种本质上都是在缓存删除之后,将旧数据写入缓存,导致数据库和缓存的不一致。
先删除缓存,再操作数据库:删除缓存,查询,写入缓存速度快,更新数据库速度慢。若线程一删除缓存后,线程二查询缓存未命中,查询数据库获得旧值,写入redis,线程一再更新数据库。导致了缓存和数据库的数据不一致性。即:redis被重新写入旧数据。
先操作数据库,再删除缓存:需要线程一缓存失效时,数据库更新前查询数据库,此后线程二更新数据库,删除缓存。线程一最后将旧数据写入缓存。此情况要求比较苛刻

推荐选择第二种,并设定超时时间,若发生错误,使错误数据只保留一段时间。

修改代码:
查询店铺若未命中,则查询数据库,将结果写入缓存,并设定超时时间
根据id修改店铺时,先修改数据库,再删除缓存。

@Transactional会在发生异常或错误时候,回滚数据库操作,对于redis什么的不会处理。
java里任何类型都可以和string类型相加,会自动调用tostring方法。

todo:postman或apifox构造请求方法

缓存穿透:客户端和数据库均不存在该数据,缓存不生效,每一次请求都会到达数据库。
缓存空对象:不存在时存入null到redis
优点:简单
缺点:可能存在短期不一致性,占用内存(可以通过设置过期时间缓解)

布隆过滤器:客户端和Redis中间加一层布隆过滤器,若不存在则直接拒绝请求。
原理:布隆过滤器是一个二进制数组,将数据根据哈希算法映射到数组上,数组的0 1值标识是否存在。
有点:空间占用小。
缺点:实现复杂。有误判风险:布隆显示存在,可能实际不存在。有穿透风险。

此外还有一些主动解决的方案:
增加id的复杂度,避免id被猜测。若不符合id规律,直接返回错误。

加强用户的权限校验。

做好热点参数的限流。

修改代码:
采用缓存空对象的方法。
img
修改此处代码,查询结果为空,也存入redis。
img

由于修改,redis中可能为空数据,所以在取值时,需要判断。
原本若redis isNotblank则直接返回,没返回的要不是""要不是null,若是"",则说明是我们手动设置的,则直接返回即可
img

缓存雪崩
同一时间段大量key同时失效,或者redis服务宕机,导致大量请求到达数据库,带来巨大压力。
解决方案:
给不同的key的ttl添加随机值。(缓存预热批量导入数据时)
利用redis集群,增加redis的可靠性,防止宕机。redis哨兵,实现服务的监控,主从模式。
缓存业务增加降级限流策略。(快速失败,拒绝服务,牺牲部分服务,防止服务全挂)
给业务增加多级缓存

缓存击穿
部分key过期导致的严重后果,热点key问题,高并发访问并且缓存重建业务比较复杂的key突然失效,被无数请求访问会瞬间给数据库带来巨大的冲击。
:缓存重建的时间较长,多个请求连续到来,发现缓存失效,都进行缓存重建访问数据库,给数据库带来压力。
解决方案:
互斥锁:
给缓存重建加锁,只有获取锁的线程才能访问数据库。此时若有其他请求到来,先查询redis,发现缓存失效,尝试获取锁,发现锁被占用,休眠一段时间,重新从查询redis开始尝试。
一致性优先
问题:互相等待,多个线程都在等待,导致性能较差。有死锁风险。
优点:无额外内存消耗。保证强一致性。实现简单。

逻辑过期:不设置ttl,缓存数据加入一个expire字段,该字段为存入时间加ttl。理论上只要放入redis,就都能查到数据。查询出数据根据expire值判断是否过期。若过期,则加锁,开启一个新的线程做查询数据库,缓存重建。开启重建线程后,直接返回旧数据。若此时有其他线程,发现数据过期,尝试获取锁失败,则直接返回旧数据。
可用性优先
优点:性能较好。
问题:不保证一致性,额外内存消耗,实现复杂。

修改代码(互斥锁方法):
根据id查询商户。
查询redis,未命中,尝试获取锁,若获取失败,则等待一段时间,然后回到查询redis,开始下一轮尝试。
由于提供的锁,默认获取失败则等待,这里需要自定义逻辑,所以采用自定义锁。
采用String类型的Setnx实现锁,若成功插入,说明此前没人加锁,上锁成功。若插入失败,说明已经被加锁,上锁失败。
释放锁只需要把set的数据删除即可。

在shopServiceImpl中:
首先定义获取锁和释放锁的方法,便于调用。
serIfAbsent函数可能返回null(若连接redis失败),由于获取锁函数返回值是基本数据类型,自动拆箱可能出现空指针异常,所以用BooleanUtil isTrue处理。
boolean作为基本类型,值只能为true或false

获取互斥锁,并给锁添加一个有效期,有效期需大于缓存重建时间,注意锁id不能与店铺数据的id相同:
首先尝试从redis获取数据,获取失败则尝试获取锁
若获取失败,则休眠一段时间,重新返回上一步
若获取成功,首先进行doublecheck(否则每一个线程都会访问数据库),尝试从redis获取数据,若失败再查询数据库,更新redis,释放锁。

快捷键:ctrl alt t 插入try catch

img
这里是否会发生:获取锁失败,但是却释放锁?
前面有一个递归,若获取锁失败就会进入递归,只有解锁后,才能运行到finally代码。所以应该不会出现。

由于重建本地数据库太快了,所以增加一个休眠代码,高并发时其他线程有时间在上锁的情况下,获取锁。
img

问题:在打开nginx时可能由于端口被占用,导致启动失败,通过直接点击nginx.exe不会报错,可以通过logs下的error查看错误日志,推荐用控制台启动:start nginx.exe
问题:又遇到一直显示数据库连接错误,连接池错误之类的,重启多次后自己好了,ai说是数据库maven导入的数据库驱动应该升级到8.x

todo:.bat是什么后缀

修改代码(逻辑过期方法):
首先需要缓存预热,存热点key到redis中
从redis查询缓存,我们默认热点key都存在缓存中,
若未命中,认为非热点key,直接返回空。
若命中,则判断是否过期
若未过期,直接返回
若过期,则尝试更新缓存
首先尝试获取锁
若获取失败,则直接将旧数据返回
若获取成功,则开启一个新进程执行缓存重建,自己返回旧数据
新进程查询数据库,更新缓存数据,设置有效时间(逻辑有效时间)
释放锁

如何为shop增加一个expire属性呢?
方案一:修改shop类的源码,添加一个属性。
不好。修改了源代码。

方案二:另外定义一个对象RedisData,设置一个expireTime属性
此时又有两种方案:让shop类继承它,或者为RedisData添加一个Object类型的data属性,使他能聚合任意对象。
我们选择后者。

基于单元测试做缓存预热:直接调用Service层的save2Redis方法。

queryWithLocalExpire方法,不考虑缓存穿透问题。事实上,所有返回的数据都是通过redis获得的:未命中返回空,命中后过期与不过期返回的都是redis查到的数据。(看看缓存重建如何处理查不到数据的情况)

关于java静态类型与动态类型的转化问题
用obj 类型接收 shop类型
静态类型是obj,编译器只知道静态类型
动态类型是shop,JVM运行时知道实际类型是shop
当从json类型转回obj类型后,没有信息能推断原类型是shop,信息丢失,此时:
静态类型:obj(因为redisdata类的属性就是obj类型)
动态类型:jsonobj(hutool的tobean的默认处理方式)

快捷键:移动行:shift alt 方向键

通过修改数据库中shop的名字,来判断查询出的数据是旧的还是新的。

todo:拉姆达表达式到底是啥,函数式编程(函数式接口)是啥(好像是java8的新特性),泛型方法学一下,多线程学一下,反射学一下

优惠卷秒杀

全局唯一ID

用户购买优惠卷需要生成订单id,若订单id依赖自增,存在以下问题:
id的规律性太明显
受单表数据量限制,当订单数量达到一定数量级,需要分表存储,而mysql的自增是单表内自增,会出现id重复

全局id生成器,分布式系统生成全局唯一id的工具,需要满足特性:
唯一性
高可用
高性能:生成id速度够快
递增下:替代数据库主键id,有利于数据库创建索引,提高查询效率
安全性:规律性不能太明显
--> 利用redis的string数据结构的incr命令,因为redis是唯一的,分在多个表不影响。

为了保证安全性,不能直接使用redis自增数值,拼接其他信息:
唯一id采用数值类型(Java的Long型):提高数据库性能,占用空间更小,建立索引更方便。
long型占用8B,即64b
第一位是符号位,永远为0
中间31位是时间戳,以秒为单位,指定一个起始时间,标识距起始时间的时间差(能支持69年)
最后32位是序列号,即redis自增的计数器

其他生成策略:
UUID:jdk自带工具,生成16进制,返回是字符串类型,且没有递增特性
redis自增:刚刚学的,好
snowflake算法:雪花算法,不依赖redis,可能性能更高,对时钟要求高
数据库自增:redis自增数据库版,性能较差,另外一张表维护id,订单表从索引表获取id

在jmeter的http请求头添加字段
img

秒杀--超卖问题

超卖问题,高并发情况下:
代码逻辑:先查询库存,再判断,成功再扣减
某一时刻多个线程在扣减逻辑执行前,执行查询操作,均得到库存充足的结果。
采用加锁的方法解决
悲观锁:线程安全问题一定发生,执行数据操作问题先获取锁,线程串行执行
java提供的synchronized lock均属于悲观锁,数据库互斥锁也是悲观锁
特点:性能不好

乐观锁:(更新数据才可以用,因为依据是数据是否改变)认为线程安全问题不一定发生,不加锁,在更新数据是判断是否有其他线程修改了数据。
若未修改则认为是安全的,自己更新数据。
若已经修改则认为发生安全问题,重试或异常。
特点:性能好。
问题:如何判断数据被修改?
版本号法:每次修改数据,版本号加一,只需要判断版本号是否变化即可。
set
stock = stock - 1,
version = version + 1
where
id = 10
and version = 1
在一条sql语句中同时执行判断和修改操作,判断成功则修改
update语句有行锁
此方法可以防止aba问题,a查询到a,b修改为b,再修改为a,a判断,发现数据没变化。

CAS方案:compare and swap
库存代替版本,直接判断数据是否变化
set
stock = stock - 1
where
id= 10
and stock = #{prestock}

使用乐观锁方式,200人抢100张,只卖出了20张,成功率过低。
修改,只要判断库存大于零即可。

秒杀--一人一单

userId + voucherId 的组合在表内必须唯一
在判断库存充足后,根据优惠卷id和用户id查询,判断是否存在订单数据,若存在直接返回异常:一人限买一单
发现无法解决问题:
因为可能多个线程同时查询订单,结果为0,接着同时创建订单,一下卖出多张。从查询结果到扣减库存生成订单之间有空隙(不具备原子性)。
解决方法:加锁
因为乐观锁只能用于更新操作,所以使用悲观锁。
从查询订单(判断订单是否存在),到扣减库存,到生成订单,都是加锁范围。因为判断的依据是订单数据,必须等到订单数据更新后,才能解锁。
提取出方法:
img
若在方法上加synchronized,锁的对象是this,把整个方法锁住。所有用户都抢同一把锁。性能很差。
移动事务注解到提取出的方法,因为事务是针对修改数据库操作。

synchronized应该给用户id加锁。
注意,synchronized(userId.toString),因为userId是Long类型的,每次get都是新对象,所以使用toString,想要获得相同的对象,但是toString底层是new,在堆中创建,还是新的对象。所以得用 userId.toString().intern()

方法内部加锁,transactional是方法执行完后提交,此时锁已经释放了,此时其他线程已经可以进入此方法。所以范围过小了,应该是函数执行完再释放锁。

应该在调用函数的时候加锁。

todo:spring常见事务失效常见?
img
事务是通过aop代理对象实现的,(想起来controller层依赖注入的时候都是通过接口引用接收的,所以能保证事务不失效?)但是函数里主动调用类内另一个方法时,实际上是this.xxx,不能调用到代理对象,所以需要做一些处理。
img
img
需要导入依赖
img
启动类开启暴露代理对象
img

集群模式下的秒杀

service下复制多个启动配置
img
修改端口参数
img
这样来模拟集群。
nginx的配置文件,nginx.conf打开8082端口的注释
img
cmd中重新加载配置文件
img

集群模式下,synchronized锁失败。
synchronized获取锁失败,则等待,重复查询。
锁的实现原理是在JVM内部维护一个锁监视器,synchronized()传入的参数作为判断是否为同一个锁的依据。
集群部署时候,每个服务器都是独立的tomcat,有自己的jvm,锁监视器相互隔离,所以无法锁住。
解决方法:需要一个跨jvm,跨进程的锁,只有一个锁监视器 -->分布式锁

分布式锁

满足分布式系统下或集群模式下,多进程可见,互斥的锁。
需要满足特性:
多进程可见
互斥
高可用
高性能
安全性(考虑异常情况,是否导致死锁)

mysql写操作时候,自动分配互斥锁
断开连接,自动释放锁

Redis使用setnx互斥目录实现互斥
利用key过期机制,到期释放,保证安全性

Zookeeper利用节点的唯一性和有序性实现互斥:
多个线程创建的节点,id单调递增,约定id最小的获取锁成功,利用有序性实现互斥
Zookeeper强调强一致性,数据同步消耗资源,性能一般
img

基于redis的分布式锁
为防止tomcat宕机导致资源未释放,加上过期时间
保证setnx 和 expire 操作的原子性:
set lock thread1 nx ex 10

获取锁失败的操作:阻塞式:等待; 非阻塞式:成功true,失败false

快捷键:ctrl shift u 一键切换大小写

posted @ 2025-04-26 14:24  violet0evergarden  阅读(132)  评论(0)    收藏  举报