天天看點

SpringBoot實作Java高并發秒殺系統之Service層開發

Service接口的設計

之前我們寫好了DAO層的接口,這裡我們要開始着手編寫業務層接口,然後編寫業務層接口的實作類并編寫業務層的核心邏輯。

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

  • 1.定義業務方法的顆粒度要細。
  • 2.方法的參數要明确簡練,不建議使用類似Map這種類型,讓使用者可以封裝進Map中一堆參數而傳遞進來,盡量精确到哪些參數。
  • 3.方法的return傳回值,除了應該明确傳回值類型,還應該指明方法執行可能産生的異常(RuntimeException),并應該手動封裝一些通用的異常處理機制。

類比DAO層接口的定義,我這裡先給出完整的 SeckillService.java 的定義(注意:在DAO層(Mapper)中我們定義了兩個接口 SeckillMapper 和 SeckillOrderMapper ,但是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但又不屬于,僅用來封裝秒殺狀态的結果,目的是提高代碼的重用率):

此例源碼請看: GitHub

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方法

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

SpringBoot實作Java高并發秒殺系統之Service層開發

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

SpringBoot實作Java高并發秒殺系統之Service層開發

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

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

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

1.分析參數清單

由于 executeSeckill() 方法涉及:1.減庫存;2.記錄購買明細。因為我們的項目不涉及複雜的資料,是以沒有太多的明細參數(用 money 替代)。那麼目前參數分别有何作用?

  • seckillId 和 userPhone 用于在insert訂單明細時進行防重複秒殺;隻要有相同的 seckillId和 userPhone 就一定主鍵沖突報錯。
  • seckillId 和 md5 用于組成秒殺接口位址的一部分,當使用者點選搶購時擷取到之前暴露的秒殺位址中的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() )。我們以一張圖來描述一下主要邏輯:

SpringBoot實作Java高并發秒殺系統之Service層開發

由此我抛出以下問答:

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.為什麼先記錄秒殺訂單資訊操作再執行減庫存操作?

這裡涉及了一個簡單的Java并發優化操作,詳細内容優化方式請看: SpringBoot實作Java高并發秒殺系統之系統優化

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。

以上就是我的分享,覺得有收獲的朋友們可以點個關注,想多學習Java技術方面知識的朋友們可以進我的一個後端技術群,裡面有高可用、高并發、高性能及分布式、Jvm性能調優、Spring源碼,Dubbo,Nginx等多個知識點的架構資料,進群即可免費領取,QQ群:680075317,也可以進群一起交流,比如遇到技術瓶頸、面試不過的,大家一些交流學習!