'教你SpringBoot實現Java高併發秒殺系統之Service層開發'

Java 設計 數據庫 技術 Redis Programmer小米 2019-08-03
"
"
教你SpringBoot實現Java高併發秒殺系統之Service層開發

Service層又稱為業務層,在Spring階段主要是由 @Service 註解標記的一層,包含Service業務接口的開發和業務接口實現類的開發,這裡我們將講解如何優雅的設計業務層接口以及針對秒殺系統業務層的優化技術等和針對高併發的解決方案。關注、轉發、評論頭條號每天分享java知識,私信回覆“555”贈送一些Dubbo、Redis、Netty、zookeeper、Spring cloud、分佈式資料

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層開發

Service層又稱為業務層,在Spring階段主要是由 @Service 註解標記的一層,包含Service業務接口的開發和業務接口實現類的開發,這裡我們將講解如何優雅的設計業務層接口以及針對秒殺系統業務層的優化技術等和針對高併發的解決方案。關注、轉發、評論頭條號每天分享java知識,私信回覆“555”贈送一些Dubbo、Redis、Netty、zookeeper、Spring cloud、分佈式資料

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層開發

Service層又稱為業務層,在Spring階段主要是由 @Service 註解標記的一層,包含Service業務接口的開發和業務接口實現類的開發,這裡我們將講解如何優雅的設計業務層接口以及針對秒殺系統業務層的優化技術等和針對高併發的解決方案。關注、轉發、評論頭條號每天分享java知識,私信回覆“555”贈送一些Dubbo、Redis、Netty、zookeeper、Spring cloud、分佈式資料

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層開發

Service層又稱為業務層,在Spring階段主要是由 @Service 註解標記的一層,包含Service業務接口的開發和業務接口實現類的開發,這裡我們將講解如何優雅的設計業務層接口以及針對秒殺系統業務層的優化技術等和針對高併發的解決方案。關注、轉發、評論頭條號每天分享java知識,私信回覆“555”贈送一些Dubbo、Redis、Netty、zookeeper、Spring cloud、分佈式資料

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知識,私信回覆“01”贈送一些資料。

"
教你SpringBoot實現Java高併發秒殺系統之Service層開發

Service層又稱為業務層,在Spring階段主要是由 @Service 註解標記的一層,包含Service業務接口的開發和業務接口實現類的開發,這裡我們將講解如何優雅的設計業務層接口以及針對秒殺系統業務層的優化技術等和針對高併發的解決方案。關注、轉發、評論頭條號每天分享java知識,私信回覆“555”贈送一些Dubbo、Redis、Netty、zookeeper、Spring cloud、分佈式資料

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知識,私信回覆“01”贈送一些資料。

教你SpringBoot實現Java高併發秒殺系統之Service層開發

"
教你SpringBoot實現Java高併發秒殺系統之Service層開發

Service層又稱為業務層,在Spring階段主要是由 @Service 註解標記的一層,包含Service業務接口的開發和業務接口實現類的開發,這裡我們將講解如何優雅的設計業務層接口以及針對秒殺系統業務層的優化技術等和針對高併發的解決方案。關注、轉發、評論頭條號每天分享java知識,私信回覆“555”贈送一些Dubbo、Redis、Netty、zookeeper、Spring cloud、分佈式資料

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知識,私信回覆“01”贈送一些資料。

教你SpringBoot實現Java高併發秒殺系統之Service層開發

教你SpringBoot實現Java高併發秒殺系統之Service層開發

"
教你SpringBoot實現Java高併發秒殺系統之Service層開發

Service層又稱為業務層,在Spring階段主要是由 @Service 註解標記的一層,包含Service業務接口的開發和業務接口實現類的開發,這裡我們將講解如何優雅的設計業務層接口以及針對秒殺系統業務層的優化技術等和針對高併發的解決方案。關注、轉發、評論頭條號每天分享java知識,私信回覆“555”贈送一些Dubbo、Redis、Netty、zookeeper、Spring cloud、分佈式資料

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知識,私信回覆“01”贈送一些資料。

教你SpringBoot實現Java高併發秒殺系統之Service層開發

教你SpringBoot實現Java高併發秒殺系統之Service層開發

教你SpringBoot實現Java高併發秒殺系統之Service層開發

"
教你SpringBoot實現Java高併發秒殺系統之Service層開發

Service層又稱為業務層,在Spring階段主要是由 @Service 註解標記的一層,包含Service業務接口的開發和業務接口實現類的開發,這裡我們將講解如何優雅的設計業務層接口以及針對秒殺系統業務層的優化技術等和針對高併發的解決方案。關注、轉發、評論頭條號每天分享java知識,私信回覆“555”贈送一些Dubbo、Redis、Netty、zookeeper、Spring cloud、分佈式資料

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知識,私信回覆“01”贈送一些資料。

教你SpringBoot實現Java高併發秒殺系統之Service層開發

教你SpringBoot實現Java高併發秒殺系統之Service層開發

教你SpringBoot實現Java高併發秒殺系統之Service層開發

教你SpringBoot實現Java高併發秒殺系統之Service層開發

"

相關推薦

推薦中...