商城秒杀系统总结(Java)


本文写的较为零散,对没有基础的同学不太友好。

一、秒杀系统项目总结(基础版)

classpath

在.properties中时常需要读取资源,定位文件地址时经常用到classpath

类路径指的是src/main/java,或者是src/main/resource下的路径。例如:resource 下的 classpath:mapping/*.xml,经常用于Mybatis中配置mapping文件地址。

Mybatis-generator

在写项目中可以利用mybatis-generator进行一些机械性工作(在pom中引入),这里将配置文件中的一部分进行展示:

<?xml version="1.0" encoding="UTF-8"?>



    
        
        
        
        
        
            
            
        
        
        
            
        
        
        
        
            
        

        
        
        

在使用mybatis-generator之后要注意检查mapping中的文件,进行适当修改,比如Insert操作中声明自增和主键。

Spring异常拦截:

  1. 如果对Spring程序没有进行异常处理,则遇到特定的异常会自动映射为指定的HTTP状态码,部分如下:
image-20220119235134663

表中的异常一般会由Spring自身抛出,作为DispatcherServlet处理过程中或执行校验时出现问题的结果。如果DispatcherServlet无法找到适合处理请求的控制器方法,那么将会抛出NoSuchRequestHandlingMethodException异常,最终的结果就是产生404状态码的响应(Not Found)。

  1. 通过使用@ResponseStatus注解能将异常映射为特定的状态码:
//定义exceptionhandler解决未被controller层吸收的exception
    @ExceptionHandler(Exception.class)
    @ResponseStatus(HttpStatus.OK)
    @ResponseBody
    public Object handlerException(HttpServletRequest request, Exception ex){
        Map responseData = new HashMap<>();
        if( ex instanceof BusinessException){
            BusinessException businessException = (BusinessException)ex;
            responseData.put("errCode",businessException.getErrCode());
            responseData.put("errMsg",businessException.getErrMsg());
        }else{
            responseData.put("errCode", EmBusinessError.UNKNOWN_ERROR.getErrCode());
            responseData.put("errMsg",EmBusinessError.UNKNOWN_ERROR.getErrMsg());
        }
        return CommonReturnType.create(responseData,"fail");
    }

这里将响应200(OK)状态码,但是大多数时候,我们需要知道这个异常的具体信息,这就需要如上代码所示,加上 @ExceptionHandler(Exception.class),一旦捕捉到异常,则按handler流程运行。 如果需要一个contrller具有该异常处理,可以建立一个基类进行继承,不然需要每个controller都写一遍,这种方式较为麻烦。

一个Controller下多个@ExceptionHandler上的异常类型不能出现一样的,否则运行时抛异常.

  1. @ControllerAdvice+@ExceptionHandler拦截异常并统一处理

    @ExceptionHandler的作用主要在于声明一个或多个类型的异常,当符合条件的Controller抛出这些异常之后将会对这些异常进行捕获,然后按照其标注的方法的逻辑进行处理,从而改变返回的视图信息。

    @ControllerAdvice
    public class GlobalExceptionHandler{
        @ExceptionHandler(Exception.class)
        @ResponseBody
        public CommonReturnType doError(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, Exception ex) {
            ex.printStackTrace();
            Map responseData = new HashMap<>();
            if( ex instanceof BusinessException){
                BusinessException businessException = (BusinessException)ex;
                responseData.put("errCode",businessException.getErrCode());   //自定义的异常类
                responseData.put("errMsg",businessException.getErrMsg());
            }else if(ex instanceof ServletRequestBindingException){
                responseData.put("errCode",EmBusinessError.UNKNOWN_ERROR.getErrCode());
                responseData.put("errMsg","url绑定路由问题");
            }else if(ex instanceof NoHandlerFoundException){
                responseData.put("errCode",EmBusinessError.UNKNOWN_ERROR.getErrCode());  //自定义的枚举类
                responseData.put("errMsg","没有找到对应的访问路径");
            }else{
                responseData.put("errCode", EmBusinessError.UNKNOWN_ERROR.getErrCode());
                responseData.put("errMsg",EmBusinessError.UNKNOWN_ERROR.getErrMsg());
            }
            return CommonReturnType.create(responseData,"fail");
        }
    }
    

    这样,当访问任何controller的时候,如果在该controller中抛出了Exception,那么理论上这里的异常捕获器就会捕获该异常,判断情况,然后返回我们定义的异常视图(默认的error视图)。

    在数据库设计层面需要注意的有:例如商品价格属性在后台设置为BigDecimal,但是mysql中是没有这个关键字的,我们可以在表中设计为double属性,包括商品的DO对象也为double,但是在商品的model对象中属性为BigDecimal,需要进行类型转换。不用double的原因为后端传送给前端后,可能会出现一些错误,例如1.9传过去之后可能为1.99999...

    建议将价格等对数位敏感的数据在后台处理为BigDecimal。

    在数据结构设计层面建立了3种数据对象,视图层中的VO对象,这是为了将用户需要的数据进行呈现,避免将一些用户不需要感知的数据进行前后端交互。dao层的DO对象,这是为了和数据库真正进行交互。Service层的Model对象,这是为了后台整体逻辑统一,例如用户的资料和用户的密码在本项目中分两个表存,肯定有两个DO对象,而在后台设计时,每次都调用两个DO属性较为麻烦,直接建立一个用户的逻辑对象,将用户相关的所有数据放在一个对象中,方便操作。

基础知识

前端

在编写前端页面的时候,通常使用一些框架,比如本项目使用的Metronic,之前也稍微用过element-ui这些,一般逻辑为:首先 中引入样式和.js资源,然后在 中通过调用"class"即可直接完成页面的美化,在处理动态逻辑的时候,需要用ajax进行click等动作的判定,以及请求的发送。

对于前端我只了解一点点,可能说的不对,不过稍微理解概念后即可在模板上进行修修改改。

Java 8 stream api

在代码中经常使用.stream()有利于简化代码结构,效率高一点,举例:

//使用stream apiJ将list内的itemModel转化为ITEMVO;
        List itemVOList =  itemModelList.stream().map(itemModel -> {
            ItemVO itemVO = this.convertVOFromModel(itemModel);
            return itemVO;
        }).collect(Collectors.toList());

这一段即为将一个Model结构的list,利用stream api转成VO结构的list。

MD5加密

数据库中通常不存明文密码(防止数据库数据泄露,密码被公开),这时候我们需要一种加密方式,大多数采用MD5加密,在Java原生包中 MD5Encoder 只支持16位长度,这样的话不方便业务实现。

md5是不可逆的,也就是没有对应的算法,从生产的md5值逆向得到原始数据。但是如果使用暴力破解,那就另说了。

简单实现方式:

public String EncodeByMd5(String str) throws NoSuchAlgorithmException, UnsupportedEncodingException {
        //确定计算方法
        MessageDigest md5 = MessageDigest.getInstance("MD5");
        BASE64Encoder base64en = new BASE64Encoder();
        //加密字符串
        String newstr = base64en.encode(md5.digest(str.getBytes("utf-8")));
        return newstr;
    }

MD5的几个特点:

1.长度固定:

不管多长的字符串,加密后长度都是一样长
作用:
方便平时信息的统计和管理

2.易计算:

字符串和文件加密的过程是容易的.
作用: 开发者很容易理解和做出加密工具

3.细微性

一个文件,不管多大,小到几k,大到几G,你只要改变里面某个字符,那么都会导致MD5值改变.
作用:
很多软件和应用在网站提供下载资源,其中包含了对文件的MD5码,用户下载后只需要用工具测一下下载好的文件,通过对比就知道该文件是否有过更改变动.

4.不可逆性

你明明知道密文和加密方式,你却无法反向计算出原密码.
作用:基于这个特点,很多安全的加密方式都是用到.大大提高了数据的安全性

交易模型

交易模型流程:

//1.校验下单状态,下单的商品是否存在,用户是否合法,购买数量是否正确,校验活动信息
//2.落单减库存(下单时刻即减少库存,但是如果用户取消交易需要将库存还原,适用于后台备货比显示多的情况),还有一种交易减库存,这是只有当成功交易才会减少库存,适用于显示的库存为真实库存,会让用户有一定的交易紧迫感
//3.订单入库,生成交易流水号,订单号,加上商品的销量
//4.返回前端

设计订单号:(订单号显示是具有一定意义的,简单的自增ID无法满足需求)

设计订单号为16位:前8位为时间信息(年月日)方便在数据库数据量过大时候,可以删除几个月前的无用订单数据。中间6位为自增序列,如果每天的订单量超过6位数,则需要扩增。最后两位为分库分表位,区分在哪个库哪张表。这是订单号的一个简单设计。

秒杀环节的简单思考:

秒杀通常与商品活动挂钩,因此必然有一个活动开始时间,活动结束时间,以及活动开始倒计时,在增加秒杀活动的过程中,我们就需要对商品模型数据结构进行修改,可以增加一个促销模型属性,而促销模型进行分层设计,设计其service等等。在前端进行一定的页面修改,显示时间,显示促销价格等等。同时对订单模型进行修改,增加是否促销属性,如果促销,则订单入库时需要以促销价格入库,这些地方需要注意。

至于后端订单接口如何识别是否在活动呢?

//1.通过前端url上传过来秒杀活动id,然后下单接口内校验对应id是否属于对应商品且活动已开始
//2.直接在下单接口内判断对应的商品是否存在秒杀活动,若存在进行中的则以秒杀价格下单

显然,使用2的话,在非促销商品的下单环节会增加不必要的运行。

前端设计:

下单时,将promo_id传进去

jQuery(document).ready(function(){
		$("#createorder").on("click",function(){
			$.ajax({
				type:"POST",
				contentType:"application/x-www-form-urlencoded",
				url:"http://localhost:8090/order/createorder",
				data:{
					"itemId":g_itemVO.id,
					"amount":1,
					"promoId":g_itemVO.promoId
				},
				xhrFields:{withCredentials:true},
				success:function(data){
					if(data.status == "success"){
						alert("下单成功");
						window.location.reload();
					}else{
						alert("下单失败,原因为"+data.data.errMsg);
						if(data.data.errCode == 20003){
							window.location.href="login.html";
						}
					}
				},
				error:function(data){
					alert("下单失败,原因为"+data.responseText);
				}
			});

		});

后台下单:

//封装下单请求
@RequestMapping(value = "/createorder",method = {RequestMethod.POST},consumes={CONTENT_TYPE_FORMED})
@ResponseBody
public CommonReturnType createOrder(@RequestParam(name="itemId")Integer itemId,
                                    @RequestParam(name="amount")Integer amount,
                                    @RequestParam(name="promoId",required = false)Integer promoId) throws BusinessException {

    Boolean isLogin = (Boolean) httpServletRequest.getSession().getAttribute("IS_LOGIN");
    if(isLogin == null || !isLogin.booleanValue()){
        throw new BusinessException(EmBusinessError.USER_NOT_LOGIN,"用户还未登陆,不能下单");
    }

    //获取用户的登陆信息
    UserModel userModel = (UserModel)httpServletRequest.getSession().getAttribute("LOGIN_USER");

    OrderModel orderModel = orderService.createOrder(userModel.getId(),itemId,promoId,amount);

    return CommonReturnType.create(null);
}

部署

本人是直接利用宝塔linux面板进行环境部署,在运行项目是采用外挂配置:

nohup java -jar "目标jar" --spring.config.additon-location=/外挂配置地址
//nohup可挂在后台运行jar包

并且外挂配置优先级高于默认配置

二、JMETER性能测试

image-20220302170432864

JMETER实际上就是在本地开一个线程组,自己规定线程组的规模,向服务器发出HTTP请求,进行性能压测。一般需要配置HTTP请求,查看结果树,聚合报告这三项。

image-20220302170708900

这是一个GET请求的示例,设置20个线程,ramp-up时间设为10秒,即jmeter用10秒启动20个线程并运行。(改动了线程组的设置)

image-20220302171847971

观测结果,即平均58ms响应,90%的为64ms内响应,99%的为110ms内响应,TPS为2.1。

TPS 即Transactions Per Second的缩写,每秒处理的事务数目。一个事务是指一个客户机向服务器发送请求然后服务器做出反应的过程(完整处理,即客户端发起请求到得到响应)。客户机在发送请求时开始计时,收到服务器响应后结束计时,以此来计算使用的时间和完成的事务个数,最终利用这些信息作出的评估分。一个事务可能对应多个请求,可以参考下数据库的事务操作。

在服务器上查看tomcat当前维护的线程树:

image-20220302172638385

可知当前共维护28个线程。1422为java运行端口。

因为测试服务器是单核2G内存,当测试5000个线程,10秒开启,循环10次时,就会出现大量错误请求。

内嵌tomcat配置

SpringBoot内嵌了tomcat容器,配置如下(部分):

{
  "sourceType": "org.springframework.boot.autoconfigure.web.ServerProperties",
  "defaultValue": 8080,  //tomcat端口设置
  "name": "server.port",
  "description": "Server HTTP port.",
  "type": "java.lang.Integer"
},
{
  "sourceType": "org.springframework.boot.autoconfigure.web.ServerProperties$Tomcat",
  "defaultValue": 100,   //tomcat线程池队列超过100后,请求将被拒绝
  "name": "server.tomcat.accept-count",
  "description": "Maximum queue length for incoming connection requests when all possible request processing threads are in use.",
  "type": "java.lang.Integer"
},
{
  "sourceType": "org.springframework.boot.autoconfigure.web.ServerProperties$Tomcat",
  "defaultValue": 10,   //线程池的最小线程数量,可以理解为corePoolSize
  "name": "server.tomcat.min-spare-threads",
  "description": "Minimum number of worker threads.",
  "type": "java.lang.Integer"
},
{
  "sourceType": "org.springframework.boot.autoconfigure.web.ServerProperties$Tomcat",
  "defaultValue": 10000,   //tomcat支持最大连接数
  "name": "server.tomcat.max-connections",
  "description": "Maximum number of connections that the server accepts and processes at any given time. Once the limit has been reached, the operating system may still accept connections based on the \"acceptCount\" property.",
  "type": "java.lang.Integer"
},
{
  "sourceType": "org.springframework.boot.autoconfigure.web.ServerProperties$Tomcat",
  "defaultValue": 200,  //tomcat支持最大线程数,可认为maximumPoolSize
  "name": "server.tomcat.max-threads",
  "description": "Maximum number of worker threads.",
  "type": "java.lang.Integer"
},

测试4000个线程,15秒内启动,循环100次,观察:

image-20220302175908933

可以看到java进程的线程数在不断上升。

而jmeter开始观察到错误请求。

image-20220302175812733

image-20220302175822195

关于SpringBoot中内嵌tomcat默认配置如下:

image-20220302180152582

接下来修改默认配置:

image-20220302181707281

一般经验上,在4核8G的服务器上,最大线程数可设为800,但是本服务器为单核2G,暂设为200。

image-20220302181935791

重启程序,可以看到,最小线程数较之前已有较大提升。

之前测试过高直接导致服务器卡死,重新设置,200线程,15秒启动,循环50次,

image-20220302184844757

可见比之前几十个线程,已经多了很多。

keep-alive设置

关于keepalive,如何设置连接断开时间或者该请求访问多少次之后断开连接,在内嵌tomcat的配置json中是没有的,这时候需要更改代码:

增加config package:

//当Spring容器内没有TomcatEmbeddedServletContainerFactory这个bean时,会吧此bean加载进spring容器中
@Component
public class WebServerConfiguration implements WebServerFactoryCustomizer {
    @Override
    public void customize(ConfigurableWebServerFactory configurableWebServerFactory) {
            //使用对应工厂类提供给我们的接口定制化我们的tomcat connector
        ((TomcatServletWebServerFactory)configurableWebServerFactory).addConnectorCustomizers(new TomcatConnectorCustomizer() {
            @Override
            public void customize(Connector connector) {
                Http11NioProtocol protocol = (Http11NioProtocol) connector.getProtocolHandler();

                //定制化keepalivetimeout,设置30秒内没有请求则服务端自动断开keepalive链接
                protocol.setKeepAliveTimeout(30000);
                //当客户端发送超过10000个请求则自动断开keepalive链接
                protocol.setMaxKeepAliveRequests(10000);
            }
        });
    }
}

这样配置之后当springboot加载tomcat容器时,会扫描该定制类,加载设置。

容量问题优化方向

image-20220302192007866

在jmeter压测过程中,通过top -H命令是可以看到进程占用情况的,可以看到mysql是主要占据内存的应用。因为每个请求实际上都是到数据库进行查询。

image-20220302192827115

关于数据库QPS可以参考上图。

三、分布式扩展

原项目性能压测:

image-20220302193853956

TPS在200左右。接下来考虑优化:通过nginx反向代理负载均衡进行水平扩展。

思路为:一台nginx代理服务器,两台java程序运行服务器,一台mysql服务器。

首先在数据库服务器开放远程端口:

需要开放权限,本文是进行内网访问,可参考该篇博客:https://blog.csdn.net/zhazhagu/article/details/81064406

nginx

作为web服务器

Nginx架构,通过修改nginx.conf来实现这个架构

location /resources/ {

            alias   /usr/local/openresty/nginx/html/resources/;

            index  index.html index.htm;

        }

表明当访问路径命中了/resources之后,就把/resources/替换成

/usr/local/openresty/nginx/html/resources/

并将 /resources/后面的html资源拼接在后面

将所有的前端文件和static文件都移动到resources文件夹中

因为修改了配置文件,所以要重启nginx,nginx提供了无缝平滑重启(用户不会感知):

image-20220302233615792

动静分离服务器

image-20220302235624928

将conf/nginx.conf进行配置:

upstream backend_server{
        server 172.27.65.183 weight=1;
        server 172.16.162.179 weight=1;   #两个应用服务器,权重均为1,则为轮询方式进行访问
    }
    server {
        listen       80;
        server_name  localhost;

        #charset koi8-r;

        #access_log  logs/host.access.log  main;

        location /resources/ {
            alias   /usr/local/openresty/nginx/html/resources/;
            index  index.html index.htm;
        }
	    #新增
        location / {
            proxy_pass http://backend_server;   #当访问/路径时,将反向代理到backend_server上
            proxy_set_header Host $http_host:$proxy_port;  #host和port进行拼接,发送到应用服务器
            proxy_set_header X-Real-IP $remote_addr;  #真正的ip地址是远端的地址,否则将会拿到nginx服务器的地址
            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;  #设置这个头表明nginx只是转发请求
        } 

效果如下:

image-20220303001426126

请求转发到了应用服务器上,且响应正确。

通过开启tomcat access_log进行观察请求是否进入应用服务器:

通过修改项目application.properties

server.tomcat.accesslog.enabled=true

server.tomcat.accesslog.directory=/www/SpringBoot/tomcat

server.tomcat.accesslog.pattern=%h %l %u %t "%r" %s %b %D

# %h远端host  %l通常为- %u用户 %t请求时间 %r对应的HTTP请求的第一行,请求的URL等信息 %s返回状态码 %b请求返回大小(字节) %D处理请求的时长(毫秒)

日志输出如下:

172.27.65.182 - - [03/Mar/2022:00:26:19 +0800] "GET /item/get?id=6 HTTP/1.0" 200 303 1156

注意因为nginx代理给两个应用服务器,所以没刷新两次页面,才有一个请求被分配给这个打印日志的服务器。

目前负载均衡策略:请求以轮询方式分给两台应用服务器。

JMETER性能压测

代理服务器带宽为3M,应用服务器带宽为1M,数据库服务器带宽为1M。

测试参数设置:700线程,10秒内启动,30次循环

对代理服务器发送请求:

image-20220303004726612

可以看到TPS已经上升到了490左右,峰值600左右,由于线程开启过多的话,TOP工具将会非常卡,所以对更高参数不作测试。

观察数据库服务器:

image-20220303005633257

面对这样的请求,数据库服务器还是较为轻松。

观察水平扩展后的应用服务器:(这里JVM的内存设置为1G,服务器内存为2G)

image-20220303005825990

对比

对于单机进行测试:

image-20220303005111170

从top工具可知,单个服务器负载面对同样的情况非常高,已经开始拒绝请求。可见,水平扩展的效果是比较好的。

image-20220303005307874

目前优化后的系统架构:

image-20220303005947174

优化nginx服务器

目前nginx服务器与两台应用服务器不是长连接,需要从nginx.conf中进行设置。

#更改两处
upstream backend_server{
        server 172.27.65.183 weight=1;
        server 172.16.162.179 weight=1;
        keepalive 30;
    }
location / {
            proxy_pass http://backend_server;
            proxy_set_header Host $http_host:$proxy_port;
            proxy_set_header X-Real-IP $remote_addr;
            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
            proxy_http_version 1.1;  #修改header
            proxy_set_header Connection "";  #将Connection字段置空,Connection为空就使用KeepAlive
        }

配置了之后,Nginx和应用服务器之间就不会有频繁的建立释放连接的过程.访问平均响应时间会快很多

这样处理之后,处理TIME_WAIT状态的进程数就会少很多。

nginx高性能的原因

epoll多路复用

image-20220303011418494

select和epoll的区别可以理解为:一个需要遍历查找哪个发生变更,而epoll是不需要的,因此epoll更快,且监听更多。

master-worker进程模型

image-20220303012114559

master和worker是父子进程,下图第二行显示。

image-20220303012253315

因此,master进程可以管理worker进程,worker进程为真正连接客户端的进程。client发送socket连接请求时(TCP),master并不会进行accept处理,而是发送信号给worker进行accept动作。本质上是多个worker去抢占锁,抢到的进行accept连接。后续send和recv均由连接的worker负责。

nginx平滑重启的原因是什么呢?

不论是worker挂了,还是管理员发出重启命令,master是不能挂的,对应的master进程会将死亡的worker进程所有的socket句柄交给master管理,这是master会Load所有的配置文件去new一个新的worker,并将所有句柄交给他。

每个worker中只有一个线程,这些线程基于epoll模型,理论上worker的线程是不阻塞的,因此非常快。

协程机制

image-20220303013315430

协程的模型:一个线程有多个协程,依附于线程,只调内存开销,开销比较小。
协程程序遇到阻塞,自动将协程权限剥夺,调出不阻塞协程执行。
不需要加锁。不是线程要抢夺锁资源效率会比较高。

分布式会话

image-20220303013743278
//将OTP验证码同对应用户的手机号关联,使用httpsession的方式绑定他的手机号与OTPCODE
httpServletRequest.getSession().setAttribute(telphone,otpCode);
//在验证之后,将成功标识加入session中作为登录凭证

第一种方式。之前的方式只适用于单体应用,因为session_id存储于spring内嵌的tomcat容器中,如果有多台服务器,携带的session_id只能对应其中一台应用服务器的登陆凭证。

将session存储在redis服务器上

第一种方式在分布式应用上的实现,需要迁移到redis上。

引入依赖:


 org.springframework.boot
 spring-boot-starter-data-redis



 org.springframework.session
 spring-session-data-redis

新建类:设置Redis的session过期时间为3600秒-一小时

@Component
@EnableRedisHttpSession(maxInactiveIntervalInSeconds = 3600)   //将httpsession放入redis内
public class RedisConfigure {
}

本地windows安装redis.下载zip包解压即可:redis-server.exe redis.windows.conf

redis-cli.exe -h 127.0.0.1 -p 6379启动redis

在IDEA配置:redis

spring.redis.host=127.0.0.1
spring.redis.port=6379
spring.redis.database=10
#spring.redis.password=
#设置jedis连接池
spring.redis.jedis.pool.max-active=50
spring.redis.jedis.pool.min-idle=20

那么现在session信息的存储就是默认存储在Redis上

但是存储在Redis上的对象要可序列化,实现Serizaliable接口(也可以不实现,修改redis的序列化方式,这里介绍序列化方式,直接在需要存在redis上的数据结构上implements Serializable,使用java默认的序列化方式)

而redis需要部署在数据库服务器上,因为假如分别部署到两个应用服务器上,各自存各自的登录凭证,和之前的cookie存储session是一样的,并不能实现分布式会话登录。

注意修改数据库服务器上redis的配置文件,绑定本机内网地址,(4台服务器内网相连)。修改jar包配置文件,

#配置springboot对redis的依赖
spring.redis.host=127.0.0.1   #这里为redis服务器内网地址
spring.redis.port=6379
spring.redis.database=10
#spring.redis.password=    #默认是没有密码的

基于token实现分布式会话

修改usercontroller中的/login

//用户登陆服务,用来校验用户登陆是否合法
        UserModel userModel = userService.validateLogin(telphone,this.EncodeByMd5(password));
        //将登陆凭证加入到用户登陆成功的session内

        //修改成若用户登录验证成功后将对应的登录信息和登录凭证一起存入redis中

        //生成登录凭证token,UUID
        String uuidToken = UUID.randomUUID().toString();
        uuidToken = uuidToken.replace("-","");
        //建议token和用户登陆态之间的联系
        redisTemplate.opsForValue().set(uuidToken,userModel);  //通过RedisTempate可以操作springboot中内嵌的redis的bean
        redisTemplate.expire(uuidToken,1, TimeUnit.HOURS);

//        this.httpServletRequest.getSession().setAttribute("IS_LOGIN",true);
//        this.httpServletRequest.getSession().setAttribute("LOGIN_USER",userModel);

        //下发了token
        return CommonReturnType.create(uuidToken);

修改ordercontroller中的下单接口,

String token = httpServletRequest.getParameterMap().get("token")[0];
if(StringUtils.isEmpty(token)){
    throw new BusinessException(EmBusinessError.USER_NOT_LOGIN,"用户还未登陆,不能下单");
}
//获取用户的登陆信息
UserModel userModel = (UserModel) redisTemplate.opsForValue().get(token);
if(userModel == null){
    throw new BusinessException(EmBusinessError.USER_NOT_LOGIN,"用户还未登陆,不能下单");
}

将相关接口修改之后即可实现分布式会话。

四、查询性能优化

缓存设计:1.用快速存取设备,用内存处理 2.将缓存推到离用户最近的地方 3.脏缓存清理

多级缓存的几个策略:
1.redis缓存
2.JVM本地缓存
3.Nginx Proxy Cache
4.Nginx lua缓存

现在使用的是单机版的redis,弊端是redis容量问题,单点故障问题。除了单机模式,可以有sentianal的哨兵模式:

连接哪个redis全部由sentinal决定,下图sentinal通过心跳机制监测两台redis服务器,假设redis1挂掉,则启用redis2。redis2成为master,redis1成为slave。并通知jar发生了改变,get/set操作通过访问redis2进行。

image-20220304181038548

除了哨兵模式之外,集群cluster模式。没有集群模式之前:使用分片机制:

image-20220304181624194

客户端通过哨兵得知有两台redis master,通过哈希将数据路由到两台redis服务器上。根据哈希对相应redis进行get/set操作。这种分片方式导致数据迁移和客户端操作比较复杂。

cluster集群模式:

image-20220304182201740

集群中所有redis都有所有集群成员的关系表,客户端连接任意一个redis即可。假设redis1-4,4台服务器,其中redis3挂掉,则redis集群进行rehash保持数据同步以及数据分块,客户端自己会维护一个路由表,当redis集群发生改变,第一时间,客户端的路由表并未变化,所以会按照原来的方式进行访问,假如说访问redis2,这时候redis2会返回一个reask更新客户端中的路由表。

Jedis已经集成了这三种模式的管理。

缓存商品详情页接入

将商品信息首先在缓存中查询,如果查询不到则进入数据库。

//商品详情页浏览
@RequestMapping(value = "/get",method = {RequestMethod.GET})
@ResponseBody
public CommonReturnType getItem(@RequestParam(name = "id")Integer id){
    ItemModel itemModel = null;

    //先取本地缓存
    itemModel = (ItemModel) cacheService.getFromCommonCache("item_"+id);

    if(itemModel == null){
        //根据商品的id到redis内获取
        itemModel = (ItemModel) redisTemplate.opsForValue().get("item_"+id);

        //若redis内不存在对应的itemModel,则访问下游service
        if(itemModel == null){
            itemModel = itemService.getItemById(id);
            //设置itemModel到redis内
            redisTemplate.opsForValue().set("item_"+id,itemModel);
            redisTemplate.expire("item_"+id,10, TimeUnit.MINUTES);  //设置过期时间
        }
        //填充本地缓存
        cacheService.setCommonCache("item_"+id,itemModel);  //本地热点数据缓存,存在JVM中
    }


    ItemVO itemVO = convertVOFromModel(itemModel);

    return CommonReturnType.create(itemVO);

}

注意:这里的itemModel因为没有对应序列化方式,程序会报错,需要对itemModel和promoModel(item中包含)进行序列化。注意默认使用java序列化,redis中存储的key-value直接查询将是一组乱码。

为了在redis中查询的更方便直接,对redisTemplate进行配置:

@Component
@EnableRedisHttpSession(maxInactiveIntervalInSeconds = 3600)
public class RedisConfig {
    @Bean
    public RedisTemplate redisTemplate(RedisConnectionFactory redisConnectionFactory){
        RedisTemplate redisTemplate = new RedisTemplate();
        redisTemplate.setConnectionFactory(redisConnectionFactory);

        //首先解决key的序列化方式
        StringRedisSerializer stringRedisSerializer = new StringRedisSerializer();
        redisTemplate.setKeySerializer(stringRedisSerializer);

        //解决value的序列化方式
        Jackson2JsonRedisSerializer jackson2JsonRedisSerializer = new Jackson2JsonRedisSerializer(Object.class);

        ObjectMapper objectMapper =  new ObjectMapper();
        SimpleModule simpleModule = new SimpleModule();  //对序列化作定制
        simpleModule.addSerializer(DateTime.class,new JodaDateTimeJsonSerializer());
        simpleModule.addDeserializer(DateTime.class,new JodaDateTimeJsonDeserializer());

        objectMapper.enableDefaultTyping(ObjectMapper.DefaultTyping.NON_FINAL);//需要加上这行配置在redis中加入类信息,不然无法反序列化

        objectMapper.registerModule(simpleModule);

        jackson2JsonRedisSerializer.setObjectMapper(objectMapper);

        redisTemplate.setValueSerializer(jackson2JsonRedisSerializer);

        return redisTemplate;
    }
}

注意,itemmodel中含有DateTime属性(jodatime),因此需要单独对此序列化。因为redis默认对datetime的解读不友好。举例如下:

public class JodaDateTimeJsonSerializer extends JsonSerializer {
    @Override
    public void serialize(DateTime dateTime, JsonGenerator jsonGenerator, SerializerProvider serializerProvider) throws IOException {
        jsonGenerator.writeString(dateTime.toString("yyyy-MM-dd HH:mm:ss"));
    }
}

本地热点数据缓存(JVM内存)

满足:1.热点数据 2.脏读非常不敏感 3.内存可控

实际上是实现一个满足并发读写的HashMap结构,存储key-value在应用服务器上即可,但是缓存数据还需要设置失效时间。可利用Guava cache(可控制大小和超时时间,可配置LRU策略,线程安全)。

相应实现类:

@Service
public class CacheServiceImpl implements CacheService {

    private Cache commonCache = null;

    @PostConstruct
    public void init(){
        commonCache = CacheBuilder.newBuilder()
                //设置缓存容器的初始容量为10
                .initialCapacity(10)
                //设置缓存中最大可以存储100个KEY,超过100个之后会按照LRU的策略移除缓存项
                .maximumSize(100)
                //设置写缓存后多少秒过期
                .expireAfterWrite(60, TimeUnit.SECONDS).build();
    }

    @Override
    public void setCommonCache(String key, Object value) {
            commonCache.put(key,value);
    }

    @Override
    public Object getFromCommonCache(String key) {
        return commonCache.getIfPresent(key);
    }
}

Nginx Proxy Cache缓存(拓展)

在nginx.conf中两个地方配置:

proxy_cache_path /usr/local/openresty/nginx/tmp_cache levels=1:2 keys_zone=tmp_cache:100m inactive=7d max_size=10g;

//tmp_cache缓存存放文件夹,levels=1:2分子目录,tmp_cache内存空间:100兆大小,过期时间7天,最大大小10g

    proxy_cache tmp_cache;
    proxy_cache_key $uri;  //使用传递进来的uri作为key

    proxy_cache_valid 200 206 304 302 7d;

但是,nginx的缓存是存在文件磁盘中,io会限制缓存速度,所以这种方式较少使用

本文只对高并发性能优化作出以上方向的扩展,实际上还有很多种技术可以利用:静态资源CDN引入,对于交易模块的优化还有进行,这都是可以继续提高的一部分。