商城秒殺系統總結(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"?>
<!DOCTYPE generatorConfiguration
        PUBLIC "-//mybatis.org//DTD MyBatis Generator Configuration 1.0//EN"
        "http://mybatis.org/dtd/mybatis-generator-config_1_0.dtd">
<generatorConfiguration>

    <context id="DB2Tables"    targetRuntime="MyBatis3">
        <!--數據庫鏈接地址賬號密碼-->
        <jdbcConnection driverClass="com.mysql.jdbc.Driver" connectionURL="jdbc:mysql://127.0.0.1:3306/庫名" userId="sql_id" password="sql_password">
        </jdbcConnection>
        <!--生成DataObject類存放位置-->
        <javaModelGenerator targetPackage="com.imooc.miaoshaproject.dataobject" targetProject="src/main/java">
            <property name="enableSubPackages" value="true"/>
            <property name="trimStrings" value="true"/>
        </javaModelGenerator>
        <!--生成映射文件存放位置-->
        <sqlMapGenerator targetPackage="mapping" targetProject="src/main/resources">
            <property name="enableSubPackages" value="true"/>
        </sqlMapGenerator>
        <!--生成Dao類存放位置-->
        <!-- 客戶端代碼,生成易於使用的針對Model對象和XML配置文件 的代碼
                type="ANNOTATEDMAPPER",生成Java Model 和基於注解的Mapper對象
                type="MIXEDMAPPER",生成基於注解的Java Model 和相應的Mapper對象
                type="XMLMAPPER",生成SQLMap XML文件和獨立的Mapper接口
        -->
        <javaClientGenerator type="XMLMAPPER" targetPackage="com.imooc.miaoshaproject.dao" targetProject="src/main/java">
            <property name="enableSubPackages" value="true"/>
        </javaClientGenerator>

        <!--生成對應表及類名-->
        <!--
        <table tableName="user_info"  domainObjectName="UserDO" enableCountByExample="false"
        enableUpdateByExample="false" enableDeleteByExample="false"
        enableSelectByExample="false" selectByExampleQueryId="false"></table>
        <table tableName="user_password"  domainObjectName="UserPasswordDO" enableCountByExample="false"
               enableUpdateByExample="false" enableDeleteByExample="false"
               enableSelectByExample="false" selectByExampleQueryId="false"></table>
        -->
        <table tableName="promo"  domainObjectName="PromoDO" enableCountByExample="false"
               enableUpdateByExample="false" enableDeleteByExample="false"
               enableSelectByExample="false" selectByExampleQueryId="false"></table>
    </context>
</generatorConfiguration>

在使用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<String,Object> 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<String,Object> 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這些,一般邏輯為:首先<head> </head>中引入樣式和.js資源,然后在<body> </body>中通過調用"class"即可直接完成頁面的美化,在處理動態邏輯的時候,需要用ajax進行click等動作的判定,以及請求的發送。

對於前端我只了解一點點,可能說的不對,不過稍微理解概念后即可在模板上進行修修改改。

Java 8 stream api

在代碼中經常使用.stream()有利於簡化代碼結構,效率高一點,舉例:

//使用stream apiJ將list內的itemModel轉化為ITEMVO;
        List<ItemVO> 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<ConfigurableWebServerFactory> {
    @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上。

引入依賴:

<dependency>
 <groupId>org.springframework.boot</groupId>
 <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>

<dependency>
 <groupId>org.springframework.session</groupId>
 <artifactId>spring-session-data-redis</artifactId>
</dependency>

新建類:設置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<DateTime> {
    @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<String,Object> 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引入,對於交易模塊的優化還有進行,這都是可以繼續提高的一部分。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM