商城秒殺系統總結(Java)

GaoYuan206發表於2022-03-04

本文寫的較為零散,對沒有基礎的同學不太友好。

一、秒殺系統專案總結(基礎版)

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引入,對於交易模組的優化還有進行,這都是可以繼續提高的一部分。

相關文章