/Item-Seckill

Support the practice of commodity kill projects under high concurrency.

Primary LanguageJavaGNU General Public License v3.0GPL-3.0

基于SprintBoot的大型商城秒杀项目

框架:SpringBoot(轻量级框架,创建项目快)

数据库:mysql(关系型数据集,体积小、速度快)

中间件:redis、rocketmq

其他组件:Nginx,mybatis(半自动框架,实现java对象与数据库的自动映射,灵活,更好上手)

削峰限流组件:令牌桶

一、spring优点:

  • 1.Ioc(控制反转):管理bean之间的关系;管理bean的生命周期与作用域,若没有Ioc,那么需要自己创建对象与对象间的依赖关系,这与程序员的水平有关,若水平不高那么后期维护很困难,做不到可插拔的效果;而Ioc可以使项目做到可插拔;那怎么实现的可插拔呢;使用注解将使用的实现类注入到容器中,如后期不想使用这个实现类,那就可以将其注解删除,在需要的实现类上加入注解,可实现可插拔;将bean的管理权力交给spring;可以降低程序bean之间的耦合度;使bean做到可插拔;

​ 程序中例如User实体实例不适合用容器管理,因为会有无数个实例,而service与repository只实例化了一次,可以复用;所以可以使 用容器; 可以复用的就可以使用容器管理,不可复用的不可使用容器;

​ Ioc是依赖于Applicationcontext与Beanfactory实现的,其中Beanfactory是顶层的接口,Applicationcontext是底层子接口,继承Beanfactory,功能更强;Beanfactory通常是spring framework内部人员调用的API,Applicationcontext是开发者使用的;Applicationcontext核心功能:获取bean;

  • 2.AOP: @component代表公共注解;@Aspect代表这是AOP的组件,可以影响很多bean; 面向切面编程,面向aspect,可以解决多个bean的公共问题,

​ Target代表哪些bean是需要处理的;而Joinpoint是哪些方法需要处理的;Pointcut代表需要处理的哪些bean的哪些方法需要处理;* service..(..)代表service包下任意类的任意方法的任意参数任意返回值需要处理;@Advice代表事情发生的时机是什么;@Before是之前调,@afterthrowing是抛异常时候调用,@afterreturing是代码之后调用,@after是finally之后调用;

二、Mybatis:

  • 使用Mybatis时候,注解是@Mapper,与@repository意义一样,但在Mybatis中使用Mapper是规定;

    Mybatis是半自动化的框架,需要手动写SQL语句,然后手动将数据库数据与POJO进行映射;Hibernate是全自动框架,既实现了数据库数据与POJO的映射,又可以自动生成SQL语句;Mybatis的优点是灵活,更好上手;

    Mybatis中有逆向工程,可以完成数据库与POJO的自动配置,但缺点是只能实现单表的映射,出现级联的情况时就不能使用了;但是可以先将单表的映射自动生成,再手动写级联关系;并且mybatis的dao层不需要写接口的实现类,可以运行时动态创建的,是动态代理机制,并且注解写的是@Mapper;

  • Mybatis的核心组件:mysql是通过SqlSession连接的,而SqlSession是通过SqlSessionFactory来创建的;

    这个项目在所有表的Mapper.xml文件与dao层的接口以及实体类都是通过Mybatis框架的逆向工程自动生成的;这个项目没有单独写出来怎么生成的,但是如果要生成的话很简单,我们操作过一次,在mybatisgenerate这个项目中实现过;生成之后不要忘记在repository层的mapper接口中加入@Mapper注解;

Mybatis流程

image-20220702221714585

1.程序一启动,MyBatisAutoConfiguration自动生成了SqlSessionFactory,SqlSessionFactory需要生成SqlSession;

2.SqlSessionFactory通过调用Configuration读取配置文件application.properties,获取对应的配置;并且可以生成SqlSession;

3.Configuration会读取Mapper.xml会有配置文件,至此SqlSessionFactory加载了与配置有关的内容;

4.MyBatisAutoConfiguration调用MapperScannerConfigurer;

5.让MapperScannerConfigurer扫描哪些文件带有@Mapper注解,生成对应的MpperProxy代理类,所以后面@Autowird注入的是MapperProxy。

橙色与浅灰色框是SpringBoot与Mybatis整合的时候有的组件;真正Mybatis自己的组件时中间的四个组件;

三、用户注册与登录

状态管理:

​ 比如下单功能或者其他功能的时候需要验证是否登录状态,只有登录状态才可以进行相关操作,这些验证状态的功能可以使用同一个函数,是基于请求路径来验证的,面向Url的过滤,但这不是AOP功能,AOP是面向方法名的过滤,这里可以用拦截器interceptor,拦截器可以在handler前,handler后,与返回view视图的时候进行操作,这里我们只需要在handler前进行拦截就可,因为需要验证登录状态;在seckill.html与item.html页面均有右上角的用户名显示与注销按钮;

跨域问题

首先我们在前端访问的是127.0.0.1:5500,但我的server是127.0.0.1:8080这就是跨域访问了,端口不一样也是不同的域;异步请求xhr

(1)使用Ngnix代理

image-20220702222154233

浏览器根据路径不直接访问Liver server,因为这时候就是跨域了,为解决这问题,通过代理Ngnix,不管是前端还是后端都先进去Ngnix,Ngnix判断他的路径中有static,说明是静态资源,走的是静态的服务器Liver server,然后返回页面,然后通过ajax请求获得后台的访问路径$.ajax{url..},进Ngnix之后判断他不是静态资源,所以送入其他服务器,这种方式规避了跨域问题,但他的缺陷是前端与后端人员应尽可能分开。

(2)HTTP协议跨域资源共享

其实a.com跨域访问b.com时候,只要b.com同意就可以访问了,所以前端的开发协议已经规定了

在本项目中,使用@CrossOrgin来判断服务端是否可以跨域,并规定可以从哪个域中跨过来。allowHeaders表示可以使用这个handler的东西,读取内容,allowCredentials表示可以使用cookie;

@CrossOrigin(origins="${nowcoder.web.path}",allowedHeaders="*",allowCredentials="true")

前端中:表示可以带cookie,允许跨域否是后端允许的,前端这个表示前端可以带cookie

cookie&session

cookie最早提出来是只读的网页,这很不方便,需要服务端能记住客户端的逻辑;通俗讲cookie就是我去理发店办了会员卡,然后我将会员卡带走,店里没有我的信息。下次理发带着卡然后将卡次数-1;但这种方法的弊端是无法确保敏感信息不被篡改与复制;而session是理发店将我的会员信息存入电脑中,理发店只给我了顺序号,之后我报号码即可找到对应的信息,由理发店保管我的信息。更安全。

四、分布式系统状态管理

分布式系统管理:浏览器请求通过Nginx进行分发给多个server,可以平均分发,也可按照权重分发,为了解决第一次在server1进行的操作,下一次请求到server2时不认识客户端了,因为server自身单独创建独立的sessionid,是不一样的,所以将多个server的session统一存到Redis中,但这种方法对多客户端的常见不适用,不能出现电脑端可以访问,手机端不能访问的情况。app没有cookie,而session是基于cookie生成的。

所以新的方法可以适用于多客户端的情况:当客户端访问服务端时,不需要创建session,可以自己设置一个ID-token,这是给用户的凭证,然后将其返回给客户端,客户端自己想办法记住这个凭证,下一次访问服务端时将token带过来,然后server从redis中找token对应的user,指向相关操作。

五、单点登陆的实现机制

一个系统太大了,拆分成无数个子系统,每个子系统直接无需重复登陆。

  • 根域名相同的情况

    首先浏览器访问a.jd.com时基于cookie创建session,然后存到Redis中,并且cookie.setDomain("/")表示cookie在根路径相同的情况下可以携带,所以在访问b.jd.com时可以降cookie带过去,从Redis中找到key对应的session,然后执行相关的操作。

  • 根域名不同的情况

    两个域名www.jd.com与www.nc.com完全不同,这时候需要中间的服务器www.sso.com进行验证。不是自己通过cookie验证。

    • 首先浏览器访问www.jd.com进行下单操作,www.jd.com发现其未登录,则会将其重定向到登陆页面进行登陆,返回重定向的来源是www.jd.com;

    • 浏览器访问www.sso.com,此时会带着访问的来源jd.com,sso.com给浏览器返回一个登陆页面;

    • 浏览器访问sso.com进行登陆操作,然后重定向回去他原来的地方jd.com,并返回全局的token;

    • 浏览器再次访问jd.com并带回token,jd.com需要与sso.com验证这个token是否正确;验证成功执行相关操作,并给浏览器返回jd.com局部token,以后再访问jd.com时就不需要验证了;

    • 浏览器访问与jd.com有关的nc.com,进行支付操作,nc.com发现其未登录返回浏览器重定向的来源,并将其重定向到sso.com进行登陆;

    • 浏览器访问sso.com并带着之前在浏览器存的cookie,此时sso.com发现这个cookie对应的user已经登陆过了,那么就将其重定向到原来的地方nc.com,并返回新的全局token;

    • 浏览器再次访问nc.com并带着token信息,nc.com与sso.com验证传来的token是否正确,验证成功后执行相关操作,并返回浏览器一个局部token ,以后访问nc.com时不需要进行验证了。

    这里jd.com与sso.com与nc.com不适合将token放在redis中,因为这三个可能是不同公司的,就算是同一个公司的,若有一个操作错误会直接影响到其他服务器的。所以不使用Redis,这样更安全也解耦合。

六、数据库索引

索引的作用:

使用索引可快速访问数据库表中的特定信息

判断是否需要索引,可以用这个sql语句查看:explain select * from item;

explain语句显示的结果这些字段的含义分别是什么?

image-20220702223851493

可以看到如果不用索引查,那么需要查询30行数据;这是预估的,并不准确

select * from student where id=1;其中id=1就是索引,比其查询出所有结果,索引查询很快;

索引的分类:普通索引、唯一索引、空间索引、全文索引;

唯一索引与主键索引的区别在于主键的key是非空的,而唯一索引的主键可以是空的。

慢查询分析

怎么知道这条语句的查询时间很慢呢,单靠explain 语句是不现实的,不可能主观判断查询快慢,那么就可以根据mysql支持的分析每个语句的时间然后将超时的语句记录到日志中,之后再根据日志记载的具体语句进行优化,优化的方式是通过explain判断他是没加索引的问题还是索引没正确匹配最左前缀导致的时间长。

通过show variables like 'slow_query%';语句查出慢查询开关是否开启,并且查看日志存放位置;

我门项目中的查询sql语句的查询时间为10毫秒。

  • 常见查询慢的原因常见的话会有如下几种:

  • 1、没有索引或没有用到索引

    PS:索引用来快速地寻找那些具有特定值的记录,所有MySQL索引都以B-树的形式保存。如果没有索引,执行查询时MySQL必须从第一个记录开始扫描整个表的所有记录,直至找到符合要求的记录。表里面的记录数量越多,这个操作的代价就越高。如果作为搜索条件的列上已经创建了索引,MySQL无需扫描任何记录 即可迅速得到目标记录所在的位置。如果表有1000个记录,通过索引查找记录至少要比顺序扫描记录快100倍。 索引类型: 普通索引:这是最基本的索引类型,没唯一性之类的限制。 唯一性索引:和普通索引基本相同,但所有的索引列只能出现一次,保持唯一性。 主键:主键是一种唯一索引,但必须指定为"PRIMARY KEY"。 全文索引:MYSQL从3.23.23开始支持全文索引和全文检索。在MYSQL中,全文索引的索引类型为FULLTEXT。全文索引可以在VARCHAR或者TEXT类型的列上创建。 2、IO吞吐量小形成了瓶颈 PS:这是从系统层来分析MYSQL是比较耗IO的。一般数据库监控也是比较关注IO。 监控命令:$iostat -d -k 1 10 参数 -d 表示,显示设备(磁盘)使用状态;-k某些使用block为单位的列强制使用Kilobytes为单位;1 10表示,数据显示每隔1秒刷新一次,共显示10次。

查看sql的执行时间:

  • 1.输入show profiles:此时没有数据

  • 2.输入show variables:查看profiling是否开启,即value为on;使用模糊查询,show variables like 'profiling';显示off,那么输入

    set profilling=1开启profilling,这样我们的Mysql就可以查看Sql语句的执行时间

  • 3.输入我们的sql语句执行完之后,再次输入show profiles即可看到刚才sql语句对应的执行时间

    image-20220702225222127

七、扣减库存的时机

​ 消减库存可以在下单时就进行,也可以在支付后进行消减,而支付后才消减的话那些下了单的人明明已经抢到了但是由于犹豫了几分钟再付款发现已经没有库存了,这样很没有秒杀体验感,所有我们将消减库存的操作在下单时就进行了,并且我们的项目中没有支付操作。

下单操作注意这几个表:

order_info:存储用户订单记录的表: 表中存储的数据主要是:订单编号,用户id,商品id,活动id,下单的价格,下单数量,下单总金额,下单时间,其中订单编号id使用8位年月日加12位流水号的方式存储,这样的好处将半年前的数据卸载移到另外一张表中,保证当前表是最近半年的数据,保证这个表就有几千万个数据还可以接受,那么这个订单编号的存储就方便业务拆分了。进行大表拆分。

order_serial表示当前订单编号存储到20210306000000000020最后两位的索引值为20,value表示当前索引加step之后下一个订单的索引值为21,step的值是可自定义的。

八、数据库事务与数据库的锁

​ 这项目中只针对mysql的InnoDB引擎而言的,mysql有的引擎不支持事务,有的引擎仅支持简单的事务,InnoDB是完整的高性能的引擎。

​ 由于X锁要求很严格,与任何锁都不兼容,IX锁与X/S锁不兼容,但与IS锁兼容。这使得并发性变得很差,那么如何使得加了锁而并发性也很好呢,如何更好的使用锁呢?

​ 共享锁S是行锁,要读取某一行,排它锁X是行锁,是修改某一行,意向共享锁IS指的是打算读取某个表几行数据,意向排它锁是打算修改某个表的几行。

​ 查询的时候也可以加X锁,不是只有修改操作才可以加X锁,减少并发。

​ 意向共享锁IS与意向排它锁IX是可以共存的,因为只是意向不是真正要去处理某几行的数据。 机制-无锁:mvcc:对于正在更新的数据,InnoDB会去读取该行的一个快照数据(undo log),这个undo log实在事务提交之后自动生成的日志,里面存的数据是历史数据,比如语句1,2,3。而在进行修改某行的时候加了X锁锁的其实是mysql的正式数据,那么别人在查询这个数据的时候也不妨碍我修改,提高了并发性。所以修改的是正式数据,查询的是历史数据,但不可以同时进行修改,是互斥的。

​ 事务读取方式中:脏读是最不安全的最需要杜绝的,不可重复读有时候需要杜绝有时候可以不杜绝,幻读可以不杜绝但是如果杜绝就完美。DML(数据操作语言)

​ 死锁解决方法:死锁检测:wait for graph,采用等待图的方式进行死锁检测,是一种更主动的死锁检测方式,假设t1,t2,t3,t4是四个事务,根据事务锁的对象画出一副事务图,看到t1与t2之间存在死锁,所有解决他俩的死锁问题。

InnoDB存储引擎不存在锁升级的问题:因为不管是锁一行记录还是一个表,锁都是加在了页上,所有没有升级的说法。

​ 隔离级别越高会使得并发性越低;但mysql的InnoDB能将两者兼并。

​ 为了在修改当前行的数据时,防止其他事务进行修改所以在事务中加入锁,而加锁会使得并发性降低,所以采用mvcc提高并发性。mvcc是从历史版本中读取数据的。

​ serializable序列锁:可以解决任何问题,是级别最高的锁,他在读的时候就加了序列锁,所有可以将数据进行锁定,被序列锁锁定的行,其他事务不可读也不可修改该行的数据。

​ read committed锁:可以解决脏读问题,通过Record lock(锁的一页)解决的脏读问题,通过mvcc提高了并发性。

​ repeteable read锁: 可以解决三个读取问题,是默认的隔离级别,然后采用next-key-lock算法解决了三个问题,解决脏读的原因是锁住了单行,解决不可重复读的原因是锁住了单行以及当前行的范围,解决幻读也是这个原因。采用mvcc提高了并发性。mvcc读的是历史数据。

​ 持久性:完整成功,一致性:完整失败,若能保证持久性与一致性就能保证原子性。

​ Spring boot添加解决事务的时候只需要在方法前加@Transactional注解,并且@Transactional后面可以不加参数,不加的时候代表默认的隔离级别是repeteable read锁若加参数:后面参数requires_new指的是事务嵌套中,事务的传播机制是?B方法在A方法中,但是B事务也开启了一个事务就是事务的嵌套,而requires_new是不管A方法加事务否,B方法都可以自己建立一个事务,B就是单独的事务,最开始begin,中间异常就回滚,最后commit。requires指的是如果A加了事务,那么B可以不加事务。订单编号是没有回滚的,若已经产生订单编号但是没有付款,那这个编号中的序列号也不会回滚,这个号废就废了。

九、项目部署与压测

尽可能占用所有服务器内存的同时提高并发性。将代码部署到单个服务器上

我使用的是阿里云ECS,在仅有一个服务器的时候怎么充分部署好;需要部署web页面,需要web服务(Nginx),Nginx也可以做反向代理,后端代码使用tomcat,或者将代码打包成jar包,这个jar包自带tomcat,可不用单独tomcat。压测重点压测的是商品详情页与下单功能。这两个是消耗最大的。AJAX异步请求,通过端口区分是要访问静态页面还是动态页面。

image-20220702230523426

十、分布式部署

image-20220702230901728

  • 分布式部署:若真正上线的时候,单个服务器肯定不满足很多个线程同时访问,单个服务器的性能是有限的,所以大多情况使用的是分布式部署,多个服务器同时存储了同一个代码,然后将请求进行分发给服务器,分发的方式可以是平均分发也可以按照权重分发,服务器使用的是集群方式。

  • Nginx:同时请求是通过Nginx来送往服务器的,而使用单个Nginx是有缺陷的,因为如果Nginx坏掉了,那么请求无法正常传给服务器,那么客户访问不了服务端,所以通常使用多个Nginx,这里画了两个Nginx,其中一个正常处理请求,另一个Nginx监视第一个Nginx,当第一个出现问题时,第二个Nginx就开始顶替处理请求。

  • Mysql读写分离: 仅有一个mysql时压力会很大,可以将其做分布式部署,但是尽可能不做mysql的分布式部署,因为如果将一个表的数据分到多个表中,事务就不好处理,所以是将同一个业务相关的数据放到一个mysql中,其他事务相关的数据放到另一个mysql中,是按照业务拆分的;可以做mysql的读写分离,使用两个mysql节点,有主从mysql之分,将数据存储到主mysql中,读取的时候从从mysql中读取数据。

  • guava cache与Redis一二级缓存机制: 由于tomcat的业务量很大,如果都同时往mysql中写数据,那么mysql压力特别大,所以会在访问mysql之前加一个缓存机制提高并发性能,guava cache是一级缓存,tomcat先访问guava cache有没有数据,但由于guava cache与tomcat等在同一个服务器中,所以给到guava cache的内存很小,一般才小几G,比如用户的数据就不放在这里,因为用户的数据基本是几万的内存,所以使用了二级缓存Redis,二级缓存也使用的是集群,所以先看redis有没有数据,再看mysql有没有。

  • **mybatis自带的缓存不可以替换guava cache **:主观来说可以,因为mybatis也是挂在tomcat上的,但是没有guava cache更灵活,mybatis直接连数据库,所以直接将数据库的数据进行存储,但若需要存储的不是表中的数据,而是将查到的数据进行加工,最后将加工的结果缓存,那么mybatis自带的缓存就不适用了。而redis想存什么都可以而且内存也很大。

  • RocketMQ: 若系统中有多个子系统,或者多个功能之间解耦,需要使用RocketMQ做异步,将功能解耦性能提升。

Mysql的读写分离:两个物理机,基于主从mysql模式之下,而主从模式是基于两个log文件实现的;

主服务器执行DML操作之后,将数据存储到bin log文件中,从服务器将主服务器修改的数据从bin log 传送过来并写入到从服务器的relay log文件中,当有事务读取mysql中的数据时是从relay log文件中读取的,并且从服务器有两个快照功能,目的是当主服务器进行了错误操作之后,从服务器根据快照恢复之前的操作与数据。

从服务器有两个线程处理问题,一个是将主服务器的数据传送过来,另一个是进行SQL语句的查询。

分布式事务:外部的分布式事务,比如跨银行转账,因为彼此使用的数据库都不一定相同,所以要使用分布式事务,只要数据库支持分布式事务,并不一定只有在一个数据库的情况下才可以做分布式事务。

核心:两阶段提交。

资源管理器:支持分布式事务的数据库,他们之间不能相互连接,需要一个协调者:事务管理器。

需求方:应用程序

1.需求方给事务管理器发一个请求;

2.然后事务管理器给每一个资源管理器发一个start命令;然后事务管理器给应用程序发一个确定发送的信号;

3.应用程序给资源管理器发送DML操作;然后给事务管理器发送结束信号;

4.事务管理器给资源管理器发送结束命令;然后记录start与end范围之内到底做了什么事情;

然后开始提交事务,这几个资源管理器符合原子性,要么全成功要么全失败;两阶段提交;

5.事务管理器先给所有资源管理器发送prepared命令,看他们准备好没,可以提交否;

6.若所以资源管理器都准备好了,就commit,有一个不能提交就rollback。

这个机制的缺点:无法保证能确保每一个资源管理器都能传达到位,如果事务管理器在某个阶段挂了也不能传达到位,所以可以在应用程序上加一个监视事务管理器的或者资源管理器的重试机制,监视资源管理器事务提交否,事务结束否。怎么实现重试机制呢:心跳检测,不断的ping这个服务器,若ping连接不上说明出问题。

Mysql内部的分布式事务:主要发生在读写分离的过程中

Master表示主mysql服务器,Slave表示从mysql服务器。

mysql读写分离中,先在主服务器中将数据写入到bin log中,然后同步到从服务器的relay log中,并且InnoDB也会写入redo log,redo log在commit之后才会停止写入,并且redo log与bin log是受事务保护的,不能没有其中任何一个,那当第三步突然断电了,redo log 就写不进去了,所以需要解决bin log与redo log的同步问题,需要mysql的内部分布式事务机制,因为bin log是mysql模块上的任务,redo log是InnoDB上的任务,同步她两必须使用分布式事务。

解决方式:两阶段提交:

1.在存数据之前准备;

2.写bin log;

3.写relay log;

4.写redo log,若此时InnoDB断电或其他问题导致写不上,由于之前进行了InnoDB prepare阶段,若未成功就从bin log中恢复。可以找回。解决了bin log与redo log的同步问题。

mysql的读写分离不是分布式事务的关系,分布式事务是两个服务器都做了DML操作,而读写分离是通过多线程来操作的。三阶段提交比两阶段提交多了检查阶段。

十一、分布式状态管理

之前状态管理使用的是session,现在使用redis存储用户凭证token,利用redis管理状态

1.服务器安装redis,yum list redis*,yum install -y redis.aarch64;

2.修改redis的配置文件,地址/etc/redis.conf,vim /etc/redis.conf 输入:set nu显示行号

1)注释69行的语句,意思是可以利用内网和外网来访问,若不注释那就只能通过内网127来访问;

2)修改136行的daemonize no为yes,意思是可以支持后台运行,客户端关闭也可以正常跑;

3)解除507语句的注释,然后为其设置一个redis的密码,当外网访问时提高安全性。redis123密码

3.启动redis:redis-server /etc/redis.conf

4.利用redis自带的命令行来访问redis:redis-cli -a 密码;

5.每个redis自带16个库,默认使用0库,选择某个库使用select 9;

6.redis是key-value形式的数据库,通过key来查到对应的值,value常用的形式有五种;keys *命令查看所有的key值,但实际开发不可以使用这个语句,因为实际开发中存的key很多,查询到的时间也很慢。

由于我们使用redis主要是用于缓存,所以value的数据类型使用的是字符串,若redis用于持久化时,value的数据类型可以是哈希。

7.redis的命令演示:

  1)存储key与value:set name newcoder
  2) 根据key取value: get name
  3) 若存的value是数字,可以使用incr count来进行自增操作,比如浏览数用到了这个方法,自减decr count
  4)为缓存设置有效时间: set cache 100 ex 3(ex表示秒,px表示毫秒)
  5)查询当前缓存剩余有效时间 ttl cache
  6)删库 flushdb,将当前的key全删除
  • 访问redis使用的是RedisTemplate这个类,而RedisTemplate使用之前需要进行实例化,并且new一个新的RedisTemplate的方法底层使用的序列化方式是二进制,二进制不方便进行查看,所有重写了这个方法,改变序列化的方法,哪个类需要使用redis,就注入redistemplate;

  • 怎么利用redis来分布式进行状态管理?将session替换成redis,session都在controller里面,因为session是基于http实现的组件,是通过request持有的在进行项目优化时,为了避免少改了哪些类,所以利用搜索功能将所有使用到session的类都找出来;

  • 我们的项目中只有三个类使用到了session,logincheckinterptor拦截器,还有登陆与下单操作,将所有使用session的地方均使用redis.登陆时传给前端token,后续前端根据token返回user的相关信息。

  • 之前登陆成功后会将信息存入session中,而前端不用做处理,给前端传的是cookie,浏览器可以直接处理,现在我们使用token,所以不使用cookie进行存储了,cookie的安全性也不高,利用sessionstorage存储,当前也有localstorage,但localstorage中存值不会删掉,而sessionstorage存的值在浏览器关闭之后就清了,所有使用sessionstorage存储token;

  • 获取用户信息时,从客户端返回到服务端时需要带着token;现在将所有与状态有关的东西都存到了redis中,所有后续可以进行分布式部署,可以部署多个tomcat

  • 为什么不使用session作为状态管理的工具,因为客户端是多样的,有的客户端可能没有cookie机制,所以就需要使用token作为状态管理的工具。

十二、Redis

redis单线程模型:采用了io多路复用机制

image-20220702232216089

redis的文件事件,Redis 服务器是事件驱动程序,分为文件事件时间事件

  • 文件事件:socket 的可读可写事件
  • 定时任务

Redis 客户端通过 TCP socket 与服务端交互,文件事件指的就是 socket 的可读可写事件。一般使用非阻塞模式,相关的 I/O 多路复用有select/epoll/kqueue等,不同的操作系统不同的实现。

epoll为例,它是 Linux 内核为处理大量并发网络连接而提出解决方案。epoll提供3个 API

redis基于多个socket与多个客户端相连; 基于io多路复用可以处理多个请求,处理请求的组件为socket,redis处理客户端的请求时根据套接字来处理的;在四个状态下处理socket;利用单线程监听socket状态;文件事件分派器也是单线程的,根据不同的请求处理不同的事件;底层实现是根据当前系统选择性能最高的底层实现。

十三、缓存商品与用户

突破单机的瓶颈就是改变成分布式的状态

微服务是当项目很大的时候才采用的技术,springcloud等;微服务是将服务拆分成多个子服务,各个子服务之间可以直接相互调用,而不是通过我们之前前后端分离的方式,通过代理进行的调用。微服务一定是分布式部署的,但分布式部署不一定就是微服务。

我们项目在缓存商品与用户的时候用到了缓存机制;但不同的业务场景使用的缓存机制是不同的:

1.在缓存商品信息的时候用到了二级缓存,一级缓存使用的是google公司设计的guava cache,由于他与tomcat部署在一个服务器上面,与服务端离得近,所以访问的时候性能高,但由于他与tomcat都部署到了服务器上,所以可以分配给guava cache的内存很小,guava cache可以存储的数据很少,所以存的都是最热的数据,最近使用的数据,而将较热的数据存到二级缓存机制redis中。Guava cache是key value的存值方式:在service中的itemserviceimpl中具体写了从cache中读取数据的方法。

2.在缓存用户信息的时候仅用到了redis缓存机制,因为某个app同时在线的人数高达几百万,那么将用户信息存在内存很小的guava cache是不够的,将其存储到内存很大的redis中,只在下单操作时需要不停的访问用户状态,所以这个时候可以直接从内存中取值,而用户登陆操作中是直接用token来判断的,所以不需要redis的缓存机制。在下单操作中使用了redis进行存储用户的状态,取用户信息从redis中取数据:

3.缓存的实现流程:首先从guava cache中取数据,如果guava中没有数据,那就从redis中取数据,若redis没有才从mysql中取数据,并且从mysql中取出数据后存入redis中,下一次直接从redis中读取,同理若redis中取到了数据就存入guava cache中,下一次直接从guava cache 中取数据,为guava cache与redis设置数据的过期时间,不能一直将数据放在某个缓存机制中,要时刻保证guava cache中存的数据是最热的数据。并且redis的过期时间较guava cache的时间长。

redis持久化机制

image-20220702233609735

redis在允许性能的情况下最多丢一秒的数据,很客观,基于这个优点经常来存储点赞数量,浏览数量与阅读数量,

redis的持久化机制有三种,RDB存储的是二进制文件,AOF存储的是命令。

内存最小管理单元:页

**RDB持久化的流程: **

image-20220702233740812

1:后台执行bgsave命令后,需要通知父进程fork出一个子进程进行持久化的操作,若已经有子进程则直接返回;

2:当父进程在fork子进程的那一刻,父进程是阻塞状态,不执行任何客户端的请求;

3、4同时进行:子进程开始存储父进程的数据,往硬盘中写入数据,写到RDB结尾的文件中并且父进程解除阻塞之后开始执行客户端的其他请求;

5:子进程写完之后通知父进程替换掉旧的RDB文件

为什么父进程的修改操作与子进程的写入操作可以同时进行?

得益于操作系统的写时复制机制:copyonwrite;

copyonwrite对于内存的最小单元是页面、当子进程开始读取红色页面的数据时,copyonwrite会将page单独复制一份副本,而此时父进程修改的是copy出来的副本文件,所以可以同时进行。

由于RDB是快照的方式进行存储文件,也就是说在fork出子进程的时候就快照出来当时的数据,所以子进程存储的是当时的数据。

:bgsave不能时时刻刻做,因为bgsave是将数据都以二进制的形式进行存储的,若时刻刷就会阻塞父线程,所以只能几个小时进行操作一次,作为备份功能。

AOF持久化的流程:

由于AOP是记录命令的。而很多命令是对同一组数据进行的修改,可能会造成冗余,会导致AOF的体积很大,我们只要最后的结果就行,不需要记住所有的命令。这就是重写机制用来压缩AOF的体积。

重写机制:根据内存的数据,分析数据给他一个合适的命令代表此时的数据,然后将这个命令存到新的AOF文件中,将新的AOF文件替换掉旧的AOF文件。

  • AOF流程:

image-20220702234302845

命令写入(写入,修改数据,查询不缓存)时,redis先将命令缓存起来,存入aof_buf,然后按照频率刷到磁盘中,不是一次性写入的,然后缓冲区的数据同步到aof文件中。

同步的方法三种:

1.每次命令写入均刷盘,性能很差,不用这种方法;
2.操作系统什么时候觉得aof_buf满了什么时候就刷盘,但这种方法不可控,时间不确定的,万掉电了数据就丢失了;般不用这种方法;
3.间隔秒刷次,万掉电就损失秒的数据是可以接受的。
掉电重启服务后,直接从AOF文件中加载数据。 
  • AOF的重写机制:基于bgrewriteaof命令,这是自动命令不是手动的。

image-20220702234406160

1:与RDB相似也需要父进程fork出子进程,若AOF正在重写就返回,若正在执行RDB的持久化那就等待

2:父进程fork出子进程处理持久化操作

3、4、5同时进行:

3:父进程fork子进程的那一瞬间是阻塞的,之后就继续他的客户端的请求处理;

4:为了解决子进程将之前快照数据存入新的AOF文件,之后将新的AOF文件替换掉旧的AOF文件,而丢失了在子进程存储数据的同时,父进程处理命令的丢失,将父进程在子进程进行处理期间的命令存入缓存rewrite_buf文件中;一般子进程处理的时间很短,所以rewrite_buf占的内存很小;

5:子进程将fork的那一瞬间与父进程共享内存时的数据存入新的AOF文件中,这是直接将数据存入,而不是分析旧AOF文件从而去掉冗余命令;

6:子进程通知父进程可以将旧的AOF文件替换了;

7:将父进程的缓存文件同步到新AOF文件中

8:进行替换。

:黄色区域是为了压缩体积来做的,不是必须做的;未加黄色的区域与RDB的功能是一样的,只不过每次存储命令会使得体积很大,所以进行了重写机制为了减少体积。黄色区域也可以几个小时做一次,不是时时刻刻做的。

RDB-AOF混合持久化

Redis分布式缓存:

image-20220702234949292

其中redis缓存淘汰策略中有LRU与LFU算法,其实LRU算法不太靠谱,LRU是最近最不常用的缓存机制,可能将某个数据最近访问一次,但之后再也不用了,就不太靠谱。

缓存与数据库的同步:

需要先更新数据库,再删除缓存,其实删除缓存比更新缓存好,因为可能有的人一直在修改数据而不查询数据,那缓存放在那里就是占用资源,所以删除缓存好。

为何先更新数据库再删缓存好?因为另一种会导致缓存数据库持久不同步

若不管先操作哪一步,第二步失败?

A与B分别代表不同线程。

  • 若A先删除缓存,再A更新数据库这一步异常,那么其他线程B再访问呢的时候会先从数据库中读取旧值再更新到缓存中,所以后面的线程会从缓存里面读历史旧数据,那么后续线程读的一直都会是旧数据;即使后面A线程抢到了资源,重试机制更新了数据库,但是缓存是有key对应的value的,不会从数据库中读值。

  • 若A先更新数据库,再A删缓存这一步异常时,前面的线程B会读到缓存的数据,但是后续有线程A抢到资源**重试机制(异步重试)**之后,会删除缓存,后面再有线程B来访问数据的时候会从数据库中读值,然后更新到缓存中,虽然前期会读到旧数据,但是是可以接收的。

    image-20220703104843851

若不管先操作哪一步,第二步不失败?

  • A先删缓存,然后A更新数据库,此时线程B从缓存中读不到值,会从数据库中读值然后set到缓存中,后面线程A才抢到资源更新数据库,但是后面的线程会直接从缓存中取值的,读到的都是旧值。

  • A先更新数据库,此时线程B会从缓存取值,后面线程A再次抢到资源删除缓存,后面线程从缓存取不到值,会从数据库取值再次set到缓存中,后面线程就取到新值了。虽然也会读到一些旧值,但是较另一种方法可以接收的。

image-20220703105702691

Redis常见问题

  • 缓存穿透
  • 缓存击穿
  • 缓存雪崩

十四、异步化扣减库存

前端做异步很简单,使用ajax异步请求,先向服务器请求静态资源,然后再发异步请求;

但后端做异步不能向前端一样等待一会,需要专门的请求队列;RocketMQ;

  • 认识RocketMQ:

​ 阿里做的,适合电商:https://rocketmq.apache.org/

​ 中文介绍:https://github.com/apache/rocketmq/tree/master/docs/cn

  • 1.概念:

    • 1)生产者消费者间的通信可以使用队列,如果队列满那么生产者就会阻塞,若队列空那么消费者会阻塞;例如:blockqueue;
    • 2)topic:之前消费者是没有思绪的从消息队列中取数据,可能有的数据根本用不上,所以设计了topic,将消息进行划分主题,使得消费者从大量的消息队列中仅取出与topic有关的数据;可以称之为订阅模式;
    • 3)代理服务器(broker server):最终干活的服务器;可以有多个;
    • \4) 名字服务(name server) :中心服务器,因为broker有多个,那往哪个broker中发消息?是由name server决定的;充当路由
    • 5)推送式消费(Push Consumer):该模式下Broker收到数据后会主动推送给消费端,该消费模式一般实时性较高;我们项目用的是这个
    • 6)拉取式消费(Pull Consumer):消费者每隔一小段时间就去看一下有没有消费者的消息;
  • 2.技术架构:

    • borker是集群的,有多个数据节点,数据节点可以有主从之分;通常生产者与消费者是在一个应用中;

      生产者要broker集群中发消息时该往哪个broker发需要问name server,name server会根据broker cluster状态找一个合适的broker 然后将数据发送给对应的broker server;

      消费者应该从哪个broker中消费消息要从name server中获取;

image-20220703110128665

  • 3.设计

    生产者消费者通过消息队列沟通,并且rocketMQ底层不是仅有一个队列,核心队列是commitlog;生产者将所有的消息都放在commitlog中,包含了所有主题的信息;若消费者直接从commitlog中读取对应主题的信息,就是随机读取,commitlog是放在磁盘上的,从磁盘中随机读取数据性能很差,为了解决此问题;rocketMQ又引入另一个消息队列:ConsumerQueue;默认有三个ConsumerQueue,那么这三个ConsumerQueue都能访问commitlog,提高并发的能力;ConsumerQueue包含了commitlog中相同主题的消息;消费者直接从ConsumerQueue读取对应主题的信息性能提升。

image-20220703110211546

ConsumerQueue通过消息主题+消息id消费

消费者往commitlog写文件刷盘的方式:异步刷盘性能高。

image-20220703110248055

安装4.8.0RocketMQ:
wget https://archive.apache.org/dist/rocketmq/4.8.0/rocketmq-all-4.8.0-bin-release.zip
判断某命令是否已经启动:
Ps -ef | grep 命令名
首次安装好RocketMQ并启动之后,你看broker.log发现报错,提示说commitlog不存在。这个问题可以忽略,因为你还没有发放任何消息,这个文件还没有被初始化,当你发送任何条消息值,它自动就会创建好。
进行测试:使用bin目录下的tools文件
# 关闭RocketMQ:
sh ./bin/mqshutdown broker
sh ./bin/mqshutdown namesrv
  • 4.样例:

    发送异步消息;消费消息;顺序消息样例(了解);延时消息样例(解决少卖问题);消息事务样例(重要)

  • rocketmq的自动配置文件rocketautoconfiguration.class;

  • rocket的配置信息是以rocketmq为前缀的信息

  • 项目中若需要用到rocketmq就直接注入rocketMQTemplate就可;

十五、事务型消息-RocketMq

保证最终一致性,保证生产者的事务提交与消费者消费数据的一致性,同成功与同失败,利用两阶段提交;

image-20220703110454813

  • 1.生产者向broker发送数据前先发送一个信号告知broker server要发消息了;
  • 2.broker server服务器向生产者返回一个确认信号;
  • 3.生产者处理事务;
  • 4.若事务提交成功,生产者就会向broker server发送commit信息;否则回滚;但可能出现第3步事务正常处理,但是成功与否的信息不能传回到broker server服务器中,就会导致broker server中存在的半成品信息无法被消费者正常消费;所有rocketMQ有回查机制,若一段时间broker server没有收到生产者的事务结果,就会向生产者询问是否提交事务,而生产者又会向mysql询问是否提交事务,这个回查机制会一直查,直到能查询到结果为止;
  • 5.回查;
  • 6.回查;
  • 7.将查询到的结果再次传到broker server服务器中,等到broker正常接收到commit信号时,将信息给消费者进行消费;
  • 8.消费。

异步扣减库存

不是在下单的瞬间就扣减库存,否则并发就少了,因为如果100个人同时抢同一个商品,那么直接扣减库存的压力会很大,大家都是锁定同一个库存表,并发性能低,而如果先创建订单,订单创建与库存无关,性能也会很好;延时一段时间将数据库的库存扣减了,但是前面不能不做预减库存,因为可能实际的库存已经没有了,而还有人在下单,所以先预减缓存的库存,

扣减库存时将数据库的库存移到缓存中,然后为了解决少卖的问题,先从缓存中预减库存,解决有的人抢到了但是没货的情况,但是可能有的人下单但是不支付,那就会少卖,所一将缓存的预减库存与真正数据库的扣减库存做成异步的;

image-20220703110842944

下单是我们项目的亮点,因为存在高并发的情况,也存在扣减库存的异步问题(两阶段提交),所以producer代表的下单操作,consumer代表扣减数据库的库存操作;此时的缓存与数据库的库存一定是一致的,但是可能数据库的扣减库存可能会慢一点;但是一定会一致(回查);

  • 1:首先producer在扣减库存前向broker server发送信息并且生成流水,这个流水的目的是后期check的时候,检查订单不合理,因为有时候可能订单都创建不成功,是因为延迟了,但是直接因为订单没有创建好就认为他没有生成订单是不靠谱的,那check就不能正常,所以使用了库存的流水;库存流水是状态值:0未生成订单;1未知;2生成订单
  • 2:broker接收到producer发送的信息(半成品)以及流水,然后给producer回复确认信号;
  • 3:producer进行事务操作,先预减库存,然后创建订单,并更新流水;
  • 4:producer给broker server发送事务是否成功;若这一步不能正常传入broker;
  • 5:回查;检查库存流水;
  • 6:回查
  • 7:检查到事务成功与否;返回给broker server;
  • 8:若事务成功,将broker server的信息(库存的id等信息)传给cousumer;然后cousumer进行扣减库存操作。

注:这里虽然数据库中引入了库存流水,但相比于之前整个库存,用户下单时需要对整个库存表锁定,并发性很差,而这里的库存流水是每个用户单独创建的一个很小的表,只有一行,锁定一行,用户对自己的库存流水进行锁定,不影响其他用户,锁的力度很小;

流水只能在第一步就生成,如果在创建订单的时候生成,那么万一订单由于阻塞生成不成功,回查的时候流水也没有,那回查的依据没有,所以第一步就生成流水的数据,如果订单未创建成功,那回查的时候发现流水没有更新,就知道了订单是被阻塞了 。通过库存流水的状态能知道整个的进度。

RocketMQ解决消息丢失问题:

image-20220703203358755

消息失败解决

image-20220703203427426

重复消费问题

重复消费原因:生产者向broker server发送消息时失败,那么生产者会再次发送消息,但是可能消息队列已经将消息发送给消费者进行消费了,只是没有及时或者成功的给生产者返回信息,这时候生产者再一次发送消息,那么就有重复消息发送给消费者,消费者就进行了重复消费,就是我们项目中重复扣减库存问题,这个重复消费问题不可解决,但是我们项目中重复扣减一两件库存是可以接受的。

image-20220703203629201

死信队列

消息队列中重试了超过次数的消息,那么就会将这个消息放入死信队列中;RocketMQ将这种正常情况下无法被消费的消息称为死信消息(Dead-Letter Message),将存储死信消息的特殊队列称为死信队列(Dead-Letter Queue)

最终一致性问题

使用事务性消息保证了数据库的库存与缓存的库存一致性。

十六、削峰限流与防刷

create方法下单之后异步扣减库存,但是这个地方的承载能力也有限,若一下子进来几千万流量,服务器撑不住的,所以要从源头限制掉;还有若秒杀商品仅100件,那么放一万人进来也是不合理的,所以限流很有必要;

在交易之前加入用户的验证,通过验证来限流,验证通过的用户获得令牌,若仅有100件商品,令牌设置1000足以,目的是放1000人进来抢商品,所以加入大闸限制令牌的数量,令牌数量一般是某个商品库存的10倍即可;而当商品有几十万件时,令牌就需要几十万,数量太大,所以加入限流器,限制单机的TPS,设置每台服务器最多能处理多少万的请求,由于请求较多,直接全送入交易接口也是不可行的,因为可能会存在阻塞的情况,所以加入队列,底层是线程池,增加线程的缓冲能力,不是一股脑的全加入交易接口中;

在用户验证前可以加入验证码输入操作,避免用户在同一时间下单操作,因为秒杀时间是固定的,输入验证码的操作会平滑流量到1-N时间范围内,验证码的操作可以使用easy captcha,这个验证码生成技术支持简单的加法运算,字符填写技术,那些比较复杂的动态验证码,需要移位等的技术是大公司用的;大闸使用redis技术限流器使用guava技术队列使用线程池

防刷防的是黄牛,限流是限制正常进来的人数;

防刷:我们项目无法体现防刷技术,因为没有那么多终端

限流技术:ratelimiter:底层使用令牌桶算法,限流技术限制的是一秒访问的数量

  • 令牌桶算法:限流器初始化的时候会初始化一定数量的令牌数量,令牌生成器每秒会生成一定数量的令牌,令牌桶满的话就抛弃,此时客户端访问服务器的时候首先被限流器拦截,若令牌桶的数量空,就返回给客户端信息。不允许访问;若有令牌就给客户端发一令牌。并且令牌总数-1,客户端到达业务组件。

  • 漏桶算法: 与令牌同不同,漏桶直接与业务组件对接;漏桶每次只漏出10滴水给业务组件,也就是说业务组件的处理请求的频率是固定的,每次只处理10个请求。当往桶中注水桶满时就溢出,拒绝访问。当客户端访问服务器时会先被限流器拦截,若桶满则拒绝访问,若未满就加入桶一滴水,加入之后这个请求不是立即执行,要等前面的请求执行完毕,每秒10个请求给业务组件。

令牌桶与漏桶的区别:

令牌桶有高峰与低峰的情况:

有可能秒杀一开始1000个请求全抢走了令牌,然后业务组件开始处理这些请求,然后慢慢的令牌生成器往令牌桶中加令牌,之后就每次处理10个请求,低峰指的是秒杀未开始时没有请求;

漏桶虽然业务组件恒定处理请求,但是不能处理秒杀一开始高并发情况,所以根据业务场景选择合适的限流器算法,我们项目使用令牌桶算法处理秒杀时刻的高并发问题。

十七、再次压测与总结

再次部署就不能用之前的服务器,因为之前服务器的带宽1M,流量有限,遇到瓶颈了,所以最终的测试可以买按需计费的服务器:华为云,2核4G,选择最大带宽4G,基础带宽1.2G,选择按流量计费,带宽选择300M,突破带宽瓶颈。大概2-3小时测试完。

去压测,线程可以调很大进行查询下单等操作,2000个线程20秒跑完,循环30次测试的时候将限流器注释掉,让服务器没有限制的跑,让数据尽可能跑到极限。

压测下单操作时,需要传入的数据是:这次不需要cookie了,我们使用的是token。

在浏览器执行相应操作时,network会记录请求响应的所有记录数据。然后根据数据模拟请求进行压测。jemeter

每秒处理5200个请求,这个数字不是越大越好,是可观最好,我上线之后的性能就是这个,当然这个结果不是最好,限制于我们服务器的性能。

QPS与TPS不一定谁就比谁高,要看具体情况:

我们项目中在自己服务器上(带宽1M)其实QPS比TPS低,因为QPS查询操作,查询结果数量很大,网络有瓶颈的时候TPS>QPS。

十八、未来改进点

解决少卖的延时队列,到点取消订单,还有付款功能没做

优化方向:现在项目的tomcat、nginx、mysql、redis、rocketMQ都是单节点的,后续可以优化为集群的方式,由于我们现在没有服务器,所以现在没办法这么优化;

线程池的数量应该是多少呢?根据环境有关,需要实际调整。消融实验优化。

补充:并发:多线程是解决并发问题的手段;但不是唯一的;解决互斥的问题

先理解java内存模型,java堆与栈的关系

解决少卖问题

消费者检查redis中存储的每个订单是否到时间了,到时间还没付款就取消。然后再检查下一个。redis还要写一个定时器。

image-20220703204836685

RocketMQ等MQ机制解决少卖问题:MQ自带定时器3

image-20220703204905147

十九、网站维护

由于我租的服务器,很容易被恶意侵入,前端时间被植入了挖矿程序,导致我的网站崩溃

解决方案,网站的维护很重要

入侵的主演原因:

服务器端口号默认22,用户名root;很容易入侵,所以将服务器禁止root登陆,将端口号从22改为19707,用户名:自定义,密码:自定义,登陆之后再进入root用户:su root,输入root的密码:970307Xiami;

之后将可以重启程序,要判断使用的几个组件是否启动,使用以下命令:

1.ps -ef | grep 命令

2.Nginx: 进入到/etc/nginx目录下输入nginx -s reload

3.启动nginx: nginx -c /etc/nginx/nginx.conf

4.若显示98端口被占用:输入fuser -k 80/tcp

5.Redis: 启动redis:redis-server /etc/redis.conf

6.RocketMQ: 先进入目录cd /root/rocketmq-all-4.8.0-bin-release

开启nameserver:       nohup sh ./bin/mqnamesrv -n localhost:9876 &
开启broker:           nohup sh ./bin/mqbroker -n localhost:9876 -c ./conf/broker.conf &

7.关闭rocketmq:

关闭broker:          sh ./bin/mqshutdown broker
关闭nameserver:      sh ./bin/mqshutdown namesrv

8.mysql:查看mysql的状态:systemctl status mysqld 开启mysql: systemctl start mysqld

9.都启动后再进入/root目录下执行sh seckill.sh可以正常访问

二十、后续优化

  • 秒杀url:

    为了避免有程序访问经验的人通过下单页面url直接返回顾问后台接口来秒杀货品、我们需要将秒杀的url直接动态化,解释是开发整个系统的人都无法在秒杀开始前知道秒杀的url,具体的做法是通过md5加密一串随机字符作为秒杀的url,然后前端访问后台获取具体的url,后台校验完成之后才能继续完成秒杀。

  • 秒杀页面静态化

    将商品的描述、参数、成交记录、图像等全部写入到一个静态页面,用户请求不需要通过访问后端服务器,不需要经过数据库,直接在前台客户端生成,这样可以最大可能减少服务器的压力,做法:使用freemarker模板渲染技术,建立网页模板,填充数据,然后渲染网页。