Springcloud 微服務 高並發(實戰1):第1版秒殺


瘋狂創客圈 Java 高並發【 億級流量聊天室實戰】實戰系列之15 【博客園總入口


前言

瘋狂創客圈(筆者尼恩創建的高並發研習社群)Springcloud 高並發系列文章,將為大家介紹三個版本的 高並發秒殺:

一、版本1 :springcloud + zookeeper 秒殺

二、版本2 :springcloud + redis 分布式鎖秒殺

三、版本3 :springcloud + Nginx + Lua 高性能版本秒殺

以及有關Springcloud 幾篇核心、重要的文章

一、Springcloud 配置, 史上最全 一文全懂

二、Springcloud 中 SpringBoot 配置全集 , 收藏版

三、Feign Ribbon Hystrix 三者關系 , 史上最全 深度解析

四、SpringCloud gateway 詳解 , 史上最全

本文:是**第一個版本 springcloud + zookeeper 秒殺 **實現,文章比較長,大家可以挑選感興趣的部分,選擇性閱讀。

本文的秒殺效果圖:

在這里插入圖片描述

提示: 本文內容,稍微有些陳舊,最新的源碼和最新內容,請關注高並發社群—— 瘋狂創客圈

1 為何要以秒殺做為高並發實戰案例?

時間調到在單體架構還是主流的年代,那時候,大家學習J2EE技術的綜合性實戰案例,一般來說,就是從0開始實現,一行一行代碼的,磊出來一個購物車應用。這個案例能對J2EE有一個全面的練習,包括前台的腳本、MVC框架、事務、數據庫等各個方法的技術。

時代在變,技術的復雜度變了,前后的分工也在變。

現在和以前不同了,現在已經進入到微服務的時代,前后台程序員已經有比較明確的分工,在前后台分離的團隊,后台程序員專門做Java開發,前台程序員專門做前台的開發。后台程序員可以不需要懂前台的技術如 Vue、TypeScript 等等,前台的程序員就更不一定需要懂后台技術了。

對於后台來說,現在的分布式開發場景,在技術難度上,要比單體服務時代大多了。首先面臨一大堆分布式、高性能中間件的學習,比如 Netty 、Zookeeper、RabbitMq、SpringCloud、Redis 等等。而且,在分布式環境下,要掌握如何發現解決數據一致性、高可靠性等問題,因為在高並發場景下,本來很正常的代碼,也會跑出很多的性能相關的問題,所以,像Jmeter這類壓力測試,也已經成為每一個后台程序員所必須掌握的工具。

所以,這里以秒殺程序作為實戰案例,簡單來說就是繼往開來。繼承單體架構時代的購物車應用的知識體系,開啟高並發時代的Netty 、Zookeeper、RabbitMq、SpringCloud、Redis、Jmeter等新技術體系的學習。

1.1 業務場景和特點

秒殺案例在生活中幾乎隨處可見:比如商品搶購,比如春運搶票,還是就是隨處可見的紅包也是類似的。

另外,在跳槽很頻繁的IT行業,大家都會有面試的准備要求。在面試中, 秒殺業務或者秒殺中所用到的分布式鎖、分布式ID、數據一致性、高並發限流等問題,一般都是成為重點題目和熱門題目,為面試官和應聘者津津樂道.

從下單的角度來說,秒殺業務非常簡單:根據先后順序,下訂單減庫存。

秒殺的特點:(1)瞬時大流量:秒殺時網站的面臨訪問量瞬時大增;(2)只有部分用戶能夠成功,秒殺時購買的請求數量遠遠大於庫存。

1.1.1 詳解:秒殺系統的業務流程

從系統角度來說,秒殺系統的業務流程如圖1所示,分成兩大維度:

(1)商戶維度的業務流程;

(2)用戶維度的業務流程。
在這里插入圖片描述

​ 圖1 秒殺系統的業務流程

一、商戶維度的業務流程,主要涉及兩個操作:

(1)增加秒殺

通過后台的管理界面,增加特定商品、特定數量、特定時段的秒殺。

(2)暴露秒殺

將符合條件的秒殺,暴露給用戶,以便互聯網用戶能參與商品的秒殺。這個操作可以是商戶手動完成,更合理的方式是系統自動維護。

二、用戶維度的業務流程,主要涉及兩個操作:

(1)減庫存

減少庫存,簡單說就是減少被秒殺到的商品的庫存數量,這也是秒殺系統中一個處理難點的地方。為什么呢? 這不僅僅需要考慮如何避免同一用戶重復秒殺的行為,而且在多個微服務並發情況下,需要保障庫存數據的一致性,避免超賣的情況發生。

(2)下訂單

減庫存后,需要下訂單,也就是在訂單表中添加訂單記錄,記錄購買用戶的姓名、手機號、購買的商品ID等。與減庫存相比,下訂單相對比較簡單。

特別說明下:為了聚焦高並發技術知識體系的學習,這里對秒殺的業務進行了餿身,去掉了一些其他的、但是也非常重要的功能,比如支付功能、提醒功能等等。

1.1.2 難點:秒殺系統面臨的技術難題

秒殺業務一般就是下訂單減庫存,流程比較簡單。那么,難點在哪里呢?

(1)秒殺一般是訪問請求數量遠遠大於庫存數量,只有少部分用戶能夠秒殺成功,這種場景下,需要借助分布式鎖等保障數據一致性。

(2)秒殺時大量用戶會在同一時間同時進行搶購,網站瞬時訪問流量激增。這就需要進行削峰和限流。

總體來說,秒殺系統面臨的技術難題,大致有如下幾點:

(1)限流:

鑒於只有少部分用戶能夠秒殺成功,所以要限制大部分流量,只允許少部分流量進入服務后端。

(2)削峰

對於秒殺系統瞬時會有大量用戶涌入,所以在搶購一開始會有很高的瞬間峰值。高峰值流量是壓垮系統很重要的原因,所以如何把瞬間的高流量變成一段時間平穩的流量也是設計秒殺系統很重要的思路。實現削峰的常用的方法有利用緩存和消息中間件等技術。

(3)異步處理

秒殺系統是一個高並發系統,采用異步處理模式可以極大地提高系統並發量,其實異步處理就是削峰的一種實現方式。

(4)內存緩存

秒殺系統最大的瓶頸一般都是數據庫讀寫,由於數據庫讀寫屬於磁盤IO,性能很低,如果能夠把部分數據或業務邏輯轉移到內存緩存,效率會有極大地提升。

(5)可拓展

秒殺系統,一定是可以彈性拓展。如果流量來了,可以按照流量預估,進行服務節點的動態增加和摘除。比如淘寶、京東等雙十一活動時,會增加大量機器應對交易高峰。

1.2 基於Zuul和Zookeeper的秒殺架構

從能力提供的角度來說,基於Zuul和Zookeeper的秒殺架構,大致如所示。
在這里插入圖片描述
​ 圖2 從能力提供的角度展示Zuul和Zookeeper的秒殺架構

在基於Zuul和Zookeeper的秒殺架構中,Zuul網關負責路由和限流,而Zookeeper 作為幕后英雄,提供分布式計數器、分布式鎖、分布式ID的生成器的基礎能力。

分布式計數器、分布式鎖、分布式ID的生成器等基礎的能力,也是大家所必須系統學習和掌握的知識,超出了這里介紹的范圍,如果對這一塊不了解,請翻閱尼恩所編著的另一本高並發基礎書籍《Netty、Zookeeper、Redis 高並發實戰》。

1.2.1 分層詳解:基於微服務的秒殺架構

從分層的角度來說,基於Zuul和Zookeeper的微服務秒殺系統,在架構上可以分成三層,如圖3所示:

(1)客戶端

(2)微服務接入層

(3)微服務業務層

一、客戶端的功能

(1)秒殺頁面靜態化展示:

在桌面瀏覽器、移動端APP展示秒殺的商品。不論在哪個屏幕展示,秒殺的活動元素,需要盡可能全部靜態化,並盡量減少動態元素。這樣,就可以通過CDN來抗峰值。

(2)禁止重復秒殺

用戶在客戶端操作過程中,客戶端需要具備用戶行為的控制能力。比如,在用戶提交秒殺之后,可以將用戶秒殺的按鈕置灰,禁止重復提交。

二、微服務接入層功能

(1)將請求攔截在系統上游,降低下游壓力

秒殺系統特點是並發量極大,但實際秒殺成功的請求數量卻很少,所以如果不在前端攔截很可能造成數據庫讀寫鎖沖突,甚至導致死鎖,最終請求超時。

攔截用戶的限流方式,有很多種。這里是秒殺的第一個版本,出於學習目的,本版僅僅介紹使用Zookeeper 的計數器能力進行限流,在后面的第二個秒殺版本,將會詳細介紹如何使用Redis+Lua進行更高效率的限流,在更加后面的第三個秒殺版本,將會詳細介紹使用Nginx+Lua 進行更加更加(兩個更加)高效率的限流。

(2)消息隊列削峰

上面只攔截了一部分訪問請求,當秒殺的用戶量很大時,即使每個用戶只有一個請求,到服務層的請求數量還是很大。比如我們有100W用戶同時搶100台手機,服務層並發請求壓力至少為100W。

使用消息隊列可以削峰,將為后台緩沖大量並發請求,這也是一個異步處理過程,后台業務根據自己的處理能力,從消息隊列中主動的拉取秒殺消息進行業務處理。

這個版本,不做消息隊列削峰的介紹。在更加后面的第三個秒殺版本,將會詳細介紹使用RabbitMq進行秒殺的削峰。

在這里插入圖片描述

​ 圖3 Zuul和Zookeeper的秒殺架構分層示意

三、微服務業務層功能

單體的秒殺服務,完成到達后台的秒殺下單的前台請求。然后,基於Springcloud的服務編排能力,進行多個單體服務的集群,使得整個系統具備可以動態擴展的能力。

其實,上面的圖中,沒有將數據庫層列出,因為這是眾所周知的。數據庫層,也是最脆弱的一層,數據庫層只承擔“能力范圍內”的訪問請求。所以,需要在上游的接入層、服務層引入隊列機制和緩存機制,讓最底層的數據庫高枕無憂。

1.2.2 簡介:總體的項目結構

分成兩個部分,介紹基於Zuul和Zookeeper的秒殺系統項目結構:

(1)Zuul網關與微服務基礎能力的項目結構

(2)秒殺服務的項目結構

一:Zuul網關與微服務基礎能力的項目結構

網關的路由能力,由Zuul和Eureka整合起來的微服務基礎框架Ribben提供;網關的限流能力,主要在Zuul的過濾器類 —— ZkRateLimitFilter類中提供。

Zuul網關與微服務基礎能力的項目結構如圖4所示,具體請參見源碼。
在這里插入圖片描述
​ 圖4 Zuul網關與微服務基礎能力的項目結構

二:秒殺微服務的項目結構

秒殺微服務是一個標准的SpringBoot項目,分成controller、service、dao三層,如圖5所示。,更加具體的項目結構學習,請參見源碼。
在這里插入圖片描述

​ 圖5 秒殺服務的項目結構

1.2.3 接入層:使用Zuul進行路由

前面詳細介紹Zuul的使用,這里不做大多的技術介紹。僅僅介紹一下,Zuul和seckill-provider秒殺服務的路由配置,具體如下:


#服務網關配置
zuul:
  ribbonIsolationStrategy: THREAD
  host:
    connect-timeout-millis: 60000
    socket-timeout-millis: 60000
  #路由規則
  routes:
#    user-service:
#      path: /user/**
#      serviceId: user-provider
    seckill-provider:
      path: /seckill-provider/**
      serviceId: seckill-provider
    message-provider:
      path: /message-provider/**
      serviceId: message-provider
    urlDemo:
      path: /user-provider/**
      url: http://127.0.0.1/user-provider

1.2.4 接入層:使用Zookeeper分布式計數器進行限流

理論上,接入層的限流有多個維度:

(1)用戶維度限流:

在某一時間段內只允許用戶提交一次請求,比如可以采取IP或者UserID限流。采取IP限流,可以攔截了瀏覽器訪問的請求,但針對某些惡意攻擊或其它插件,在接入層需要針對同一個訪問UserID,限制訪問頻率。

(2)商品維度的限流

對於同一個搶購,在某一時間段內只允許一定數量的請求進入,利用這種簡單的方式,防止后台的秒殺服務雪崩。

無論是那個維度的限流,掌握其中的一個,其他維度的限流,在技術實現上都是差不多的。這里,僅僅實現商品維度的限流,用戶維度限流,大家可以自己去實現。

這里,為了完成商品維度的限流,實現了一個Zuul的過濾器類 —— ZkRateLimitFilter類,通過對秒殺的請求 "/seckill-provider/api/seckill/do/v1" 進行攔截,然后通過Zookeeper計數器,對當前的參與商品的秒殺人數進行判斷,如果超出,則進行攔截。

ZkRateLimitFilter類的源碼如下:

package com.crazymaker.springcloud.cloud.center.zuul.filter;


import com.crazymaker.springcloud.common.distribute.rateLimit.RateLimitService;
import com.crazymaker.springcloud.seckill.contract.constant.SeckillConstants;
import com.netflix.zuul.ZuulFilter;
import com.netflix.zuul.context.RequestContext;
import lombok.extern.slf4j.Slf4j;
import org.apache.curator.framework.recipes.atomic.DistributedAtomicInteger;
import org.springframework.stereotype.Component;

import javax.annotation.Resource;
import javax.servlet.http.HttpServletRequest;

/**
 * zookeeper 秒殺限流
 */
@Slf4j
@Component
public class ZkRateLimitFilter extends ZuulFilter {

    @Resource(name="zkRateLimitServiceImpl")
    RateLimitService rateLimitService;

    @Override
    public String filterType() {
//		pre:路由之前
//		routing:路由之時
//		post: 路由之后
//		error:發送錯誤調用
        return "pre";
    }

    /**
     * 過濾的順序
     */
    @Override
    public int filterOrder() {
        return 0;
    }
    /**
     * 這里可以寫邏輯判斷,是否要過濾,true為永遠過濾。
     */
    @Override
    public boolean shouldFilter() {
        RequestContext ctx = RequestContext.getCurrentContext();
        HttpServletRequest request = ctx.getRequest();

        if(request.getRequestURI().startsWith("/seckill-provider/api/seckill/do/v1"))
        {
            return true;
        }

        return false;
    }

    /**
     * 過濾器的具體邏輯
     */
    @Override
    public Object run() {
        RequestContext ctx = RequestContext.getCurrentContext();
        HttpServletRequest request = ctx.getRequest();

        String goodId = request.getParameter("goodId");
        if (goodId != null) {

            DistributedAtomicInteger counter= rateLimitService.getZookeeperCounter(goodId);
            try {

                log.info( "參與搶購的人數:" + counter.get().preValue());
                if(counter.get().preValue()> SeckillConstants.MAX_ENTER)
                {
                    String msg="參與搶購的人太多,請稍后再試一試";
                    errorhandle(ctx, msg);
                    return null;
                }
            } catch (Exception e) {
                e.printStackTrace();

                String msg="計數異常,監控到商品是"+goodId;
                errorhandle(ctx, msg);
                return null;
            }

            return null;
        }else {

            String msg="必須輸入搶購的商品";
            errorhandle(ctx, msg);
            return null;
        }

    }


    /**
     * 統一的異常攔截
     */

    private void errorhandle(RequestContext ctx, String msg) {
        ctx.setSendZuulResponse(false);
        try {
            ctx.getResponse().setContentType("text/html;charset=utf-8");
            ctx.getResponse().getWriter().write(msg);
        } catch (Exception e) {
            e.printStackTrace();
        }
    }


}

1.2.5 數據層:數據表和PO實體設計

秒殺系統的表設計還是相對簡單清晰的,主要涉及兩張表:

(1)秒殺商品表

(2)訂單表

當然實際情況肯定不止這兩張表(比如付款相關表),出於學習技術的目的,這里我們只考慮秒殺系統的業務表,不考慮實際系統所涉及其他的表,而且,實際系統中,也不止表中的這些字段。

與商品表和訂單表相對應,有設計兩個PO實體類。啰嗦一下這里的系統命名規范,實體類統一使用PO后綴,傳輸類統一使用DTO后綴。這里的兩個PO類分別為:

(1)SeckillGoodPO 類,對應到秒殺商品表

(2)SeckillOrderPO 類,對應到訂單表

這里的兩個PO類,和兩個表,是嚴格的一一對應的。這種情況下,在基於JPA的實際開發中,習慣上常常可以基於PO類,逆向的生成數據庫的表。所以,這里就不對數據表的結構做展開說明,而是以PO類進行替代。

SeckillGoodPO類的代碼如下:

package com.crazymaker.springcloud.seckill.dao.po;

import com.fasterxml.jackson.annotation.JsonFormat;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.hibernate.annotations.GenericGenerator;
import org.springframework.format.annotation.DateTimeFormat;

import javax.persistence.*;
import java.io.Serializable;
import java.math.BigDecimal;
import java.util.Date;

/**
 * 秒殺商品PO
 * 說明: 秒殺商品表和主商品表不同
 *
 */

@Entity
@Table(name = "SECKILL_GOOD")
@Data
@AllArgsConstructor
@NoArgsConstructor
@Builder
public class SeckillGoodPO implements Serializable {

    //商品ID
    @Id
    @GenericGenerator(
            name = "SeckillGoodIdentityGenerator",
            strategy = "com.crazymaker.springcloud.seckill.idGenerator.SeckillGoodIdentityGenerator")
    @GeneratedValue(strategy = GenerationType.IDENTITY,generator = "SeckillGoodIdentityGenerator")
    @Column(name = "GOOD_ID", unique = true, nullable = false, length = 8)
    private Long id;

    //商品標題
    @Column(name = "GOOD_TITLE", length = 400)
    private String title;

    //商品標題
    @Column(name = "GOOD_IMAGE", length = 400)
    private String image;

    //商品原價格
    @Column(name = "GOOD_PRICE")
    private BigDecimal price;

    //商品秒殺價格
    @Column(name = "COST_PRICE")
    private BigDecimal costPrice;

    //創建時間
    @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
    @Column(name = "CREATE_TIME")
    private Date createTime;

    //秒殺開始時間
    @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
    @Column(name = "START_TIME")
    private Date startTime;

    //秒殺結束時間
    @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
    @Column(name = "END_TIME")
    private Date endTime;


    //剩余庫存數量
    @Column(name = "STOCK_COUNT")
    private long stockCount;


}

秒殺訂單PO類SeckillOrderPO的代碼如下:

package com.crazymaker.springcloud.seckill.dao.po;

import com.fasterxml.jackson.annotation.JsonFormat;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.hibernate.annotations.GenericGenerator;
import org.springframework.format.annotation.DateTimeFormat;

import javax.persistence.*;
import java.io.Serializable;
import java.math.BigDecimal;
import java.util.Date;

/**
 * 秒殺訂單PO  對應到 秒殺訂單表
 */

@Entity
@Table(name = "SECKILL_ORDER")
@Data
@AllArgsConstructor
@NoArgsConstructor
@Builder
public class SeckillOrderPO implements Serializable {

    //訂單ID
    @Id
    @GenericGenerator(
            name = "SeckillOrderIdentityGenerator",
            strategy = "com.crazymaker.springcloud.seckill.idGenerator.SeckillOrderIdentityGenerator")
    @GeneratedValue(strategy = GenerationType.IDENTITY, generator = "SeckillOrderIdentityGenerator")
    @Column(name = "ORDER_ID", unique = true, nullable = false, length = 8)
    private Long id;


    //支付金額
    @Column(name = "PAY_MONEY")
    private BigDecimal money;


    //秒殺用戶的用戶ID
    @Column(name = "USER_ID")
    private Long userId;

    //創建時間
    @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
    @Column(name = "CREATE_TIME")
    private Date createTime;


    //支付時間
    @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
    @Column(name = "PAY_TIME")
    private Date payTime;


    //秒殺商品,和訂單是一對多的關系
    @Column(name = "GOOD_ID")
    private Long goodId;

    //訂單狀態, -1:無效 0:成功 1:已付款
    @Column(name = "STATUS")
    private Short status ;

}

想要說明的是,這里的訂單SECKILL_ORDER表中的GOOD_ID商品ID字段,和商品表SECKILL_GOOD的GOOD_ID字段,是多對一的關系,但是,在建表的時候,不建議在數據庫層面使用外鍵關系,這種一對多的邏輯關系,建議在Java代碼中計算,而不是在數據庫維度解決。

為什么呢? 因為如果訂單量巨大,會存在分庫的可能,SECKILL_ORDER表和SECKILL_GOOD 表的相關聯的數據,可能保存在不同的數據庫中,數據庫層的關聯關系,可能會導致系統出現致命的問題。

1.2.6 數據層:使用分布式ID生成器

實際的開發中,很多的項目為了應付交付和追求速度,對於數據的ID,簡單粗暴的使用了Java的UUID。實際上,這種ID,項目初期會比較簡單,但是項目后期會導致性能上的問題,具體的原因,筆者在《Netty、Zookeeper、Redis高並發實戰》一書中,做了非常細致的總結。

這里使用主流的基於Zookeeper+Snowflake算法,高效率的生成Long類型的數據,並且在源碼中,分別為商品表和訂單表封裝了兩個Hibernate的定制化ID生成器。訂單表的Hibernate的定制化ID生成器類名稱為 SeckillOrderIdentityGenerator ,使用的具體代碼如下:

  //訂單ID
    @Id
    @GenericGenerator(
            name = "SeckillOrderIdentityGenerator",
            strategy = "com.crazymaker.springcloud.seckill.idGenerator.SeckillOrderIdentityGenerator")
    @GeneratedValue(strategy = GenerationType.IDENTITY, generator = "SeckillOrderIdentityGenerator")
    @Column(name = "ORDER_ID", unique = true, nullable = false, length = 8)
    private Long id;

SeckillOrderIdentityGenerator生成器類,繼承了Hibernate內置的自增式IncrementGenerator 生成器類,代碼如下:

package com.crazymaker.springcloud.seckill.idGenerator;
import com.crazymaker.springcloud.common.idGenerator.IdService;
import com.crazymaker.springcloud.standard.basicFacilities.CustomAppContext;
import org.hibernate.HibernateException;
import org.hibernate.engine.spi.SharedSessionContractImplementor;
import org.hibernate.id.IncrementGenerator;

import java.io.Serializable;

/**
 * hibernate 的自定義ID生成器
 */
public class SeckillOrderIdentityGenerator extends IncrementGenerator {
    /**
     * 生成ID
     */
    @Override
    public Serializable generate(SharedSessionContractImplementor sessionImplementor, Object object) throws HibernateException {
        Serializable id = null;
        /**
         * 調用自定義的snowflake 算法,結合Zookeeper 生成ID
         */
        IdService idService = (IdService) CustomAppContext.getBean("seckillOrderIdentityGenerator");
        if (null != idService) {
            id = idService.nextId();
            return id;
        }

        id = sessionImplementor.getEntityPersister(null, object)
                .getClassMetadata().getIdentifier(object, sessionImplementor);
        return id != null ? id : super.generate(sessionImplementor, object);
    }
}

SeckillOrderIdentityGenerator生成器類的generate方法中,通過自定義的一個生成ID的Spring bean,生產一個新的ID。這個bean的名稱為 seckillOrderIdentityGenerator,在自定義的配置文件中進行配置,代碼如下:

package com.crazymaker.springcloud.standard.config;

import com.crazymaker.springcloud.common.distribute.rateLimit.impl.ZkRateLimitServiceImpl;
import com.crazymaker.springcloud.common.distribute.rateLimit.RateLimitService;
import com.crazymaker.springcloud.common.distribute.idService.impl.SnowflakeIdGenerator;
import com.crazymaker.springcloud.common.distribute.lock.LockService;
import com.crazymaker.springcloud.common.distribute.lock.impl.ZkLockServiceImpl;
import com.crazymaker.springcloud.common.distribute.zookeeper.ZKClient;
import com.crazymaker.springcloud.common.idGenerator.IdService;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.DependsOn;

@Configuration
@ConditionalOnProperty(prefix = "zookeeper", name = "address")
public class ZookeeperDistributeConfig {
    @Value("${zookeeper.address}")
    private String zkAddress;

    /**
     * 自定義的ZK客戶端bean
     *
     * @return
     */
    @Bean(name = "zKClient")
    public ZKClient zKClient() {
        return new ZKClient(zkAddress);
    }

    /**
     * 獲取 ZK 限流器的 bean
     */
    @Bean
    @DependsOn("zKClient")
    public RateLimitService zkRateLimitServiceImpl() {
        return new ZkRateLimitServiceImpl();
    }

    /**
     * 獲取 ZK 分布式鎖的 bean
     */

    @Bean
    @DependsOn("zKClient")
    public LockService zkLockServiceImpl() {
        return new ZkLockServiceImpl();
    }


    /**
     * 獲取秒殺商品的分布式ID 生成器
     */
    @Bean
    @DependsOn("zKClient")
    public IdService seckillGoodIdentityGenerator() {
        return new SnowflakeIdGenerator("seckillGoodIdentityGenerator");
    }


    /**
     * 獲取秒殺訂單的分布式ID 生成器
     */
    @Bean
    @DependsOn("zKClient")
    public IdService seckillOrderIdentityGenerator() {
        return new SnowflakeIdGenerator("seckillOrderIdentityGenerator");
    }

}

可以看到,這里配置兩個ID生成器,一個對應到商品表、一個對應到訂單表。為啥需要配置兩個呢? 具體原因和Zookeeper分布式命名的機制有關,由於篇幅原因,這里不做贅述,請參考《Netty、Zookeeper、Redis高並發實戰》一書。

如果表的數據量比較多,可以進行生成器的優化,將多個生成器合並成一個,具體的優化工作,還請大家自己完成。

1.1 秒殺服務Controller控制層實現

本小節首先介紹API的接口設計,然后介紹其SeckillController 類的控制層實現邏輯。

1.1.1 Rest風格的API接口設計

SpringBoot 框架很早就支持開發REST資源,可以完美的支持Restful風格的API Url地址的解析。在SpringBoot 框架上,可以在Controller中定義這樣一個由動態的數據拼接組成的、而不是將所有的資源全部映射到一個路徑下的、動態的URL映射地址,比如:/{id}/detail 。

這種URL結構的優勢:我們能很容易從URL地址上判斷出該地址所展示的頁面是什么?比如:/good/1/detail就可能表示ID為1的商品的詳情頁,看起來設計的很清晰。

在Controller層,如果解析Url中的變量呢?可以在對應的映射方法上,添加@PathVariable注解,這個注解,填在對應的Java 參數的前面,如果:@PathVariable("id") Long id,就能將的Restful風格的API Url地址/good/{id}/detail中,{id}所指定的數據並賦值給這個id參數。

秒殺的Rest API 定義在SeckillController 類中,並且,可以通過Swagger UI的進行互動交互,秒殺的Rest API列表,如圖6所示。
在這里插入圖片描述

​ 圖6 秒殺的Rest API清單

1.1.2 Controller控制層方法定義

秒殺的控制層類叫做SeckillController 類,並且使用了@RestController注解標識的類,Spring會將其下的所有方法return的Java類型的數據都轉換成JSON格式,且不會被Spring視圖解析器掃描到,也就是此類下面的所有方法都不可能返回一個視圖頁面。啰嗦一句,@RestController注解只能用在類上,不能用在方法體上。

SeckillController 類的代碼如下:

package com.crazymaker.springcloud.seckill.controller;

import com.crazymaker.springcloud.common.page.PageReq;
import com.crazymaker.springcloud.common.page.PageView;
import com.crazymaker.springcloud.common.result.Result;
import com.crazymaker.springcloud.seckill.contract.dto.SeckillGoodDTO;
import com.crazymaker.springcloud.seckill.contract.dto.SeckillOrderDTO;
import com.crazymaker.springcloud.seckill.contract.dto.SimpleOrderDTO;
import com.crazymaker.springcloud.seckill.service.SeckillService;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import org.springframework.web.bind.annotation.*;

import javax.annotation.Resource;
import java.math.BigDecimal;


@RestController
@RequestMapping("/api/seckill/")
@Api(tags = "秒殺")
public class SeckillController {
    @Resource
    SeckillService seckillService;


    /**
     * 查詢商品信息
     *
     * @param goodId 商品id
     * @return 商品 dto
     */
    @GetMapping("/good/{id}/detail/v1")
    @ApiOperation(value = "查看商品信息")
    Result<SeckillGoodDTO> goodDetail(
            @PathVariable(value = "id") String goodId) {
        Result<SeckillGoodDTO> r = seckillService.findGoodByID(Long.valueOf(goodId));
        return r;
    }


    /**
     * 獲取所有的秒殺商品列表
     *
     * @param pageReq 當前頁 ,從1 開始,和 頁的元素個數
     * @return
     */
    @PostMapping("/list/v1")
    @ApiOperation(value = "獲取所有的秒殺商品列表")
    Result<PageView<SeckillGoodDTO>> findAll(@RequestBody PageReq pageReq) {
        PageView<SeckillGoodDTO> page = seckillService.findAll(pageReq);
        Result<PageView<SeckillGoodDTO>> r = Result.success(page);
        return r;

    }


    /**
     * 秒殺開始時輸出暴露秒殺的地址
     * 否者輸出系統時間和秒殺時間
     *
     * @param gooId 商品id
     */
    @GetMapping("/good/expose/v1")
    @ApiOperation(value = "暴露秒殺商品")
    Result<SeckillGoodDTO> exposeSeckillGood(
            @RequestParam(value = "goodId", required = true) long gooId) {

        Result<SeckillGoodDTO> r = seckillService.exposeSeckillGood(gooId);
        return r;

    }

    /**
     * 執行秒殺的操作
     *
     * @param goodId 商品id
     * @param money  錢
     * @param userId 用戶id
     * @param md5    校驗碼
     * @return
     */
    @ApiOperation(value = "秒殺")
    @GetMapping("/do/v1")
    Result<SeckillOrderDTO> executeSeckill(
            @RequestParam(value = "goodId", required = true) long goodId,
            @RequestParam(value = "money", required = true) BigDecimal money,
            @RequestParam(value = "userId", required = true) long userId,
            @RequestParam(value = "md5", required = true) String md5) {
        Result<SeckillOrderDTO> r = seckillService.executeSeckillV1(goodId, money, userId, md5);
        return r;
    }

    /**
     * 執行秒殺的操作
     *
     * @param goodId 商品id
     * @param money  錢
     * @param userId 用戶id
     * @param md5    校驗碼
     * @return
     */
    @ApiOperation(value = "秒殺")
    @GetMapping("/do/v2")
    Result<SeckillOrderDTO> executeSeckillV2(
            @RequestParam(value = "goodId", required = true) long goodId,
            @RequestParam(value = "money", required = true) BigDecimal money,
            @RequestParam(value = "userId", required = true) long userId,
            @RequestParam(value = "md5", required = true) String md5) {
        SimpleOrderDTO inDto = new SimpleOrderDTO();
        inDto.setGoodId(goodId);
        inDto.setMd5(md5);
        inDto.setUserId(userId);
        SeckillOrderDTO dto = seckillService.executeSeckillV2(inDto);
        return Result.success(dto).setMsg("秒殺成功");

    }

    /**
     * 增加秒殺的商品
     *
     * @param stockCount 庫存
     * @param title      標題
     * @param price      商品原價格
     * @param costPrice  價格
     * @return
     */
    @GetMapping("/good/add/v1")
    @ApiOperation(value = "增加秒殺的商品")
    Result<SeckillGoodDTO> executeSeckill(
            @RequestParam(value = "stockCount", required = true) long stockCount,
            @RequestParam(value = "title", required = true) String title,
            @RequestParam(value = "price", required = true) BigDecimal price,
            @RequestParam(value = "costPrice", required = true) BigDecimal costPrice) {
        Result<SeckillGoodDTO> r = seckillService.addSeckillGood(stockCount, title, price, costPrice);
        return r;
    }

    /**
     * 保存秒殺到緩存
     */
    @GetMapping("/good/cache/v1")
    @ApiOperation(value = "保存秒殺到緩存")
    public Result<Integer> loadSeckillToCache() {
        Result<Integer> r = seckillService.loadSeckillToCache();
        return r;
    }
}

1.1.3 Result 類是什么?

為Controller層能返回格式一致的JSON結果數據,這里,手動創建了Result 類類來封裝一些通用的結果信息,比如status狀態碼、比如msg文本消息。Result 類是一個泛型類,真正的返回結果,封裝在data成員中。

Result 類的代碼如下:

package com.crazymaker.springcloud.seckill.controller;

import com.crazymaker.springcloud.common.page.PageReq;
import com.crazymaker.springcloud.common.page.PageView;
import com.crazymaker.springcloud.common.result.Result;
import com.crazymaker.springcloud.seckill.contract.dto.SeckillGoodDTO;
import com.crazymaker.springcloud.seckill.contract.dto.SeckillOrderDTO;
import com.crazymaker.springcloud.seckill.contract.dto.SimpleOrderDTO;
import com.crazymaker.springcloud.seckill.service.SeckillService;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import org.springframework.web.bind.annotation.*;

import javax.annotation.Resource;
import java.math.BigDecimal;


@RestController
@RequestMapping("/api/seckill/")
@Api(tags = "秒殺")
public class SeckillController {
    @Resource
    SeckillService seckillService;


    /**
     * 查詢商品信息
     *
     * @param goodId 商品id
     * @return 商品 dto
     */
    @GetMapping("/good/{id}/detail/v1")
    @ApiOperation(value = "查看商品信息")
    Result<SeckillGoodDTO> goodDetail(
            @PathVariable(value = "id") String goodId) {
        Result<SeckillGoodDTO> r = seckillService.findGoodByID(Long.valueOf(goodId));
        return r;
    }


    /**
     * 獲取所有的秒殺商品列表
     *
     * @param pageReq 當前頁 ,從1 開始,和 頁的元素個數
     * @return
     */
    @PostMapping("/list/v1")
    @ApiOperation(value = "獲取所有的秒殺商品列表")
    Result<PageView<SeckillGoodDTO>> findAll(@RequestBody PageReq pageReq) {
        PageView<SeckillGoodDTO> page = seckillService.findAll(pageReq);
        Result<PageView<SeckillGoodDTO>> r = Result.success(page);
        return r;

    }


    /**
     * 秒殺開始時輸出暴露秒殺的地址
     * 否者輸出系統時間和秒殺時間
     *
     * @param gooId 商品id
     */
    @GetMapping("/good/expose/v1")
    @ApiOperation(value = "暴露秒殺商品")
    Result<SeckillGoodDTO> exposeSeckillGood(
            @RequestParam(value = "goodId", required = true) long gooId) {

        Result<SeckillGoodDTO> r = seckillService.exposeSeckillGood(gooId);
        return r;

    }

    /**
     * 執行秒殺的操作
     *
     * @param goodId 商品id
     * @param money  錢
     * @param userId 用戶id
     * @param md5    校驗碼
     * @return
     */
    @ApiOperation(value = "秒殺")
    @GetMapping("/do/v1")
    Result<SeckillOrderDTO> executeSeckill(
            @RequestParam(value = "goodId", required = true) long goodId,
            @RequestParam(value = "money", required = true) BigDecimal money,
            @RequestParam(value = "userId", required = true) long userId,
            @RequestParam(value = "md5", required = true) String md5) {
        Result<SeckillOrderDTO> r = seckillService.executeSeckillV1(goodId, money, userId, md5);
        return r;
    }

    /**
     * 執行秒殺的操作
     *
     * @param goodId 商品id
     * @param money  錢
     * @param userId 用戶id
     * @param md5    校驗碼
     * @return
     */
    @ApiOperation(value = "秒殺")
    @GetMapping("/do/v2")
    Result<SeckillOrderDTO> executeSeckillV2(
            @RequestParam(value = "goodId", required = true) long goodId,
            @RequestParam(value = "money", required = true) BigDecimal money,
            @RequestParam(value = "userId", required = true) long userId,
            @RequestParam(value = "md5", required = true) String md5) {
        SimpleOrderDTO inDto = new SimpleOrderDTO();
        inDto.setGoodId(goodId);
        inDto.setMd5(md5);
        inDto.setUserId(userId);
        SeckillOrderDTO dto = seckillService.executeSeckillV2(inDto);
        return Result.success(dto).setMsg("秒殺成功");

    }

    /**
     * 增加秒殺的商品
     *
     * @param stockCount 庫存
     * @param title      標題
     * @param price      商品原價格
     * @param costPrice  價格
     * @return
     */
    @GetMapping("/good/add/v1")
    @ApiOperation(value = "增加秒殺的商品")
    Result<SeckillGoodDTO> executeSeckill(
            @RequestParam(value = "stockCount", required = true) long stockCount,
            @RequestParam(value = "title", required = true) String title,
            @RequestParam(value = "price", required = true) BigDecimal price,
            @RequestParam(value = "costPrice", required = true) BigDecimal costPrice) {
        Result<SeckillGoodDTO> r = seckillService.addSeckillGood(stockCount, title, price, costPrice);
        return r;
    }

    /**
     * 保存秒殺到緩存
     */
    @GetMapping("/good/cache/v1")
    @ApiOperation(value = "保存秒殺到緩存")
    public Result<Integer> loadSeckillToCache() {
        Result<Integer> r = seckillService.loadSeckillToCache();
        return r;
    }
}

1.2 秒殺服務Service層的實現

開始着手編寫業務層接口,然后編寫業務層接口的實現類並編寫業務層的核心邏輯。

1.2.1 SeckillService 秒殺服務接口定義

設計業務層接口,應該站在使用者角度上設計,如我們應該做到:

1.定義業務方法的顆粒度要細。

2.方法的參數要明確簡練,不建議使用類似Map這種類型,讓使用者可以封裝進Map中一堆參數而傳遞進來,盡量精確到哪些參數。

3.方法的return返回值,除了應該明確返回值類型,還應該指明方法執行可能產生的異常(RuntimeException),並應該手動封裝一些通用的異常處理機制。

SeckillService秒殺接口的定義如下:

package com.crazymaker.springcloud.seckill.service;

import com.crazymaker.springcloud.common.page.PageReq;
import com.crazymaker.springcloud.common.page.PageView;
import com.crazymaker.springcloud.common.result.Result;
import com.crazymaker.springcloud.seckill.contract.dto.SeckillGoodDTO;
import com.crazymaker.springcloud.seckill.contract.dto.SeckillOrderDTO;
import com.crazymaker.springcloud.seckill.contract.dto.SimpleOrderDTO;

import java.math.BigDecimal;

/**
 * 秒殺接口
 */
public interface SeckillService
{
    /**
     * 查詢商品信息
     * @param id  商品id
     * @return  商品 dto
     */
    Result<SeckillGoodDTO> findGoodByID(Long id);



    /**
     * 獲取所有的秒殺商品列表
     *
     * @return
     * @param pageReq  當前頁 ,從1 開始,和 頁的元素個數
     */
    PageView<SeckillGoodDTO> findAll(PageReq pageReq);



    /**
     * 秒殺開始時輸出暴露秒殺的地址
     * 否者輸出系統時間和秒殺時間
     *
     * @param gooId  商品id
     */
    Result<SeckillGoodDTO> exposeSeckillGood(long gooId);

    /**
     * 執行秒殺的操作
     *  @param goodId 商品id
     * @param money  錢
     * @param userId  用戶id
     * @param md5  校驗碼
     * @return
     */
    Result<SeckillOrderDTO> executeSeckillV1(
            long goodId,
            BigDecimal money,
            long userId,
            String md5);

    SeckillOrderDTO executeSeckillV2(SimpleOrderDTO inDto);
    SeckillOrderDTO executeSeckillV3(SimpleOrderDTO inDto);

    /**
     * 增加秒殺的商品
     *
     * @param stockCount  庫存
     * @param title  標題
     * @param price   商品原價格
     * @param costPrice   價格
     * @return
     */
    Result<SeckillGoodDTO> addSeckillGood(
            long stockCount,
            String title,
            BigDecimal price,
            BigDecimal costPrice);

    /**
     * 保存秒殺到緩存
     *
     */
    Result<Integer> loadSeckillToCache();
}

1.2.2 findGoodByID和findAll方法

首先看最簡單的兩個方法:findGoodByID和findAll方法。

findById(): 顧名思義根據ID主鍵查詢。按照接口的設計,我們需要指定參數是秒殺商品的ID值。返回值是查詢到的秒殺商品的SeckillGoodDTO的包裝類Result 類。

findGoodByID()方法的源碼如下:


    @Override
    public Result<SeckillGoodDTO> findGoodByID(Long id) {

        Optional<SeckillGoodPO> optional = seckillGoodDao.findById(id);

        if (optional.isPresent()) {
            SeckillGoodDTO dto = new SeckillGoodDTO();
            BeanUtils.copyProperties(optional.get(), dto);
            return Result.success(dto).setMsg("查找成功");
        }
        return Result.error("未找到指定秒殺商品");

    }

findAll(): 顧名思義是查詢數據庫中所有的秒殺商品表的數據,因為記錄數不止一條,所以一般就用List集合接收,並制定泛型是List ,表示從數據庫中查詢到的列表數據都是Seckill實體類對應的數據,並以Seckill實體類的結構將列表數據封裝到List集合中。

findAll()的源碼如下:

  /**
     * 獲取所有的秒殺商品列表
     *
     * @param pageReq 當前頁 ,從1 開始,和 頁的元素個數
     * @return
     */
    @Override
    public PageView<SeckillGoodDTO> findAll(PageReq pageReq) {
        Specification<SeckillGoodPO> specification = getSeckillGoodPOSpecification();

        Page<SeckillGoodPO> page = seckillGoodDao.findAll(specification, PageRequest.of(pageReq.getJpaPage(), pageReq.getPageSize()));

        PageView<SeckillGoodDTO> pageView = PageAdapter.adapter(page, SeckillGoodDTO.class);

        return pageView;

    }

1.2.3 秒殺暴露實現:exportSeckillUrl方法

這里有兩個問題:(1)什么是秒殺暴露呢?(2)為什么要進行秒殺暴露呢?

首先看第一個問題:什么是秒殺暴露呢?很簡單,就是根據該商品的ID,獲取到這個商品的秒殺MD5字符串。

再來看第二個問題:為什么要進行秒殺暴露呢?

目的之一就是保證公平,防止刷單。

秒殺系統中,同一件商品,比如瞬間有十萬的用戶訪問,而還存在各種黃牛,有各種工具去搶購這個商品,那么此時肯定不止10萬的訪問量的,並且開發者要盡量的保證每個用戶搶購的公平性,也就是不能讓一個用戶搶購一堆數量的此商品。

如何防止刷單呢?就是生成驗證字符串,比如MD5字符串。並且驗證字符串可以包含要進行防止刷單驗證的各種信息,比如商品ID、比如用戶ID,這樣同一用戶只能有唯一的一個MD5字符串,不同用戶間不同的,就沒有辦法通過其他人的鏈接,進行商品的刷單了。

 /**
     * 秒殺暴露
     * @param gooId  商品id
     * @return 暴露的秒殺商品
     */
    @Override
    public Result<SeckillGoodDTO> exposeSeckillGood(long gooId) {
        Optional<SeckillGoodPO> optional = seckillGoodDao.findById(gooId);
        if (!optional.isPresent()) {
            //秒殺不存在
            throw BizException.builder().errMsg("秒殺不存在").build();
        }
        SeckillGoodPO goodPO = optional.get();

        Date startTime = goodPO.getStartTime();
        Date endTime = goodPO.getEndTime();
        //獲取系統時間
        Date nowTime = new Date();
        if (nowTime.getTime() < startTime.getTime()) {
            //秒殺不存在
            throw BizException.builder().errMsg("秒殺沒有開始").build();
        }

        if (nowTime.getTime() > endTime.getTime()) {
            //秒殺已經結束
            throw BizException.builder().errMsg("秒殺已經結束").build();
        }
        //轉換特定字符串的過程,不可逆的算法
        String md5 = Encrypt.getMD5(String.valueOf(gooId));

        SeckillGoodDTO dto = new SeckillGoodDTO();
        BeanUtils.copyProperties(goodPO, dto);
        dto.setMd5(md5);
        dto.setExposed(true);
        return Result.success(dto).setMsg("暴露成功");
    }

exposeSeckillGood ()的主要邏輯:根據傳進來的goodId商品ID,查詢對應的秒殺商品數據,如果沒有查詢到,可能是用戶非法輸入的數據;如果查詢到了,就獲取秒殺開始時間和秒殺結束時間,以及進行判斷當前秒殺商品是否正在進行秒殺活動,還沒有開始或已經結束都直接拋出業務異常;如果上面兩個條件都符合了就證明該商品存在且正在秒殺活動中,那么我們需要暴露秒殺商品。

暴露秒殺商品的主要內容,就是生成一串md5值作為返回數據的一部分。而Spring提供了一個工具類DigestUtils用於生成MD5值,且又由於要做到更安全所以我們采用md5+鹽的加密方式,將需要加密的信息作為鹽,生成一傳md5加密數據作為秒殺MD5校驗字符串。

1.2.4 分布式秒殺控制:executeSeckill 方法

秒殺的核心業務邏輯,很簡單、很清晰,就是兩點:1.減庫存;2.儲存用戶秒殺訂單明細。針但是其中涉及到很多分布式控制、數據庫事務、秒殺安全驗證等問題。這里我們將秒殺分成兩個方法:

(1)分布式秒殺控制:executeSeckill 方法;

(2)執行秒殺的操作:doSeckill(order)方法。

分布式秒殺控制executeSeckill 方法的流程如圖7所示。
在這里插入圖片描述
​ 圖7 分布式秒殺控制executeSeckill 方法的流程圖

分布式秒殺控制executeSeckill 方法的代碼如下:

  /**
     * 秒殺的分布式控制
     * Spring默認只對運行期異常進行事務的回滾操作
     * 對於受檢異常Spring是不進行回滾的
     * 所以對於需要進行事務控制的方法盡可能將可能拋出的異常都轉換成運行期異常
     *
     * @param goodId 商品id
     * @param money  錢
     * @param userId 用戶id
     * @param md5    校驗碼
     * @return
     */
    @Override
    public Result<SeckillOrderDTO> executeSeckillV1(
            long goodId, BigDecimal money, long userId, String md5) {
        if (md5 == null || !md5.equals(Encrypt.getMD5(String.valueOf(goodId)))) {
            throw BizException.builder().errMsg("秒殺的鏈接被重寫過了").build();
        }

        /**
         * Zookeeper 限流計數器 增加數量
         */
        DistributedAtomicInteger counter =
                zkRateLimitServiceImpl.getZookeeperCounter(String.valueOf(goodId));
        try {
            counter.increment();
        } catch (Exception e) {
            e.printStackTrace();
            //秒殺異常
            throw BizException.builder().errMsg("秒殺異常").build();

        }

        /**
         * 創建訂單對象
         */
        SeckillOrderPO order =
                SeckillOrderPO.builder().goodId(goodId).userId(userId).build();


        //執行秒殺邏輯:1.減庫存;2.儲存秒殺訂單
        Date nowTime = new Date();
        order.setCreateTime(nowTime);
        order.setMoney(money);
        order.setStatus(SeckillConstants.ORDER_VALID);


        /**
         * 創建分布式鎖
         */
        InterProcessMutex lock =
                lockService.getZookeeperLock(String.valueOf(goodId));

        try {
            /**
             * 獲取分布式鎖
             */
            lock.acquire(1, TimeUnit.SECONDS);
            /**
             * 執行秒殺,帶事務
             */
            doSeckill(order);
        } catch (Exception e) {
            e.printStackTrace();
        } finally {
            try {
                /**
                 * 釋放分布式鎖
                 */
                lock.release();
            } catch (Exception e) {
                log.error(e.getMessage());
            }
        }


        SeckillOrderDTO dto = new SeckillOrderDTO();
        BeanUtils.copyProperties(order, dto);

        //Zookeeper 限流計數器  減少流量計算
        try {
            counter.decrement();
        } catch (Exception e) {
            e.printStackTrace();
            //秒殺異常
            throw BizException.builder().errMsg("秒殺異常").build();

        }
        return Result.success(dto).setMsg("秒殺成功");

    }

1.2.5 秒殺執行:doSeckill(order)方法

doSeckill簡單一些,主要涉及兩個業務操作:1.減庫存;2.記錄訂單明細。但是,在執行前,需要進行數據的驗證,以防止超賣等不合理的現象發生。

doSeckill(order)方法的流程如圖8所示。
在這里插入圖片描述
​ 圖8 doSeckill(order)流程圖

doSeckill(order)方法的代碼如下所示:


    @Transactional
    public void doSeckill(SeckillOrderPO order) {
        /**
         * 創建重復性檢查的訂單對象
         */
        SeckillOrderPO checkOrder =
                SeckillOrderPO.builder().goodId(order.getGoodId()).userId(order.getUserId()).build();

        //記錄秒殺訂單信息
        long insertCount = seckillOrderDao.count(Example.of(checkOrder));

        //唯一性判斷:goodId,userId 保證一個用戶只能秒殺一件商品
        if (insertCount >= 1) {
            //重復秒殺
            log.error("重復秒殺");
            throw BizException.builder().errMsg("重復秒殺").build();
        }


        Optional<SeckillGoodPO> optional = seckillGoodDao.findById(order.getGoodId());
        if (!optional.isPresent()) {
            //秒殺不存在
            throw BizException.builder().errMsg("秒殺不存在").build();
        }


        //查詢庫存
        SeckillGoodPO good = optional.get();
        if (good.getStockCount() <= 0) {
            //重復秒殺
            throw BizException.builder().errMsg("秒殺商品被搶光").build();
        }

        order.setMoney(good.getCostPrice());

        seckillOrderDao.save(order);

        //減庫存

        seckillGoodDao.updateStockCountById(order.getGoodId());
    }

1.2.6 BizException 業務異常定義

減庫存操作和插入購買明細操作都會產生很多未知異常(RuntimeException),比如秒殺結束、重復秒殺等。除了要返回這些異常信息,還有一個非常重要的操作就是捕獲這些RuntimeException,從而避免系統直接報錯。

針對秒殺可能出現的各種業務異常,這里定義了一個自己的異常類 BizException類,代碼如下:

package com.crazymaker.springcloud.common.exception;

import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;

@AllArgsConstructor
@Builder
@Data
public class BizException extends RuntimeException {
    private static final long serialVersionUID = 1L;

    /**
     * 默認的錯誤編碼
     */
    private static final int DEFAULT_BIZ_ERR_CODE = -1;


    private static final String DEFAULT_ERR_MSG = "系統錯誤";


    /**
     * 業務錯誤編碼
     */
    private int bizErrCode = DEFAULT_BIZ_ERR_CODE;

    /**
     * 錯誤的提示信息
     */
    private String errMsg = DEFAULT_ERR_MSG;

}

特別注意一下,此類繼承了 RuntimeException 運行時異常類,而不是Exception受檢異常基類,表明BizException類其實一個非受檢的運行時異常類。為什么要這樣呢? 因為默認情況下,SpringBoot 事務只有檢查到RuntimeException類型的異常才會回滾,如果檢查到的是受檢異常,SpringBoot 事務是不會回滾的,除非經過特殊配置。

1.2.7 Zookeeper 分布式鎖應用

分布式秒殺控制executeSeckill 方法中,用到了Zookeeper分布式鎖,這里簡單說明一下分布式鎖特點:

(1)排他性:同一時間,只有一個線程能獲得;

(2)阻塞性:其它未搶到的線程阻塞等待,直到鎖被釋放,再繼續搶;

(3)可重入性:線程獲得鎖后,后續是否可重復獲取該鎖(避免死鎖)。

Zookeeper的分布式鎖與Redis的分布式鎖、數據庫鎖相比,簡單來說有以下優勢:

(1)Zookeeper 一般是多節點集群部署,性能比較高;而使用數據庫鎖會有單機性能瓶頸問題。

(2)Zookeeper分布式鎖可靠性比Redis好,實現相對簡單。當然,由於需要創建節點、刪除節點等,效率比Redis肯定要低。

分布式秒殺控制executeSeckill 方法中,只有成功搶占了分布式鎖,才能進入執行實際秒殺的doSeckill()方法。即時部署了多個秒殺的微服務,也能保證,同一時刻,只有一個微服務進行實際的秒殺,具體如圖9所示。
在這里插入圖片描述

​ 圖9 秒殺的分布式鎖示意圖

在《Netty、Zookeeper、Redis高並發實戰》一書中,詳細介紹了關於分布式鎖的知識,以及如何通過Curator API實現自己的Zookeeper分布式鎖。這里不再對分布式鎖的實現,進行贅述。

這里僅僅介紹一下,如果在SpringBoot程序中,如何獲取分布式鎖。代碼如下:

package com.crazymaker.springcloud.common.distribute.lock.impl;

import com.crazymaker.springcloud.common.distribute.lock.LockService;
import com.crazymaker.springcloud.common.distribute.zookeeper.ZKClient;
import org.apache.curator.framework.CuratorFramework;
import org.apache.curator.framework.recipes.locks.InterProcessMutex;

import java.util.LinkedHashMap;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;

public class ZkLockServiceImpl implements LockService {

    Map<String, InterProcessMutex> lockMap = new ConcurrentHashMap<>();


    /**
     * 取得ZK 的分布式鎖
     * @param key  鎖的key
     * @return   ZK 的分布式鎖
     */
    public InterProcessMutex getZookeeperLock(String key) {
        CuratorFramework client = ZKClient.getSingleton().getClient();
        InterProcessMutex lock = lockMap.get(key);
        if (null == lock) {
            lock = new InterProcessMutex(client, "/mutex/seckill/" + key  );
            lockMap.put(key, lock);
        }
        return lock;
    }

}

1.3 高並發測試

1.3.1 啟動微服務和秒殺服務

首先需要啟動Eureka服務注冊和發現應用,然后啟動SpringCloud Config服務,最后啟動秒殺服務Seckill-provider。不過,為了提高並發能力,這里直接啟動了兩個Seckill-provider服務,具體如圖10所示。說明下:服務名稱不區分大小寫,圖中的服務名稱,統一進行了大寫的展示。

在這里插入圖片描述

​ 圖10 秒殺的服務清單示意圖

圖10中的message-provider消息服務,在當前的秒殺版本中,並沒有用到。但是,在秒殺的第三個實現版本中有用到,后續會詳細介紹。

1.3.2 使用Jmeter進行高並發測試

啟動完微服務后,可以啟動Jmeter,配置完參數后,開始進行壓力測試。

在這里插入圖片描述

1.3.3 高並發過程中遇到的問題

一些潛在問題,在用戶量少的場景,往往都是發現不了,而一旦進行壓力測試,就會蹦出來了。比如說,下面這個連接池的連接數不夠的問題,具體如下:

Caused by: com.alibaba.druid.pool.GetConnectionTimeoutException: wait millis 60000, active 20, maxActive 20, creating 0
        at com.alibaba.druid.pool.DruidDataSource.getConnectionInternal(DruidDataSource.java:1512)
        at com.alibaba.druid.pool.DruidDataSource.getConnectionDirect(DruidDataSource.java:1255)
        at com.alibaba.druid.filter.FilterChainImpl.dataSource_connect(FilterChainImpl.java:5007)
        at com.alibaba.druid.filter.stat.StatFilter.dataSource_getConnection(StatFilter.java:680)
        at com.alibaba.druid.filter.FilterChainImpl.dataSource_connect(FilterChainImpl.java:5003)
        at com.alibaba.druid.pool.DruidDataSource.getConnection(DruidDataSource.java:1233)
        at com.alibaba.druid.pool.DruidDataSource.getConnection(DruidDataSource.java:1225)
        at com.alibaba.druid.pool.DruidDataSource.getConnection(DruidDataSource.java:90)
        at org.hibernate.engine.jdbc.connections.internal.DatasourceConnectionProviderImpl.getConnection(DatasourceConnectionProviderImpl.java:122)
        at org.hibernate.internal.NonContextualJdbcConnectionAccess.obtainConnection(NonContextualJdbcConnectionAccess.java:35)
        at org.hibernate.resource.jdbc.internal.LogicalConnectionManagedImpl.acquireConnectionIfNeeded(LogicalConnectionManagedImpl.java:106)
        ... 118 common frames omitted

很顯然,是Druid數據庫連接池中的連接數不夠,查看代碼,發現之前的數據池配置如下:

spring:
  datasource:
    driverClassName: com.mysql.jdbc.Driver
#    driverClassName: oracle.jdbc.driver.OracleDriver
    druid:
      initial-size: 5
      max-active: 20
      max-wait: 60000
      min-evictable-idle-time-millis: 300000
      min-idle: 5
      test-on-borrow: false
      test-on-return: false
      test-while-idle: true
      time-between-eviction-runs-millis: 60000
      validation-query: SELECT 1 FROM DUAL
      ……..

max-active的值為20,表示池中最多20個連接,很顯然,這個值太小。適當的增加連接池的最大連接數限制,這里從20修改到200。生產場景中,這個數據要依據實際的最大連接數預估值去確定。修改完成后的數據庫連接池的配置如下:


spring:
  datasource:
    driverClassName: com.mysql.cj.jdbc.Driver
#    driverClassName: com.mysql.jdbc.Driver
#    driverClassName: oracle.jdbc.driver.OracleDriver
    druid:
      initial-size: 20
      max-active: 200
      max-wait: 60000
      min-evictable-idle-time-millis: 300000
      min-idle: 5
      test-on-borrow: false
      test-on-return: false
      test-while-idle: true
      time-between-eviction-runs-millis: 60000
      validation-query: SELECT 1 FROM DUAL
    password: root
    username: root
   ……..

再一次啟動秒殺微服務,后續的並發測試中,沒有出現過連接數不夠的異常。說明問題已經接近。

1.3.4 Zuul+Zookeeper秒殺性能分析

之前也提到過,Zookeeper本身的性能不是太高,所以,對測試的結果預期也不高。下面是並發測試的結果,可以看到,在50並發的場景下,單次秒殺的平均響應時間,已經到了17s。

在這里插入圖片描述

Zookeeper本身的並發性能不是太高,不是說Zookeeper沒有用,僅僅是適用領域不同。在分布式ID,分布式集群協調等領域,Zookeeper的作用是非常巨大的,這是Redis等緩存工具,沒法替代的和比擬的。

好了,至此一個版本的秒殺,已經介紹完畢。后面會介紹第二個版本、第三個版本的秒殺,后面的版本,性能會直接飆升。

最后,介紹一下瘋狂創客圈:瘋狂創客圈,一個Java 高並發研習社群博客園 總入口

瘋狂創客圈,傾力推出:面試必備 + 面試必備 + 面試必備 的基礎原理+實戰 書籍 《Netty Zookeeper Redis 高並發實戰

img


瘋狂創客圈 Java 死磕系列

  • Java (Netty) 聊天程序【 億級流量】實戰 開源項目實戰


免責聲明!

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



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