1.登录
1.1用户信息表
1.2微博社交登录流程
OAuth2.0基本流程
整合前后端页面进行社交登录(微博)
主要流程:
- 点击微博登录跳转到微博指定的登录页面
- (获得)输入账号密码提交 如果输入正确也就是登录微博成功
会向我们指定的跳转页面进行跳转 并带上code
- 我们的服务拿到code码后,然后根据code码向微博发送请求获取
Token和UID等
,获取到了说明登录成功。获取不到说明code错误,直接返回错误。 - 然后调用远程服务进行注册或者登录
- 远程登录会进行判断 根据UID 查出数据库中如果没有这个数据就进行注册
插入code码和Access Token和UID
,然后根据UID+TOKEN可以查询到用户的微博公开信息(昵称等)进行 - 如果原来使用微博登录过 就把code码和Access Token更新。
code码只能用一次,Token一段时间内都可以用
1.3手机验证码登录注册流程
测试接口使用
1.收集验证码流程
2.注册流程
前端页面数据包括(用户名(唯一)
+ 密码 + 手机号(唯一))
页面填写数据完毕提交给认证服务,进行JSR303数据格式校验(注解加在字段上),数据校验通过后,然后根据手机号获取redis中的数据,没有对应的value,直接返回错误,有value取出value进行对比,错误直接返回,正确后先删除redis的kv
s,在会员服务
进行数据的插入
插入时先判断用户名和手机号是否唯一(使用的是自定义异常机制处理,向controller抛异常)
密码加密:MD5 + 盐 缺点:需要在数据库中建一张盐值表,保存每个用户注册时密码加密的盐值
最终使用springBCrptPasswordEncoder,同一个明文加密得到的密文不一样,而且数据库也不需要存储盐值,原理就是密文中包含了盐值,只有spring知道
登录流程
可以使用用户名 或者 手机号
+ 密码进行登录
因为密码是加密存储,所以先根据用户名或者手机号查询密码,然后使用BCrPE将两个密码进行匹配,登录成功说明密码正确,不匹配说明密码错误
1.4SpringSession
Session共享的方案:
- 1.Nginx的ip_Hash
- 2.tomcat配置各台服务器的seesion共享
- 3.SpringSession
登陆成功后 (auth.gulimall.com),将用户信息存储到Session中,key是"loginUser"(随便一个字符串),value是用户的个人信息对象
,
需要跳转到guilmall.com(商城首页),但是默认的cookie只在本域名下有效,为了让它在父域名下也生效,使用SpringSession在命令浏览器保存cookie时放大作用域即可(放大到一级域名)
,并将session存储到redis中,保证各个服务之间共享session.
判断用户是否登录
- 使用SpringMVC的拦截器,实现Interceptor,prehandle(目标方法执行前干的事),postHandel(目标方法执行后干的事),
- 在preHandel中直接判断session(redis)中是否有用户,有用户说明已经登陆,取出用户的信息放到
ThreadLocal中(threadlocal.set)
,(ThreadLocal配置成静态的),然后其他的controller直接调用threadlocal.get()直接取出用户的数据即可。 - 判断session中没有用户,直接返回登录页去登录
2.优化商城首页
2.1整体概述
2.2获取三级分类SQL语句加索引
表结构是,cid(主键) parent_id(它的上一级分类id)
第一步,先查出所有的一级分类,根据一级分类的parent_id为0来查询
select cid from xx where parent_id = 0
主键cid是有索引的,但是parent_id是没有索引的,所以这里为parent_id加上索引
2.3Nginx动静分离
项目的整个访问路径如下
使用windows的host文件模拟域名
,访问指定域名时解析对应的ip地址,是Linux虚拟机的IP地址
,默认端口是80,nginx监听80端口,然后将请求转发给网关服务
,网关收到请求,进行断言将请求转发给指定的服务(商品服务订单服务等)
动静分离后
静态资源比如JS CSS
等访问路径以static开头,然后将静态资源放到nginx服务器上,请求转到nginx,nginx匹配static直接将静态资源返回,动态请求继续转给网关。
2.4使用Redis+SpringCache做缓存
2.4.1简单介绍
-
缓存的一般工作模式
:查询时先查缓存,缓存有直接返回,没有去查数据库,查完数据向缓存中放一份。更新数据时将缓存删除,或者直接更新缓存
。 注意:缓存一定要设置过期时间,保证数据的一致性。 -
本地缓存
(HashMap,在单机应该上没有问题,分布式下可能会出现多次查缓存(负载均衡到不同的服务器),最大的问题就是缓存一致性问题,查询时数据库数据变了,可能导致每个服务器的缓存时不不一致的) -
什么样的数据应该加缓存
- 访问量大且更新频率不高的(读多写少)
- 即时性、数据一致性不高
SpringBoot整合redis
-
导入starter-data-redis
-
提供了redisTemplate和
StringRedisTemplate
缓存改造
Redis中存的: key是方法名,value是使用JSON序列化后的数据
好处:JSON数据可以跨平台其他的业务系统获取JSON后直接反序列化即可。
2.4.2解决缓存问题
缓存穿透
查询一个不存在的key
解决方法
:1.缓存null值+过期时间 2.布隆过滤器
缓存雪崩
大面积key失效
解决
:设置key的过期时间为随机值
缓存击穿
热点key在大量的并发下失效,都去查数据库,导致数据库崩
解决
:查询数据库加分布式锁, 第一个请求获取锁后查询数据库后放到缓存中,后面的请求直接查缓存。
缓存一致性解决
2.4.3分布式锁
本地锁细节:确认缓存有没有+查数据库+将结果放入缓存
一整个逻辑都在同步代码块里。
本地锁在分布式系统下的问题:锁不住所有,只能锁本地
手动分布式锁
第一阶段
setNx + EX设置过期时间(防止执行业务时宕机没有删除锁导致死锁),注意调用setnx时就设置过期时间(存在这个命令,不需要使用LUA脚本),保证是一个原子性操作
第二阶段
设置了超时时间,但是防止锁过期了,将别的线程的锁删除了,所以设置的锁key的value不使用同一个值,而是使用一个随机值(时间戳+UUID),在删锁时判断一下UUID保证这个锁是自己的锁,那么就不会删除别的线程的锁 (这里也必须保证获取value和删除锁是一个原子操作 使用LUA脚本对比锁+删除锁)
保证加锁原子性(SetNX EX + 大的随机字符串) + 解锁原子性(LUA脚本 对比 + 删除)
但是还有问题:
如果说设置的过期时间到了(即锁删除了),但是业务还未执行完毕,那么其他线程还是可以占锁,所以这里给锁的过期时间设置的长一点
Redission分布式锁
本质是一个redis的客户端,操作redis的,类似jedis lettuced,底层使用lua脚本保证原子性
Redisson的这些锁,直接实现了JUC下的lock接口,本质上就是lock锁的分布式实现
好处
- 1.不需要手动自旋方式获取锁,senNX 只会尝试一次,如果说想一直尝试的话需要自行的写while进行自旋,但是Redission还是JUC下的AQS实现,直接调用lock()获取不到锁就会自行的自旋尝试。
- 2.看门狗原理,业务超长时自动给锁续期。锁默认是30秒,业务完不成自动续期,完成之后就不会续期,即使不手动解锁,30秒后自动删除,
Redisson解决:
1.内部的操作使用的都是lua脚本,保证原子性
2.由于实现了lock接口,没获取到锁时自动的自旋
2.每把锁的value(UUID+线程号)
都是不一样的,保证不会删除别的线程的锁
3.看门狗原理,自动续期,简单源码:while(true)循环一直获取锁,获取到业务还未完成,在固定秒数时进行续期,锁的默认时间是30s
缓存一致性
1.双写模式(写数据库时同时更新缓存)
可以容忍数据的暂时不一致,等待缓存失效时读请求时就会更新缓存,适用于读多写少的场景,保证最终一致性
失效模式
最终一致性,有可能写的时候读来了,读请求快直接读到的时原来的数据,最终将脏数据更新到了缓存中。
延时双删
最简单的解决办法延时双删
使用伪代码如下:
public void write(String key,Object data){
Redis.delKey(key);
db.updateData(data);
Thread.sleep(1000);
Redis.delKey(key);
}
转化为中文描述就是 (1)先淘汰缓存 (2)再写数据库(这两步和原来一样) (3)休眠1秒,再次淘汰缓存,这么做,可以将1秒内所造成的缓存脏数据,再次删除。确保读请求结束,写请求可以删除读请求造成的缓存脏数据
。自行评估自己的项目的读数据业务逻辑的耗时,写数据的休眠时间则在读数据业务逻辑的耗时基础上,加几百ms即可。
如果使用的是 Mysql 的读写分离的架构的话,那么其实主从同步之间也会有时间差。
我们系统的解决方案
我们的菜单数据是写很少,读很多的数据,并且可以容忍暂时的不一致性,所以最终使用
失效模式设置过期时间
+读写锁(解决读写并发时将脏数据更新到缓存中)
如果是读多写多不要用读写锁。
总结
缓存不一致的根本原因:由于读写并发,导致数据库的最后一次更新没有映射到redis中。
canal
可以模拟是一个mysql的从服务器,读取binlog,然后更新redis
1.解决缓存不一致
2.大数据下解决数据异构,
SpringCache
读模式下 @Cacheable,将结果放入缓存
写模式@CacheEvit(失效模式) @CacheEvit(双写模式)
2.5其他优化
比如开启thylemeaf缓存等。
3.提交订单原子性验证
- 来到提交订单页面时生成一个TOKEN,redis里存一份(key是固定前缀,value是TOKEN),页面放一份,然后点击提交订单时使用lua脚本将
获取redis的token + 对比页面提交的token + 删除token
变成一个原子性操作,注意:一定要先对比删除后 在执行业务 - 注意lua脚本最终返回值是0或者1,
业务层面判断是否对比成功就是根据lua脚本的返回值来判断的,返回1表示成功然后继续执行业务,返回0表示失败直接返回失败即可
。只有一个线程可以成功,删除key后其他线程全部失败,直接全部返回失败。 - lua脚本的使用流程,写一段字符串的脚本scripts内容,然后使用
redisTemplate.execute(scripts,参数等)
执行lua脚本获取返回值进行判断即可
4.下订单业务
本地事务提交订单流程:
整个方法是一个事务 @Transacational注解
- 1.lua脚本对比提交的令牌,获取所有订单的数据,保存订单到数据库
(方法A)
。 - 2.方法A成功后,调用远程库存服务锁定库存
(方法B)
,远程会员服务扣积分(方法C)
等 - 3.使用异常机制进行事务,根据判断远程服务返回的状态码,失败直接抛出异常,进行回滚
本地事务在分布式下的问题
- 假设远程服务B出现了假失败,即扣减库存成功,但是由于远程调用可能网络出现了波动,导致远程调用超时抛出异常,然后保存订单回滚了,但是远程扣库存业务回滚不了
(已经提交了事务)
。 - 远程事务执行成功也没有出现超时,但是远程调用下面有逻辑出现了异常,那么方法A可以回滚,但是已经执行成功的远程事务无法回滚(已经提交了事务)
控制事务的核心就是这些操作必须在一个连接Connection中,由于是远程调用,都不是一个数据库,也谈不上一个Connection里,并且最大的问题就是,远程的事务可能已经提交了,提交成功的事务是无法回滚的。
事务的传播行为
注意:只有是不同service事务,事务的传播属性才生效,因为事务本质是AOP,如果事务在一个service中,相当于是直接代码赋值粘贴,事务的传播没用
AOP的使用
4.2分布式事务详解
CAP + RAFT
分布式系统经常出现的异常
机器宕机,网络异常、消息乱序、数据错误,不可靠的TCP。
CAP定理
一致性C
:必须保证每个节点的值时刻都是相同的,如果集群中某个节点炸了,那么整个集群就不向外提供服务,可用性A
:在集群中某些节点炸了,那么整个集群对外还是提供服务的,分区容错P
:区间的通信可能失败
分布式系统实现一致性的raft算法
https://www.bilibili.com/video/BV1np4y1C7Yf?p=287
每一个节点都有三种状态:随从follower
、候选者candidate
、领导leader
领导选举过程
- 初始时所有节点启动都以follower随从状态启动,每个节点都有一段随机的自旋时间,
- 哪个节点的自旋时间最快,那么这个节点
(假设A)
变为candidate候选者状态
- 然后A节点给其他节点发送投票消息,如果收到大多数节点的票,那么A就变成
leader领导
选举成功后,leader会隔一段时间(很短)向随从节点发送心跳检测,在每个节点收到心跳检测前还会进行自旋,假设在自旋时间内收到了leader的心跳检测,那么自旋时间重置,如果自旋时间过了还未收到心跳检测(判断此时leader挂了),那么节点就会变为candidate候选者状态然后进行新一轮的投票选举复过程
假设选举过程中某些节点的自旋时间相同,选举时票数相同,那么重新进行新一轮的选举
数据修改过程
经过选举领导后,所有的修改都必须先交给领导leader节点
假设客户端让集群保存一个数据set 5
- leader写一个日志
set 5(未提交状态)
- leader发送
set 5
让其他节点保存 - 其他节点保存后,向leader发送消息告诉leader set 5的日志已经保存
(其他节点也是未提交状态)
- leader收到后,
必须要经过大多数随从节点的同意,leader才能进行set 5的提交
,然后发送消息告诉其他节点进行提交
注意:要保证一致性,必须要选出一个领导出来,选不出领导那么对外的请求直接返回错误,即服务不可用
面临的问题
即在分区容错P的情况下,不保证强一致性C,必须要保证服务对外是可用的A,所以要保证AP
即舍弃CP,保证AP
BASE理论
即保证AP的情况下,保证不了强一致性(单机数据库,要么成功要么失败,分布式下保证强一致很难,)
,但是业务肯定要一致性, 我们可以实现弱一致性,即最终一致性
基本可用:比如一两台及其挂了,不会导致整个集群不可用,
最终一致性:比如分布式下的保存订单 ——> 远程扣库存 ——>远程扣积分,远程扣积分炸了,保存订单回滚了,但是库存不会滚,使用一些技术比如MQ,过一段时间之后将这些扣掉的库存加回来,保证最终一致性
强一致、弱一致、最终一致
分布式事务的几种解决方案
2PC
柔性事务TCC事务补偿型方案
写三段代码,准备+提交,以及回滚代码,执行失败时调用回滚代码。
柔性事务,最大努力通知方案
比如支付宝,支付成功后,支付宝会一直给我们发消息告诉我们支付成功。
可靠消息+最终一致性
使用MQ的可靠消息机制,比如业务失败后,向库存服务发送MQ,让库存服务去解锁。
- 跟上面的
最大努力通知方案
区别在于保证消息是可靠的==(可以使用MQ的消息确认机制等保证消息传输)==,适用于系统都是我们开发的,我们可以保证消息的可靠传输, - 而上面的最大努力通知一般用于
第三方系统(支付宝,微信)
给我们系统发送MQ消息,我们不能保证消息一定能收到,所以第三方就通知多次。
Seata
seata不使用于分布式的高并发场景,在此项目中适用于:商品的后台页面,保存一些商品的信息,远程调用会员服务保存积分信息,此场景并发很低
而下订单提交订单并发很高,不使用于Seata。seata的实现原理会加锁,降低并发
4.3使用MQ保证最终一致性
可以使用定时任务来做,缺点1.时效性 2.会影响效率
4.3.1延时队列
延时队列实现
一个没有任何消费者监听并且设置了过期时间的队列 + Exchange路由(一个路由可以对应多个交换机)这里称为死信路由 + 一个被消费者监听的队列
实现延时队列
核心就是红色的队列的三个参数
- x-dead-letter-exchange : 表示消息过期后交给哪个路由
- x-dead-letter-routing-key:表示交给路由后使用的路由键(设置为最终监听消息的队列的路由键)
- x-message-ttl:队列的消息过期时间
并设置队列消息过期了不要扔,交给指定的路由
项目创建延时队列
每一个服务都创建一个路由,
解锁库存
库存解锁的场景
skuid 商品数量 订单的id 锁定状态(0 1已锁定 2)
订单+库存服务整体流程
订单设置一个标志位:0新建 1已支付
定时关单
定时关单(前提:所有服务包括远程的扣库存扣积分都成功),30分钟内还未支付,那么就执行定时关单:
订单创建成功时,向延时队列中发送消息(订单的所有内容,此时订单状态为新建),然后30分钟后,消息从延时队列路由到消费者,判断订单的状态还是新建,那么说明订单要被取消,那么直接将订单取消,并且给扣库存扣积分等服务发送消息,发送到各自的解锁业务,
自动解锁库存
假设远程调用锁库存假失败:
在锁库存的时候向延时队列发送消息,然后等待固定时间后获取延时队列的消息,根据库存单查询关联的订单状态,如果订单状态是未完成状态,那么将消息发送给交换机,交换机将消息传给解锁库存服务的队列,消费者获取消息后就进行解锁库存服务。
如何防止消息丢失
1.给数据库创建一张表,包含给那个消息的id,交换机发送,路由键是啥,数据(JSON序列化后),以及消息状态,定期扫描数据库,将发送失败的数据拿出来在发送一遍。
设置两个回调方法
1.消息到达broke,会回调**(ConfirmCallback)**,我们可以知道消息成功抵达了broke
2.消息没有从broke中的交换路由正确的送到指定的队列,也会回调**(setreturnCallback)**,然后我们可以修改数据库的MQ状态为失败
开启手动ACK
默认是手动ack,消费者收到消息后MQ默认删除消息。但是如果消费者收到了消息,但是没有来得及消费,消息没了,就出问题了。
手动ack,当消息真正的接收并且消费完毕后,给MQ发送ack,然后MQ才将消息真正的删除,否则收不到ack不会删除,消息还在(ready状态)。
并且还有一个拒绝,如果收到消息了,但是处理业务失败了,此时不ack,直接拒绝,然后消息又回到队列中。
总结:
做好消息确认机制(客户端+消费者手动ack)
将每一个消息记录起来,然后定期将失败的消息重新发送
最终的流程:
- 发送消息时插入数据库
- 消息发送给队列失败时,来到回调函数,此时我们修改数据库中这一条记录的状态为失败,后面定期扫描数据库,将失败的消息重新发送。
- 手动ACK模式,消息没有
收到+处理业务完毕
,那么不ack给MQ服务器。
消息重复
消息积压
5.秒杀模块
- 秒杀是一个单独的服务。我们将商品都放到
缓存(redis)
便于对比信息, - 先进行判断是否登录,然后校验合法性(时间是否到,是否已经秒杀过了等)
- 然后核心使用分布式信号量,
tryAcquire()
尝试获取信号量,获取失败直接结束(tryAcquire()不会阻塞),获取成功,这里直接快速的创建一个秒杀订单,包括用户id,订单号,商品等信息,然后讲这些信息发送给MQ慢慢处理。 - 核心:直接让用户确认信息,包括收货地址、支付等,最后直接给用户显示成功,整个过程没有一个远程调用,没有操作一次数据库,只是发了MQ,
可以保证在高并发下,用户秒杀成功后可以稳定的填写信息并且稳定的完成支付等
。并且让订单服务慢慢处理**(下订单,锁库存等等)**。
Netty项目
解决粘包半包 LengthFieldBasedFrameDecoder
用户群组数据结构
用户原始信息存储到Map中,账户和密码
private Map<String, String> allUserMap = new ConcurrentHashMap<>();
每一个客户端连接上服务器时自动触发事件,向服务器发送消息(主要是用户名),然后服务器将用户名<->Channel
存到Map中。
private final Map<String, Channel> usernameChannelMap
private final Map<Channel, String> channelUsernameMap //反向查询
群组,群名<->群组对象(Group)
private final Map<String, Group> groupMap = new ConcurrentHashMap<>();
Group数据结构(name + Set集合)
public class Group {
// 聊天室名称
private String name;
// 聊天室成员
private Set<String> members;
public static final Group EMPTY_GROUP = new Group("empty", Collections.emptySet());
public Group(String name, Set<String> members) {
this.name = name;
this.members = members;
}
}
自定义编解码
将客户端发送的消息进行编码(编码成一个ByteBuf),服务器解码(服务器同理)
- 4个字节的魔数
- 1字节版本
- 1字节序列化方式
- 1字节指令类型 (自定义Message类型,单发,群聊,建群。。。)
- 4字节xxx
- 1字节对其填充
- 4字节
数据长度
数据
@Slf4j
@ChannelHandler.Sharable
public class MessageCodec extends ByteToMessageCodec<Message> {
@Override
public void encode(ChannelHandlerContext ctx, Message msg, ByteBuf out) throws Exception {
// 1. 4 字节的魔数
out.writeBytes(new byte[]{1, 2, 3, 4});
// 2. 1 字节的版本,
out.writeByte(1);
// 3. 1 字节的序列化方式 jdk 0 , json 1
out.writeByte(0);
// 4. 1 字节的指令类型
out.writeByte(msg.getMessageType());
// 5. 4 个字节
out.writeInt(msg.getSequenceId());
// 无意义,对齐填充
out.writeByte(0xff);
// 6. 获取内容的字节数组 (对象流:将对象转换为字节流)
ByteArrayOutputStream bos = new ByteArrayOutputStream();
ObjectOutputStream oos = new ObjectOutputStream(bos);
oos.writeObject(msg);
byte[] bytes = bos.toByteArray();
// 7. 4字节 长度
out.writeInt(bytes.length);
// 8. 写入内容
out.writeBytes(bytes);
}
空闲检测+心跳检测
IdleStateHandler
连接假死
原因
- 网络设备出现故障,例如网卡,机房等,底层的 TCP 连接已经断开了,但应用程序没有感知到,仍然占用着资源。
- 公网网络不稳定,出现丢包。如果连续出现丢包,这时现象就是客户端数据发不出去,服务端也一直收不到数据,就这么一直耗着
- 应用程序线程阻塞,无法进行数据读写
问题
- 假死的连接占用的资源不能自动释放
- 向假死的连接发送数据,得到的反馈是发送超时
服务器端解决
- 怎么判断客户端连接是否假死呢?如果能收到客户端数据,说明没有假死。
因此策略就可以定为,每隔一段时间就检查这段时间内是否接收到客户端数据,没有就可以判定为连接假死
// 用来判断是不是 读空闲时间过长,或 写空闲时间过长
// 5s 内如果没有收到 channel 的数据,会触发一个 IdleState#READER_IDLE 事件
ch.pipeline().addLast(new IdleStateHandler(5, 0, 0));
// ChannelDuplexHandler 可以同时作为入站和出站处理器
ch.pipeline().addLast(new ChannelDuplexHandler() {
// 用来触发特殊事件
@Override
public void userEventTriggered(ChannelHandlerContext ctx, Object evt) throws Exception{
IdleStateEvent event = (IdleStateEvent) evt;
// 触发了读空闲事件
if (event.state() == IdleState.READER_IDLE) {
log.debug("已经 5s 没有读到数据了");
ctx.channel().close(); //关闭连接
}
}
});
客户端定时心跳
客户端可以定时向服务器端发送数据,只要这个时间间隔小于服务器定义的空闲检测的时间间隔,那么就能防止前面提到的误判,客户端可以定义如下心跳处理器
// 用来判断是不是 读空闲时间过长,或 写空闲时间过长
// 3s 内如果没有向服务器写数据,会触发一个 IdleState#WRITER_IDLE 事件
ch.pipeline().addLast(new IdleStateHandler(0, 3, 0));
// ChannelDuplexHandler 可以同时作为入站和出站处理器
ch.pipeline().addLast(new ChannelDuplexHandler() {
// 用来触发特殊事件
@Override
public void userEventTriggered(ChannelHandlerContext ctx, Object evt) throws Exception{
IdleStateEvent event = (IdleStateEvent) evt;
// 触发了写空闲事件
if (event.state() == IdleState.WRITER_IDLE) {
//log.debug("3s 没有写数据了,发送一个心跳包");
ctx.writeAndFlush(new PingMessage());
}
}
});
主要流程
所有指令都是固定格式
System.out.println(" --- 菜单 --- ");
System.out.println("send [username] [content]");
System.out.println("gsend [group name] [content]");
System.out.println("gcreate [group name] [m1, m2, m3 ...]");
System.out.println("gmembers [group name]");
System.out.println("gjoin [group name]");
System.out.println("gquit [group name]");
System.out.println("quit");
不同种类的消息由不同种类的Handler处理,继承SimpleChannelInboundHandler,泛型是指定的消息类型,
public class ChatRequestMessageHandler extends SimpleChannelInboundHandler<ChatRequestMessage> {
核心流程
- 连接建立后,客户端触发连接事件,向服务器发送自己的用户名,然后服务器保存username-channel到map中
- 单聊,对方的用户名+消息,服务器根据Map,获取对方的Channel,然后发送消息
- 建群,发送群名,以及成员名(Set),服务器收到直接存到Map中
- 发送群消息,服务器获取群名,拿到群中的用户名然后根据用户名获取Channel发送消息。