1. 程式人生 > >SpringBoot實現Java高併發秒殺系統之Service層開發(二)

SpringBoot實現Java高併發秒殺系統之Service層開發(二)

繼上一篇文章:SpringBoot實現Java高併發秒殺系統之DAO層開發 我們建立了SpringBoot專案並熟悉了秒殺系統的表設計,下面我們將講解一下秒殺系統的核心部分:Service業務層的開發。

Service層又稱為業務層,在Spring階段主要是由@Service註解標記的一層,包含Service業務介面的開發和業務介面實現類的開發,這裡我們將講解如何優雅的設計業務層介面以及針對秒殺系統業務層的優化技術等和針對高併發的解決方案。

本專案的原始碼請參看:springboot-seckill 如果覺得不錯可以star一下哦(#.#)

本專案一共分為四個模組來講解,具體的開發教程請看我的部落格文章:

Service介面的設計

之前我們寫好了DAO層的介面,這裡我們要開始著手編寫業務層介面,然後編寫業務層介面的實現類並編寫業務層的核心邏輯。

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

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

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

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

類比DAO層介面的定義,我這裡先給出完整的SeckillService.java

的定義(注意:在DAO層(Mapper)中我們定義了兩個介面SeckillMapperSeckillOrderMapper,但是Service層介面為1個):

public interface SeckillService {

    /**
     * 獲取所有的秒殺商品列表
     *
     * @return
     */
    List<Seckill> findAll();

    /**
     * 獲取某一條商品秒殺資訊
     *
     * @param seckillId
     * @return
     */
    Seckill findById
(long seckillId); /** * 秒殺開始時輸出暴露秒殺的地址 * 否者輸出系統時間和秒殺時間 * * @param seckillId */ Exposer exportSeckillUrl(long seckillId); /** * 執行秒殺的操作 * * @param seckillId * @param userPhone * @param money * @param md5 */ SeckillExecution executeSeckill(long seckillId, BigDecimal money, long userPhone, String md5) throws SeckillException, RepeatKillException, SeckillCloseException; }

這裡我將依次講解一下為什麼介面會這樣設計?介面方法的返回值是怎樣定義的?

findById和findAll方法

這兩個方法就簡單很多:

  • findById(): 顧名思義根據ID主鍵查詢。按照介面的設計我們需要指定引數是seckillId(秒殺商品的ID值。注意:這裡定義為long型別,不要定義為包裝類型別,因為包裝類型別不能直接進行大小比較,必須轉換為基本型別才能進行值大小比較);返回值自然是查詢到的商品表資料級Seckill實體類了。

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

exportSeckillUrl方法

exportSeckillUrl()方法可有的講了,他是暴露介面用到的方法,目的就是獲取秒殺商品搶購的地址

1.為什麼要單獨建立一個方法來獲取秒殺地址?

在之前我們做的後端專案中,跳轉到某個詳情頁一般都是:根據ID查詢該詳情資料,然後將頁面跳轉到詳情頁並將資料直接渲染到頁面上。但是秒殺系統不同,它也不能就這樣簡單的定義,要知道秒殺技術的難點就是如何應對高併發?同一件商品,比如瞬間有十萬的使用者訪問,而還存在各種黃牛,有各種工具去搶購這個商品,那麼此時肯定不止10萬的訪問量的,並且開發者要儘量的保證每個使用者搶購的公平性,也就是不能讓一個使用者搶購一堆數量的此商品。

這就是我們常說的介面防刷問題。因此單獨定義一個獲取秒殺介面的方法是有必要的。

2.如何做到介面防刷?

介面方法:Exposer exportSeckillUrl(long seckillId);從引數列表中很易明白:就是根據該商品的ID獲取到這個商品的秒殺url地址;但是返回值型別Exposer是什麼呢?

思考一下如何做到介面防刷?

  1. 首先要保證該商品處於秒殺狀態。也就是1.秒殺開始時間要<當前時間;2.秒殺截止時間要>當前時間。

  2. 要保證一個使用者只能搶購到一件該商品,應做到商品秒殺介面對應同一使用者只能有唯一的一個URL秒殺地址,不同使用者間秒殺地址應是不同的,且配合訂單表seckill_order聯合主鍵的配置實現。

針對上面的兩條分析,我們給出Exposer的設計(要注意此類定義在/dto/路徑下表明此類是我們手動封裝的結果屬性,它類似JavaBean但又不屬於,僅用來封裝秒殺狀態的結果,目的是提高程式碼的重用率):

public class Exposer {

    //是否開啟秒殺
    private boolean exposed;

    //加密措施,避免使用者通過抓包拿到秒殺地址
    private String md5;

    //ID
    private long seckillId;

    //系統當前時間(毫秒)
    private long now;

    //秒殺開啟時間
    private long start;

    //秒殺結束時間
    private long end;

    public Exposer(boolean exposed, String md5, long seckillId) {
        this.exposed = exposed;
        this.md5 = md5;
        this.seckillId = seckillId;
    }

    public Exposer(boolean exposed, Long seckillId, long now, long start, long end) {
        this.exposed = exposed;
        this.seckillId = seckillId;
        this.now = now;
        this.start = start;
        this.end = end;
    }

    public Exposer(boolean exposed, long seckillId) {
        this.exposed = exposed;
        this.seckillId = seckillId;
    }
}

如上我們封裝的結果類可以滿足我們的需求:1.首先指明商品當前秒殺狀態:秒殺未開始、秒殺進行中、秒殺已結束;2.如果秒殺未開始返回false和相關時間用於前端展示秒殺倒計時;3。如果秒殺已經結束就返回false和當前商品的ID;3.如果秒殺正在進行中就返回該商品的秒殺地址(md5混合值,避免使用者抓包拿到秒殺地址)。

executeSeckill方法

這裡我們再回顧一下秒殺系統的業務分析:

可以看到,秒殺的業務邏輯很清晰,使用者搶購了商品業務層需要完成:1.減庫存;2.儲存使用者秒殺訂單明細。而因為儲存訂單明細應該是在使用者成功秒殺到訂單後才執行的操作,所以並不需要定義在Service介面中。那麼我們就看一下使用者針對庫存的業務分析:

可以看到針對庫存業務其實還是兩個操作:1.減庫存;2.記錄購買明細。但是其中涉及到很多事物操作和效能優化問題我們放在後面講。這裡我們將這兩個操作合併為一個介面方法:執行秒殺的操作。

所以再看一下我們對exexuteSeckill()方法的定義:

    SeckillExecution executeSeckill(long seckillId, BigDecimal money, long userPhone, String md5)
            throws SeckillException, RepeatKillException, SeckillCloseException;

1.分析引數列表

由於executeSeckill()方法涉及:1.減庫存;2.記錄購買明細。因為我們的專案不涉及複雜的資料,所以沒有太多的明細引數(用money替代)。那麼當前引數分別有何作用?

  • seckillIduserPhone用於在insert訂單明細時進行防重複秒殺;只要有相同的seckillIduserPhone就一定主鍵衝突報錯。

  • seckillIdmd5用於組成秒殺介面地址的一部分,當用戶點選搶購時獲取到之前暴露的秒殺地址中的md5值和當前傳入的md5值進行比較,如果匹配再進行下一步操作。

2.分析返回值型別

和在設計exportSeckillUrl介面方法時一樣,針對秒殺操作也應該包含很多返回資料,比如:秒殺結束、秒殺成功、秒殺系統異常…資訊,我們也將這些資訊用類封裝在dto資料夾中。於是我們的返回值SeckillExecution類定義如下:

public class SeckillExecution {

    private Long seckillId;

    //秒殺執行結果狀態
    private int state;

    //狀態表示
    private String stateInfo;

    //秒殺成功的訂單物件
    private SeckillOrder seckillOrder;

    public SeckillExecution(Long seckillId, int state, String stateInfo, SeckillOrder seckillOrder) {
        this.seckillId = seckillId;
        this.state = state;
        this.stateInfo = stateInfo;
        this.seckillOrder = seckillOrder;
    }

    public SeckillExecution(Long seckillId, int state, String stateInfo) {
        this.seckillId = seckillId;
        this.state = state;
        this.stateInfo = stateInfo;
    }
}

state用於-1,0,1這種狀態的表示,這些數字分別被賦予不同的含義,後面講到。stateInfo表示state狀態數字的中文解釋,比如:秒殺成功、秒殺結束、秒殺系統異常等資訊。

3.分析異常

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

針對秒殺關閉的異常,我們定義SeckillCloseException.java:

public class SeckillCloseException extends SeckillException {

    public SeckillCloseException(String message) {
        super(message);
    }

    public SeckillCloseException(String message, Throwable cause) {
        super(message, cause);
    }
}

針對重複秒殺的異常,我們定義RepeatKillException.java:

public class RepeatKillException extends SeckillException {

    public RepeatKillException(String message) {
        super(message);
    }

    public RepeatKillException(String message, Throwable cause) {
        super(message, cause);
    }
}

同時,系統還可能出現其他位置異常,所以我們還需要定義一個異常繼承所有異常的父類Exception:

public class SeckillException extends RuntimeException {

    public SeckillException(String message) {
        super(message);
    }

    public SeckillException(String message, Throwable cause) {
        super(message, cause);
    }
}

ServiceImpl實現類的設計

我們在src/cn/tycoding/service/impl下建立Service介面的實現類: SeckillServiceImpl.java

在開始講解之前我們先理解幾個概念:

1.為什麼我們的系統需要事務?

舉個栗子:比如a在購買商品A的同時,售賣該商品的商家突然調低了A商品的價格,但此瞬時價格調整還沒有更新到資料庫使用者購買的訂單就已經提交了,那麼使用者不就多掏了錢嗎?又比如a購買的商品後庫存數量減少的sql還沒有更新到資料庫,此時瞬間b使用者看到還有商品就點選購買了,而此時商品的庫存數量其實已經為0了,這樣就造成了超賣。

針對上面兩個栗子,我們必須要給出解決方案,不然就太坑了。

2.什麼是事務?

在軟體開發領域,**全有或全無的操作稱為事務(transaction)。**事務有四個特性,即ACID:

  • 原子性:原子性確保事務中所有操作全部發生或全部不發生。
  • 一致性:一旦事務完成(不管成功還是失敗),系統必須卻把它所建模的業務處於一致的狀態。
  • 隔離性:事務允許多個使用者對相同的資料進行操作,每個使用者的操作不會與其他使用者糾纏在一起。
  • 永續性:一旦事務完成,事務的結果應該持久化,這樣就能從任何的系統崩潰中恢復過來。

事務常見的問題:

  • 更新丟失:當多個事務選擇同一行操作,並且都是基於最初的選定的值,由於每個事務都不知道其他事務的存在,就會發生更新覆蓋的問題。
  • 髒讀:事務A讀取了事務B已經修改但為提交的資料。若事務B回滾資料,事務A的資料存在不一致的問題。
  • 不可重複讀:書屋A第一次讀取最初資料,第二次讀取事務B已經提交的修改或刪除的資料。導致兩次資料讀取不一致。不符合事務的隔離性。
  • 幻讀:事務A根據相同條件第二次查詢到的事務B提交的新增資料,兩次資料結果不一致,不符合事務的隔離性。

3.Spring對事務的控制

Spring框架針對事務提供了很多事務管理解決方案。我們這裡只說常用的:宣告式事務。宣告式事務通過傳播行為、隔離級別、只讀提示、事務超時及回滾規則來進行定義。我們這裡講用Spring提供的註解式事務方法:@Transaction

使用註解式事務的優點:開發團隊達到一致的約定,明確標註事務方法的程式設計風格。

使用事務控制需要注意:

  1. 保證事務方法的執行時間儘可能短,不要穿插其他的網路操作PRC/HTTP請求(可以將這些請求剝離出來)。
  2. 不是所有的放阿飛都需要事務控制,如只有一條修改操作、只讀操作等是不需要事務控制的。

注意

Spring預設只對執行期異常(RuntimeException)進行事務回滾操作,對於編譯異常Spring是不進行回滾的,所以對於需要進行事務控制的方法儘量將可能丟擲的異常都轉換成執行期異常。這也是我們我什麼要在Service介面中手動封裝一些RuntimeException資訊的一個重要原因。

exportSeckillUrl方法

@Service
public class SeckillServiceImpl implements SeckillService {
    private Logger logger = LoggerFactory.getLogger(this.getClass());

    //設定鹽值字串,隨便定義,用於混淆MD5值
    private final String salt = "sjajaspu-i-2jrfm;sd";
    @Autowired
    private SeckillMapper seckillMapper;

    @Autowired
    private SeckillOrderMapper seckillOrderMapper;

    @Override
    public Exposer exportSeckillUrl(long seckillId) {
        Seckill seckill = seckillMapper.findById(seckillId);
        if (seckill == null) {
            //說明沒有查詢到
            return new Exposer(false, seckillId);
        }
        Date startTime = seckill.getStartTime();
        Date endTime = seckill.getEndTime();
        //獲取系統時間
        Date nowTime = new Date();
        if (nowTime.getTime() < startTime.getTime() || nowTime.getTime() > endTime.getTime()) {
            return new Exposer(false, seckillId, nowTime.getTime(), startTime.getTime(), endTime.getTime());
        }
        //轉換特定字串的過程,不可逆的演算法
        String md5 = getMD5(seckillId);
        return new Exposer(true, md5, seckillId);
    }

    //生成MD5值
    private String getMD5(Long seckillId) {
        String base = seckillId + "/" + salt;
        String md5 = DigestUtils.md5DigestAsHex(base.getBytes());
        return md5;
    }

exportSeckillUrl()還是比較清晰的,主要邏輯:根據傳進來的seckillId查詢seckill表中對應資料,如果沒有查詢到就直接返回Exposer(false,seckillId)標識沒有查詢到該商品的秒殺介面資訊,可能是使用者非法輸入的資料;如果查詢到了,就獲取秒殺開始時間和秒殺結束時間以及new一個當前系統時間進行判斷當前秒殺商品是否正在進行秒殺活動,還沒有開始或已經結束都直接返回Exposer;如果上面兩個條件都符合了就證明該商品存在且正在秒殺活動中,那麼我們需要暴露秒殺介面地址。

因為我們要做到介面防刷的功能,所以需要生成一串md5值作為秒殺介面中一部分。而Spring提供了一個工具類DigestUtils用於生成MD5值,且又由於要做到更安全所以我們採用md5+鹽的加密方式生成一傳md5加密資料作為秒殺URL地址的一部分發送給Controller。

executeSeckill方法

    @Override
    @Transactional
    public SeckillExecution executeSeckill(long seckillId, BigDecimal money, long userPhone, String md5)
            throws SeckillException, RepeatKillException, SeckillCloseException {
        if (md5 == null || !md5.equals(getMD5(seckillId))) {
            throw new SeckillException("seckill data rewrite");
        }
        //執行秒殺邏輯:1.減庫存;2.儲存秒殺訂單
        Date nowTime = new Date();

        try {
            //記錄秒殺訂單資訊
            int insertCount = seckillOrderMapper.insertOrder(seckillId, money, userPhone);
            //唯一性:seckillId,userPhone,保證一個使用者只能秒殺一件商品
            if (insertCount <= 0) {
                //重複秒殺
                throw new RepeatKillException("seckill repeated");
            } else {
                //減庫存
                int updateCount = seckillMapper.reduceStock(seckillId, nowTime);
                if (updateCount <= 0) {
                    //沒有更新記錄,秒殺結束
                    throw new SeckillCloseException("seckill is closed");
                } else {
                    //秒殺成功
                    SeckillOrder seckillOrder = seckillOrderMapper.findById(seckillId);
                    return new SeckillExecution(seckillId, SeckillStatEnum.SUCCESS, seckillOrder);
                }
            }
        } catch (SeckillCloseException e) {
            throw e;
        } catch (RepeatKillException e) {
            throw e;
        } catch (Exception e) {
            logger.error(e.getMessage(), e);
            //所有編譯期異常,轉換為執行期異常
            throw new SeckillException("seckill inner error:" + e.getMessage());
        }
    }

executeSeckill方法相對複雜一些,主要涉及兩個業務操作:1.減庫存(呼叫reduceStock());2.記錄訂單明細(呼叫insertOrder())。我們以一張圖來描述一下主要邏輯:

由此我丟擲以下問答:

1.insertCount和updateCount哪來?

在之前我們寫專案中可能對於insert和update的操作直接設定返回值型別為void,雖然Mybatis的<insert><update>語句都沒有resultType屬性,但是並不帶表其沒有返回值,預設的返回值是0或1…表示該條SQL影響的行數,如果為0就表示該SQL沒有影響資料庫,但是為了避免系統遇到錯誤的SQL返回錯誤資訊而不是直接報錯,我們可以在書寫SQL時:insert ignore into xxx即用ignore引數,當Mybatis執行該SQL發生異常時直接返回0表示更新失敗而不是系統報錯。

2.為什麼先記錄秒殺訂單資訊操作再執行減庫存操作?

3.上例中用到的SeckillStatEnum是什麼?

之前我們講exportSeckillUrl時在/dto/中建立了類Exposer;在講executeSeckill的時候建立了SeckillExecution類,他們都是用來封裝返回的結果資訊的,不是說他們是必須的,而是用這種方式會更規範且程式碼看起來更加整潔,而且我們的程式碼的重用率會更高。

於是,當用戶秒殺成功後其實需要返回一句話秒殺成功即可,但是我們單獨提取到了一個列舉類中:

public enum SeckillStatEnum {
    SUCCESS(1, "秒殺成功"),
    END(0, "秒殺結束"),
    REPEAT_KILL(-1,"重複秒殺"),
    INNER_ERROR(-2, "系統異常"),
    DATA_REWRITE(-3, "資料串改");

    private int state;
    private String stateInfo;

    SeckillStatEnum(int state, String stateInfo) {
        this.state = state;
        this.stateInfo = stateInfo;
    }

    public int getState() {
        return state;
    }

    public String getStateInfo() {
        return stateInfo;
    }

    public static SeckillStatEnum stateOf(int index){
        for (SeckillStatEnum state : values()){
            if (state.getState() == index){
                return state;
            }
        }
        return null;
    }
}

具體列舉的語法不再講,簡單來說就是將這些通用的返回結果提取出來,且列舉這種型別更適合當前方法的返回值特點。除了建立這個列舉物件,還需要修改SeckillExecution的原始碼,這裡不再貼出。

4.為什麼要cache這麼多異常?

前面我們已經提到了Spring預設只對執行期異常進行事務回滾操作,對於編譯期異常時不進行回滾的,所以這也是我們為什麼一直強調要手動建立異常類。

這裡就是要將所有編譯期異常轉換為執行期異常,因為我們定義的所有異常最終都是繼承RuntimeException。

此例具體程式碼請看:GitHub


交流

如果大家有興趣,歡迎大家加入我的Java交流群:671017003 ,一起交流學習Java技術。博主目前一直在自學JAVA中,技術有限,如果可以,會盡力給大家提供一些幫助,或是一些學習方法,當然群裡的大佬都會積極給新手答疑的。所以,別猶豫,快來加入我們吧!


聯絡

If you have some questions after you see this article, you can contact me or you can find some info by clicking these links.