遇到问题:
登录流程
session->JWT->SpringSession->token+Redis
(不需要改进为SpringSession,token更广泛,移动端或者前后端分离都可以用)
SpringSession配置为redis模式后,redis相当于分布式session服务器,所有tomcat服务器实例先统一访问redis,SpringSession自动帮我们做到,使用时仍然无感,照常使用session(get set底层都会去redis做),解决了服务器集群session共享问题。即session不在存放于tomcat内存而是redis。
- 用户填好手机号,点击发送验证码(调用发验证码接口,先校验手机号是否合法,是则接口生成随机数验证码,通过session.setAttribute(key,value)其中key="code"和value=随机数,记录在spring session中的redis中,key是手机号value是验证码,交给验证码发送平台开始发送)
- 接着用户把验证码和手机号作为表单整体调用login接口,接口先再次校验手机号是否合法,是则直接从session拿到对应验证码,对传入的验证码与之进行校验
- 根据手机号查数据库拿到整条用户记录(不存在则当场插入数据库创建),把它可用于前端显示的部分放到UserDTO比如userid用户名头像等,密码就不放进去了,最后把UserDTO返回给前端做显示用。
springsession
需要注意,当且仅当第一次显式调用session.setAttribute或get才会在返回一个sessionid给用户cookie之前在redis生成一个session结构,本质是key为sessionid而value是hash,否则如果没显式调用redis中不会有任何session产生。
而且双拦截器的流程也得到了简化,因为springsession在发现我们主动调用session.setAttribute或get时自动更新ttl,不需要手动刷新用户登录会话有效期了而黑马需要
下图是黑马原做法,springsession自动帮我们做到了这些
双拦截器
第一个拦截器逻辑:拦截一切路径刷新已登录用户有效期避免活跃用户重登录
User user=session.getAttribute("user");//如果用户已登录那么还会自动刷新登录有效期
if(user!=null) 放入user_threadlocal供当前请求线程的后续业务逻辑使用
return true;统一都放行
第二个拦截器逻辑:拦截需要登陆的请求路径,未登录用户的请求重定向至登陆页面
if(user_threadlocal.get()==null) return false;拦截
先走双拦截器,第一个拦截一切路径,保证已登录用户可以通过看任何页面刷新redis中token的有效期(如果都放在第二个拦截器且只拦截需要登陆的范围,那么已登录用户只能通过需要登陆的请求来刷新有效期),第二个拦截器只拦截需要登录才能看的页面请求,正常的登录请求或者无需登录就能做的请求是可以通过前两个拦截器的,只有需要登陆才能看的请求会被第二个拦截器拦截
执行完第一个拦截器,说明已登陆用户的请求已经触发redis的token刷新且用户信息UserDTO已放入threadlocal,或者,该用户没登陆于是threadlocal中没东西,
对于未登录者的请求而言,第一个拦截器相当于透明,只有已登录者的请求会因为第一个拦截器而刷新reids token有效期;
接着请求分化为 未登录者的请求是无需登录就能看的
和 未登录者的请求必须要登录才能看
两种,前者放行不会被第二个拦截器拦截,后者拦截
秒杀核心演进
乐观锁->大于0->lua脚本
最开始想用乐观锁cas(当前线程先查优惠卷拿到库存数,再拿着查到的库存数执行update where 库存=查到库存,作为版本号依据),这样两个数据库操作即可
但这样成功率太低,实际上update时只要求库存大于0就可以了,然而这样还是太慢了,秒杀需要直接对接两个数据库操作
于是开始使用redis+lua脚本来做,还能加上一人一单功能
Threadlocal内存泄漏
postHandle在控制器方法执行完毕后但在视图渲染即视图解析器前)执行
afterCompletion在整个请求完成后(包括视图渲染完成)执行 适用于资源清理(如 ThreadLocal 清理、日志记录),一定会执行
token
这里只用了uuid返回给用户作为用户token,而没用jwt
每次登录成功会把生成uuid作为token返回给用户并作为key把数据库查到的用户对象即value插入到redis中(30min有效期),然后每次用户请求都走登录校验拦截器不符合条件会被拦截请求不走后续流程,校验用户是否持有token,有就进一步检查该token作为key是否存在于redis,存在则把对应的value即用户对象放到当前请求的线程即threadlocal方便后续使用,并且刷新token有效期,不然用户明明在使用该软件,你却让他重新登陆
redis key问题
登录之后把dto比如用户头像昵称存放在redis时(返回的)key不是手机号而是token,这样前端storage存的就是token,以后每次ajax请求都带上token,而不是把隐私存放在前端,因为前端就是容易被偷。但是黑马这里竟然用uuid纯随机严谨说存在重复的可能,(可能可以用jwt)
登录校验在拦截器中,redis中有了token-UserDTO就代表登录校验成功不必再登录,并且会刷新该kv对的有效时间,相当于登陆之后访问系统内的其他请求可以延长过期时间
不过获取验证码即登陆之前的阶段,确实是用手机号作key存生成的验证码的
id生成器
业务名称+年月日 作为key放在redis记录该业务某天的自增值(方便统计每日订单量)
最后用时间戳(距离给定时间的秒数)拼接上该业务自增值返回,随后在redis该业务自增值自增
但是自增值放redis,redis宕机了就完蛋了,而且是中心化的
@Transactional
@Transactional 注解主要用于确保数据库操作的原子性,即事务内的操作要么全部成功提交,要么全部回滚。但它并不直接提供线程安全,也无法阻止其他线程在事务执行期间访问或修改相同的数据
大致实现原理是aop,得到代理对象,然后原本被@Transactional标记的方法就会在代理对象中被增强,代理对象的前面关闭事务的自动提交后面帮你手动提交事务
所以项目中,对于用户重复提交秒杀卷问题的解决方案是对@transactional修饰的方法的外面上锁(锁对象是用户id.intern()),因为原本在方法里面就放锁会导致事务还没提交,那同一用户的其他相同抢购请求(类似于幂等性,同一个用户一直点击抢购)就会查订单数据库就会发现还是第一次买就无法实现项目的一人一单了
在不同类中,非事务方法A调用事务方法B,事务生效。
在同一个类中,事务方法A调用非事务方法B,事务具有传播性,事务生效
在不同类中,事务方法A调用非事务方法B,事务生效。
@TableField(exist = false)
是 MyBatis-Plus 注解,用于标记实体类中的某个字段,表示该字段在数据库表中不存在。
exist = false:表示该字段不是数据库表的列,MyBatis-Plus 在进行数据库操作时会忽略它。
优惠卷
优惠卷有一个子类秒杀卷,二者单独成表,但秒杀卷主键来自优惠卷,新增秒杀卷时,会分别在优惠卷表和秒杀卷表插入,秒杀卷表额外记录秒杀特性比如生效时间失效时间库存,同时java中的entity voucher额外增加了秒杀卷拥有的字段且使用@TableField(exist = false) 标记,这样商家前端提交秒杀卷和优惠卷时都可以用Voucher参数接收
超卖问题
同一时刻(或者极短的一段时间内)下单线程数大于库存就非常容易发生,因为库存未被扣减就已经被大家读到库存数,后面就都以为库存数充足都进行扣减
本项目采用乐观锁且进一步改进,update时where条件为 库存>0 来代替传统乐观锁的要求update时与前面select结果一致(传统方法成功率低)相当于最终依赖数据库update时的行锁来保证
为什么要用分布式锁
启用多实例的时候相当于启动了多个jvm进程,而synchronized是jvm级别的,所以你锁不住不同jvm中的资源的
秒杀抢购秒杀卷
用户抢购时会先用lua脚本走redis,具体lua里面的逻辑是先利用redis中存放的优惠卷判断库存是否充足,是则再利用redis的set存每个优惠卷的购买用户id,判断用户是否已经买过(一人一单),如果第一次买,那就把一个订单对象需要的优惠卷id用户id以及生成的订单id以若干键值对为一个整体的方式(外部执行lua之前生成传入lua)发送给redis的stream消息队列,
接着服务器搞了一个线程池后台阻塞监听对redis的stream消息队列是否有订单消息,一旦有就开始数据库操作,扣减mysql库存内存中的订单对象弄成mysql中的,
于是抢购秒杀卷的时候变成异步下单了,即只是用redis发送了下单的消息就立刻return,等到后台线程看到这条消息再做数据库层面的处理
点赞功能
前面接一个布隆过滤器,因为多数文章都不会被当前用户点赞,
利用了redis,每次点赞接口都会先检查redis中的文章(key就是文章id)的sortedset是否已存在该用户id(但没加失效时间,这里当作无无限内存了),没有说明用户没点赞过那就把点赞用户id和时间戳作为score插入到sortedset并让文章在数据库点赞数++,反之移除并让文章在数据库点赞数–
这里不是set而是有序set,这是为了利用有序set有序和唯一,把点赞用户id和时间戳作为score插入到sortedset就是为了显示最近五个点赞用户的小头像
涉及到(根据文章id)查询文章的接口也需要利用当前用户id先在redis的文章有序set中找到是否已点赞,从而让前端决定是否高亮,同时取出top5最近点赞用户的id然后把这个list批量查数据库拿到头像用作显示
细节点
取出top5最近点赞用户的id然后把这个list批量查数据库拿到头像用作显示,用的是mybatis的listbyids接口,这个接口本质上是select * where id in(给定list的所有元素)语句,但是in语句不确保顺序,比如in(1,5),但返回的数据是(用户5,用户1),因此需要改造sql语句,多拼接一个"order by(给定顺序)",
比如select * where id in(1,5) order by field(id,1,5)即根据id字段排序并且是按照我给定的id字段顺序,最终就会返回(用户1,用户5)
redis的list是双向链表
所以可以按添加顺序排序,然后选择lpop或者rpop
关注取关以及共同关注功能
弄了一个关注表,id自增,然后存放被关注者id以及发起关注者的id(做联合索引),每次打开文章页面就可以查数据库是否存在关系从而决定关注按钮的显示,点击关注就是数据库增,取关就是删,但是为了实现共同关注功能,关注时还要插入到redis的被关注者id为key的set中(即redis中每个人都有自己的一个kv对用于记录自己粉丝id列表),删除则也是额外删redis,这样两个用户redis中的set求交集就可以拿到共同关注了
细节
这里设计很low,靠前端用参数告诉接口这次是取关还是关注,这样用户可以通过postman而非前端方式疯狂告诉后端接口这次是关注,从而在关注表疯狂添加关注数据
Feed流推送?????
这里用的是简单的发文章者推送粉丝收件箱,假设每个人在redis中都有自己关注的博主发的文章的收件箱,即有人发了文章除了要增数据库,还要数据库查(上面不是说redis中已经存了每个人的粉丝id列表了吗???????)到该人所有粉丝的id,
(其实就是一个kv对,k是用户id,v是关注的博主发的文章id list,这样用户每次打开关注那一页都是关注的博主发的文章),然后拿到粉丝id再去redis中找到对应粉丝收件箱存入自己的文章id
附近商户功能
美团有很多分类,比如吃、喝、玩、乐四类,(商户在插入商铺信息的时候就已经选好类别插入到数据库了,反正就是商铺数据库带有类型字段了),然后每一类都在redis中分别创建一个GEO,商户插入商铺信息到数据库的同时还会插入到对应分类的GEO容器中,key是商铺id,value是商铺经纬度,这样用户带着自己的经纬度和类型以及第几页参数传来接口,接口直接利用GEO的圆距离函数拿到某个距离内的所有商家的id,拿着所有范围内id再去数据库查即可
经纬度
redis6.2后支持经纬度数据结构,底层用有序set,可装入多个经度纬度对,每个经纬度对可以有单独的名字,类似于redis的hash的key中key,不过存的时候经纬度对会被转换为一串编码,然后还可以求距离
XXX经纬度容器
北京-经纬度编码 XX-经纬度编码
签到功能
bitmap直接4个字节存完一个月的签到情况而不是签到一天就增一条数据,redis用string实现了bitmap,最大512MB即2的32次方个bit位
这样每个用户每年的每个月只要用一个key为 用户id+年月
value为该月bitmap
即可,也就是redis的bitmap只用最多31位不需要真的用到2的32次方个bit位
通常可以设置过期事件为一个月,只保留一个月具体每天的签到数据,其他月份则用统计形式表示,不然一个用户每年12个key没必要,
统计今天为止连续签到天数
假设今天是X月a日,那就从bitmap拿到前a位bit,逐个遍历,如果当前为是1就++,否则直接返回
不同维度看待bitmap
上面属于每个用户的维度
你甚至可以用时间维度来做,比如用30个bitmap表示30天,每个bitmap的第i位表示今天编号为i的用户是否签到了,但这样要统计一个用户一个月的连续签到次数就得查30个bitmap
布隆过滤器
使用redisson的
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.data.redis.core.StringRedisTemplate;
@Service
public class UserService {
@Autowired
private BloomFilterService bloomFilterService;
@Autowired
private StringRedisTemplate redisTemplate;
@Autowired
private UserRepository userRepository; // 通过 JPA 或 MyBatis 获取用户数据
public User getUser(String userId) {
// 步骤 1: 使用布隆过滤器检查是否存在该用户
if (!bloomFilterService.mightContain(userId)) {
return null; // 用户不存在,直接返回空
}
// 步骤 2: 检查 Redis 缓存
String redisKey = "user:" + userId;
String cachedUser = redisTemplate.opsForValue().get(redisKey);
if (cachedUser != null) {
return deserializeUser(cachedUser);
}
// 步骤 3: 如果 Redis 没有缓存,查询数据库
User user = userRepository.findById(userId).orElse(null);
if (user != null) {
// 步骤 4: 查询到数据库,缓存到 Redis 并添加到布隆过滤器
redisTemplate.opsForValue().set(redisKey, serializeUser(user));
bloomFilterService.addElement(userId);
}
return user;
}
}
遇到的难题
aop失效问题
现有一个类X,有A和B方法,其中B方法内部调用A方法,A被aop切到增强了。此时调用X的bean的A方法返回的是增强后的结果,调用B方法(中的A方法)却没被增强,即B方法中调用的A方法是朴素A方法即未被AOP。
这里aop失效的根本原因是,springaop采用的jdk和cglib它们生成的代理对象本质上最终都会调用被代理对象的原方法,具体而言
jdk会
持有被代理对象
,比如jdk的代理类通过继承Proxy类,而Proxy类持有用户传入的invocationhanddler,而invocationhanddler中持有用户传入的原对象从而调用了被代理对象的原方法
,是旧版本springaop采用的方式
cglib
可以通过lambda在写拦截器的时候把外部的被代理对象放入并调用methodproxy.invoke(被代理对象,args)方法从而调用了被代理对象的原方法
,即本质是 强转 (被代理类)传入对象.方法名() ,这种和jdk类似一些(就调用了被代理对象的原方法而言),是新版本springaop采用的方式
,如果传入对象是代理对象就会死循环,这种可以算作间接持有。- 可以通过正常的methodproxy.invokeSuper(拦截器参数中提供的
代理对象
,args)实现调用代理对象的父方法super.即原方法。是正常使用方式
。本质是 强转 (代理类)传入对象.super.方法名() 如果传入对象是被代理对象会报类型转换错误即父类对象无法转为子类对象
上面提到,调用@transactional修饰的方法并且调用时对@transactional修饰的方法的外面上锁(锁对象是用户id.intern())
调用@Transactional标记的方法时,不能直接调用,需要通过一个spring给的api拿到代理对象(在生成代理对象的时候会存到当前线程的ThreadLocal)从而调用代理对象的该方法。因为aop的实现即动态代理本质是持有原对象最终调用原对象的原方法,你原方法用this去调用@Transational即需要aop增强的方法最后只会调用未被aop的,因为增强的方法在代理对象中不在原对象中
cglib无反射原理
上面仅仅提到methodproxy.invoke和invokeSuper的行为,但没解释底层是如何执行方法的,比如执行哪个方法
每次enhancer填入不同的被代理类都会生成三个类分别是代理类本身,代理类的fastclass,被代理类的fastclass
动态代理你最终一定要提供一个代码块让用户写自定义的增强逻辑,并且你还应该提供一个调用原方法的途径让用户手动控制原方法的调用时机,而cglib提供的代码块就是拦截器,提供的调用原方法的途径就是Methodproxy,其实cglib本身也是用到了反射(拿到Method对象供用户拦截器使用比如打印当前方法)但调用原方法的时候用的是索引加硬编码即可理解为代理类对象直接调用父类即被代理类的方法即具体method名(),即比jdk省了一个Method.invoke(代理类对象,arg),
比如jdk反射拿到A方法的Method对象,然后让用户又利用反射调用A方法即A的Method.invoke(代理类对象,arg),
而cglib反射拿到A方法的Method对象,但不去使用(而是仅仅作为辅助信息提供给拦截器),而是根据之前创建A方法的Methodproxy中存着代理类的fastclass类的对象和被代理类的fastclass类的对象以及两份分别用于在fastclass中找到要执行的方法的索引序号,在用户调用Methodproxy.invokeSuper(代理类对象,arg)时会去代理类的fastclass类对象的invoke并传入对应序号,代理类的fastclass类的invoke内部就是根据序号做switch,每个序号对应了一个方法即 (代理类)传入对象.super.方法名()
,Methodproxy.invoke则同理,有独立的序号,每个序号对应了一个方法即(被代理类)传入对象.方法名()