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

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


前言

前言

瘋狂創客圈(筆者尼恩建立的高併發研習社羣)Springcloud 高併發系列文章,將爲你們介紹三個版本的 高併發秒殺:前端

1、版本1 :springcloud + zookeeper 秒殺java

2、版本2 :springcloud + redis 分佈式鎖秒殺mysql

3、版本3 :springcloud + Nginx + Lua 高性能版本秒殺程序員

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

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

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

3、Feign Ribbon Hystrix 三者關係 , 史上最全 深度解析算法

4、SpringCloud gateway 詳解 , 史上最全spring

本文:是第一個版本 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、商戶維度的業務流程,主要涉及兩個操做:

(1)增長秒殺

經過後臺的管理界面,增長特定商品、特定數量、特定時段的秒殺。

(2)暴露秒殺

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

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、客戶端的功能

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

在桌面瀏覽器、移動端APP展現秒殺的商品。不論在哪一個屏幕展現,秒殺的活動元素,須要儘量所有靜態化,並儘可能減小動態元素。這樣,就能夠經過CDN來抗峯值。

(2)禁止重複秒殺

用戶在客戶端操做過程當中,客戶端須要具有用戶行爲的控制能力。好比,在用戶提交秒殺以後,能夠將用戶秒殺的按鈕置灰,禁止重複提交。

2、微服務接入層功能

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

秒殺系統特色是併發量極大,但實際秒殺成功的請求數量卻不多,因此若是不在前端攔截極可能形成數據庫讀寫鎖衝突,甚至致使死鎖,最終請求超時。

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

(2)消息隊列削峯

上面只攔截了一部分訪問請求,當秒殺的用戶量很大時,即便每一個用戶只有一個請求,到服務層的請求數量仍是很大。好比咱們有100W用戶同時搶100臺手機,服務層併發請求壓力至少爲100W。

使用消息隊列能夠削峯,將爲後臺緩衝大量併發請求,這也是一個異步處理過程,後臺業務根據本身的處理能力,從消息隊列中主動的拉取秒殺消息進行業務處理。

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

在這裏插入圖片描述

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

3、微服務業務層功能

單體的秒殺服務,完成到達後臺的秒殺下單的前臺請求。而後,基於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) 聊天程序【 億級流量】實戰 開源項目實戰

  • Netty 源碼、原理、JAVA NIO 原理
  • Java 面試題 一網打盡
  • 瘋狂創客圈 【 博客園 總入口 】

相關文章
相關標籤/搜索