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

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

秒殺系統在如今電商專案中是很常見的,最近在學習電商專案時講到了秒殺系統的實現,於是打算使用SpringBoot框架學習一下秒殺系統(本專案基於慕課網的一套免費視訊教程:Java高併發秒殺API,視訊教程中講解的很詳細,非常感謝這位講師)。也是因為最近學習了SpringBoot框架(GitHub教程:SpringBoot入門之CRUD ),覺得SpringBoot框架確實比傳統SSM框架方便了很多,於是更深層次練習使用SpringBoot框架,注意:SpringBoot不是對Spring功能上的增強,而是提供了一種快速使用Spring的方式。 如果你熟悉了SSM框架,學習SpringBoot框架也是很Easy的。

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

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

起步

首先我們需要搭建SpringBoot專案開發環境,IDEA搭建SpringBoot專案的具體教程請看我的:博文

如果你對SpringBoot框架或是SSM框架不熟悉,我想推薦一下我的幾個小專案幫助你更好的理解:

專案設計

.
├── README  -- Doc文件
├── db  -- 資料庫約束檔案
├── mvnw
├── mvnw.cmd
├── pom.xml  -- 專案依賴
└── src
    ├── main
    │   ├── java
    │   │   └── cn
    │   │       └── tycoding
    │   │           ├── SpringbootSeckillApplication.java  -- SpringBoot啟動器
    │   │           ├── controller  -- MVC的web層
    │   │           ├── dto  -- 統一封裝的一些結果屬性,和entity類似
    │   │           ├── entity  -- 實體類
    │   │           ├── enums  -- 手動定義的字典列舉引數
    │   │           ├── exception  -- 統一的異常結果
    │   │           ├── mapper  -- Mybatis-Mapper層對映介面,或稱為DAO層
    │   │           ├── redis  -- redis,jedis 相關配置
    │   │           └── service  -- 業務層
    │   └── resources
    │       ├── application.yml  -- SpringBoot核心配置
    │       ├── mapper  -- Mybatis-Mapper層XML對映檔案
    │       ├── static  -- 存放頁面靜態資源,可通過瀏覽器直接訪問
    │       │   ├── css
    │       │   ├── js
    │       │   └── lib
    │       └── templates  -- 存放Thymeleaf模板引擎所需的HTML,不能在瀏覽器直接訪問
    │           ├── page
    │           └── public  -- HTML頁面公共元件(頭部、尾部)
    └── test  -- 測試檔案

SpringBoot

之前我們在SpringBoot-Mybatis入門之CRUD中已經詳細講解了SpringBoot框架的開發流程,還是覺得一句話說的特別好:SpringBoot不是對對Spring功能上的增強,而是提供了一種快速使用Spring的方式。所以用SSM階段的知識足夠了SpringBoot階段的開發,下面我們強調一下小技巧:

  • SpringBoot不需要配置註解掃描,之前我們配置<context:component-scan>掃描可能使用註解(@Service,@Component,@Controller等)的包路徑。預設建立SpringBoot專案自動生成的Application.java啟動器類會自動掃描其下的所有註解。

  • SpringBoot專案中靜態資源都放在resources目錄下,其中static目錄中的資料可以直接通過瀏覽器訪問,多用來放CSS、JS、img,但是不用來放html頁面;其中templates用來存放HTML頁面,但是需要在SpringBoot的配置檔案(application.yml)中配置spring.thymeleaf.prefix標識Thymeleaf模板引擎渲染的頁面位置。

  • HTML頁面通過Thymeleaf的加持,為HTML頁面賦予了很多功能,此時的HTML頁面類似於JSP頁面。訪問後端存入域物件(session,request…)中的資料,可以通過th:text="${key}"獲得,在JS中也可以通過[[${key}]]獲得。

  • Thymeleaf提供了類似JSP頁面<include>的功能:public-component:<div th:fragment="header">,main-component:<div th:replace="path/header :: header">(其中path表示public-component相對於templates的路徑,/header表示component檔名,最後的header表示th:fragment中定義的名稱)。

pom依賴

    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>2.0.5.RELEASE</version>
        <relativePath/> <!-- lookup parent from repository -->
    </parent>

    <properties>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
        <project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
        <java.version>1.8</java.version>
    </properties>

    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-redis</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-jdbc</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-thymeleaf</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <dependency>
            <groupId>org.mybatis.spring.boot</groupId>
            <artifactId>mybatis-spring-boot-starter</artifactId>
            <version>1.3.2</version>
        </dependency>

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-devtools</artifactId>
            <scope>runtime</scope>
        </dependency>
        <dependency>
            <groupId>mysql</groupId>
            <artifactId>mysql-connector-java</artifactId>
            <scope>runtime</scope>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>

        <!-- alibaba的druid資料庫連線池 -->
        <dependency>
            <groupId>com.alibaba</groupId>
            <artifactId>druid-spring-boot-starter</artifactId>
            <version>1.1.9</version>
        </dependency>

        <!-- redis客戶端 -->
        <dependency>
            <groupId>redis.clients</groupId>
            <artifactId>jedis</artifactId>
        </dependency>

        <dependency>
            <groupId>junit</groupId>
            <artifactId>junit</artifactId>
            <version>4.12</version>
            <scope>test</scope>
        </dependency>
    </dependencies>

JavaBean實體類配置

Seckill.java

public class Seckill implements Serializable {

    private long seckillId; //商品ID
    private String title; //商品標題
    private String image; //商品圖片
    private BigDecimal price; //商品原價格
    private BigDecimal costPrice; //商品秒殺價格

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

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

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

    private long stockCount; //剩餘庫存數量
}

SeckillOrder.java

public class SeckillOrder implements Serializable {

    private long seckillId; //秒殺到的商品ID
    private BigDecimal money; //支付金額

    private long userPhone; //秒殺使用者的手機號

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

    private boolean status; //訂單狀態, -1:無效 0:成功 1:已付款

    private Seckill seckill; //秒殺商品,和訂單是一對多的關係
}

注意實體類中Date型別資料都用了@DateTimeFormat()(來自springframework)和@JsonFormat()(來自jackson)標識可以實現Controller在返回JSON資料(用@ResponseBody標識的方法或@RestController標識的類)的時候能將Date型別的引數值(經Mybatis查詢得到的資料是英文格式的日期,因為實體類中是Date型別)轉換為註解中指定的格式返回給頁面(相當於經過了一層SimpleDateFormate)。

其次要注意在編寫實體類的時候儘量養成習慣繼承Serializable介面。在SeckillOrder中我們注入了Seckill類作為一個屬性,目的是為了可以使用多表查詢的方式從seckill_order表中查詢出來對應的seckill表資料。

表設計

建立完成了SpringBoot專案,首先我們需要初始化資料庫,秒殺系統的建表SQL如下:

/*
 *  mysql-v: 5.7.22
 */

-- 建立資料庫
-- CREATE DATABASE seckill DEFAULT CHARACTER SET utf8;

DROP TABLE IF EXISTS `seckill`;
DROP TABLE IF EXISTS `seckill_order`;

-- 建立秒殺商品表
CREATE TABLE `seckill`(
  `seckill_id` bigint NOT NULL AUTO_INCREMENT COMMENT '商品ID',
  `title` varchar (1000) DEFAULT NULL COMMENT '商品標題',
  `image` varchar (1000) DEFAULT NULL COMMENT '商品圖片',
  `price` decimal (10,2) DEFAULT NULL COMMENT '商品原價格',
  `cost_price` decimal (10,2) DEFAULT NULL COMMENT '商品秒殺價格',
  `stock_count` bigint DEFAULT NULL COMMENT '剩餘庫存數量',
  `start_time` timestamp NOT NULL DEFAULT '1970-02-01 00:00:01' COMMENT '秒殺開始時間',
  `end_time` timestamp NOT NULL DEFAULT '1970-02-01 00:00:01' COMMENT '秒殺結束時間',
  `create_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '建立時間',
  PRIMARY KEY (`seckill_id`),
  KEY `idx_start_time` (`start_time`),
  KEY `idx_end_time` (`end_time`),
  KEY `idx_create_time` (`end_time`)
) CHARSET=utf8 ENGINE=InnoDB COMMENT '秒殺商品表';

-- 建立秒殺訂單表
CREATE TABLE `seckill_order`(
  `seckill_id` bigint NOT NULL COMMENT '秒殺商品ID',
  `money` decimal (10, 2) DEFAULT NULL COMMENT '支付金額',
  `user_phone` bigint NOT NULL COMMENT '使用者手機號',
  `create_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '建立時間',
  `state` tinyint NOT NULL DEFAULT -1 COMMENT '狀態:-1無效 0成功 1已付款',
  PRIMARY KEY (`seckill_id`, `user_phone`) /*聯合主鍵,保證一個使用者只能秒殺一件商品*/
) CHARSET=utf8 ENGINE=InnoDB COMMENT '秒殺訂單表';

解釋

秒殺系統的表設計還是相對簡單清晰的,這裡我們只考慮秒殺系統的業務表,不涉及其他的表,所以整個系統主要涉及兩張表:秒殺商品表、訂單表。當然實際情況肯定不止這兩張表(比如付款相關表,但是我們並未實現這個功能),也不止表中的這些欄位。這裡我們需要特別注意以下幾點:

注意

  • 1.我這裡使用的Mysql版本是5.7.22,在Mysql5.7之後timestamp預設值不能再是0000 00-00 00:00:00,具體的介紹請看:mysql官方文件。即 TIMESTAMP has a range of ‘1970-01-01 00:00:01’ UTC to ‘2038-01-19 03:14:07’ UTC.

  • 2.timestamp型別用來實現自動為新增行欄位設定當前系統時間;且使用timestamp的欄位必須給timestamp設定預設值,而在Mysql中date, datetime等型別都是無法實現預設設定當前系統時間值的功能(DEFAULT CURRENT_TIMESTAMP)的,所以我們必須使用timestamp型別,否則你要給欄位傳進來系統時間。

  • 3.decimal型別用於在資料庫中設定精確的數值,比如decimal(10,2)表示可以儲存10位且有2位小數的數值。

  • 4.tinyint型別用於存放int型別的數值,但是若用Mybatis作為DAO層框架,Mybatis會自動為tinyint型別的資料轉換成true或false(0:false; 1 or 1+:true)。

  • 5.在訂單表seckill_order中我們設計了聯合主鍵:PRIMARY KEY (seckill_id, user_phone),目的是為了避免單個使用者重複購買同一件商品(一個使用者只能秒殺到一次同一件商品)。

  • 6.無論是建立資料庫還是建立表我們都應該養成一個習慣就是指定character=utf-8,避免中文資料亂碼;其次還應該指定表的儲存引擎是InnoDB,MySQL提供了兩種儲存引擎:InnoDB, MyISAM。但是隻有InnoDB是支援事務的,且InnoDB相比MyISAM在併發上更具有高效能的優點。

DAO層開發

DAO層是我們常說的三層架構(Web層-業務層-持久層)中與資料庫互動的持久層,但是實際而言,架構是這樣設計的,但是並不代表著實際專案中就一定存在一個dao資料夾,特別是現階段我們使用的Spring-Mybatis框架。Mybatis提供了一種介面代理開發模式,也就是我們需要提供一個interface介面,其他和資料庫互動的SQL編寫放到對應的XML檔案中(但是需要進行相關的資料庫引數配置,並且Mybatis規定了使用這種開發模式必須保持介面和XML檔名稱對應)。於是在本專案中就沒有出現dao整個資料夾,取而代之的是mapper這個資料夾,我感覺更易識別出為Mybatis的對映介面檔案。其實在實際專案中考慮到專案的大小和複雜程度,daomapper可能是同時存在的,因為service可能並不滿足專案的設計,即為dao介面建立實現類,在實現類中再呼叫mapper介面來實現功能模組的擴充套件。

DAO層開發,即DAO層介面開發,主要設計需要和資料庫互動的資料有哪些?應該用什麼返回值型別接收查詢到的資料?所以包含的方法有哪些?帶著這些問題,我們先看一下秒殺系統的業務流程:

由上圖可以看出,相對與本專案而言和資料庫打交道的主要涉及兩個操作:1.減庫存(秒殺商品表);2.記錄購買明細(訂單表)。

  • 減庫存,顧名思義就是減少當前被秒殺到的商品的庫存數量,這也是秒殺系統中一個處理難點的地方。實現減庫存即count-1,但是我們需要考慮Mysql的事務特性引發的種種問題、需要考慮如何避免同一使用者重複秒殺的行為。

  • 如果減庫存的業務解決了那麼記錄購買明細的業務就相對簡單很多了,我們需要記錄購買使用者的姓名、手機號、購買的商品ID等。因為本專案中不涉及支付功能,所以記錄使用者的購買訂單的業務並不複雜。

分析了上面的功能,下面我們開始DAO層介面的編寫(原始碼請看:GitHub):

    /**
     * 減庫存。
     * 對於Mapper對映介面方法中存在多個引數的要加@Param()註解標識欄位名稱,不然Mybatis不能識別出來哪個欄位相互對應
     *
     * @param seckillId 秒殺商品ID
     * @param killTime  秒殺時間
     * @return 返回此SQL更新的記錄數,如果>=1表示更新成功
     */
    int reduceStock(@Param("seckillId") long seckillId, @Param("killTime") Date killTime);

    /**
     * 插入購買訂單明細
     *
     * @param seckillId 秒殺到的商品ID
     * @param money     秒殺的金額
     * @param userPhone 秒殺的使用者
     * @return 返回該SQL更新的記錄數,如果>=1則更新成功
     */
    int insertOrder(@Param("seckillId") long seckillId, @Param("money") BigDecimal money, @Param("userPhone") long userPhone);

但從介面設計上我們無非關注的就是這兩個方法:1.減庫存;2.插入購買明細。此處需要注意的是:

  • 對於SpringBoot系統,DAO(Mapper)層的介面需要使用@Mapper註解標識。因為SpringBoot系統中介面的XML檔案不在/java目錄下而是在/resources目錄下。

  • 對於Mapper介面方法中存在傳遞多個引數的情況需要使用@Param()標識這個引數的名稱,目的是為了幫助Mybatis識別傳遞的引數,不然Mybatis的XML中用的#{}不能識別出來你傳遞的引數名稱是誰和誰對應的,類似於Controller層中常用的@RequestParam()註解。

  • 小技巧: 之前我們做insert和update操作時直接用void作為方法返回值,實際上雖然Mybatis的<update><select>語句並沒有resultType屬性,但是並不代表其沒有返回值,預設返回0或1,表示執行該SQL影響的行數。為此我們可以這樣寫SQL,如:insert ignore into xxx用來避免Mybatis報錯,而是直接返回0表示當前SQL執行失敗。

  • 小技巧:因為我們必須要避免同一個使用者多次搶購同一件商品,在SQL中必須限制這一點(因為即使前端怎麼控制都無法避免使用者多次請求同一個介面,所謂介面防刷)。所以在設計訂單表的時候用了聯合主鍵且不自增的方式,以使用者ID和使用者電話組成聯合主鍵,這樣當同一個使用者(電話相同)多次搶購同一件商品時插入的SQL就會產生主鍵衝突的問題,這樣就會報錯。

XML對映

    <update id="reduceStock">
        UPDATE seckill
        SET stock_count = stock_count - 1
        WHERE seckill_id = #{seckillId}
        AND start_time &lt;= #{killTime}
        AND end_time &gt;= #{killTime}
        AND stock_count &gt; 0
    </update>

    <insert id="insertOrder">
        INSERT ignore INTO seckill_order(seckill_id, money, user_phone)
        VALUES (#{seckillId}, #{money}, #{userPhone})
    </insert>

SQL語句相對不是很複雜。減庫存:執行update語句,令stock_count欄位依次減一,並且當前要在一系列where條件的限制下;新增訂單資訊:儲存訂單資料,這裡為介面防刷用聯合主鍵seckillId, userPhone,如果同一個使用者多次搶購同一件商品導致主鍵衝突會直接報錯,為了避免系統不直接報錯設計了ignore實現主鍵衝突就直接返回0表示該條SQL執行失敗。

拓展

上面我使用了&lt;&gt;的語法其實代表的是>= <=這種符號,因為在Mybatis中編寫的SQL語句如果直接使用>=<=這種判斷條件可能會報錯,我這裡提供一種簡單的解決方案就是用這種英文符號代替:

原符號 替換符號
< <
<= <=
> >
>= >=
& &
'
" "

order表中findById方法

之前在SeckillOrder.java實體類中我們注入了Seckill屬性,用於可以根據查詢seckill_order表的同時查詢到其對應的seckill表資料,對應的介面定義如下:

    /**
     * 根據秒殺商品ID查詢訂單明細資料並得到對應秒殺商品的資料,因為我們再SeckillOrder中已經定義了一個Seckill的屬性
     *
     * @param seckillId
     * @return
     */
    SeckillOrder findById(long seckillId);

對應的SQL如下:

    <select id="findById" resultType="SeckillOrder">
        SELECT
          so.seckill_id,
          so.user_phone,
          so.money,
          so.create_time,
          so.state,
          s.seckill_id "seckill.seckill_id",
          s.title "seckill.title",
          s.cost_price "seckill.cost_price",
          s.create_time "seckill.create_time",
          s.start_time "seckill.start_time",
          s.end_time "seckill.end_time",
          s.stock_count "seckill.stock_count"
        FROM seckill_order so
        INNER JOIN seckill s ON so.seckill_id = s.seckill_id
        WHERE so.seckill_id = #{seckillId}
    </select>

這個SQL看似複雜些,但是就是僅僅的多表(兩張表)查詢語句:根據seckill_order表中的seckill_id欄位查詢seckill表中seckill_id欄位值對應的資料(也就是說:對於多表查詢,其實兩張表之間必然存在一定的欄位關聯關係,不一定是外來鍵關聯,當然我們也不建議用外來鍵關聯兩張表)。

其中findById的SQL中類似s.seckill_id "seckill.seckill_id"語句其實是s.seckill_id as "seckill.seckill_id",這裡省略了as(別名);而INNER JOIN語句正是查詢若兩張表中中又相同欄位的匹配值就根據兩張表關聯欄位查詢兩張表的資料。這也可以使用<resultMap>中的<association>標籤來實現,用於查詢兩張關聯表的資料,如:

  <resultMap id="findById" type="SeckillOrder">
      <id column="seckill_id" property="seckillId"/>
      <result column="user_phone" property="userPhone"/>
      ...
      <association property="seckill" javaType="Seckill">
          <id column="seckill_id" property="seckillId"/>
          <result column="title" property="title"/>
          ...
      </association>
  </resultMap>

如以上也是一種對映另外一張表資料的方式(當然使用這種方式在寫SQL的時候需要指定限制條件where s.seckill_id = so.seckill_id強調兩張表中的seckill_id欄位值相同)。

測試

在編寫了Mybatis的對映介面和XML對映檔案,我們可以編寫一個測試類來測試一下介面和XML配置是否正確。由於我們使用IDEA開發工具,開啟介面檔案用快捷鍵Alt + Enter(我這裡用的Mac系統)顯示一個面板,選擇Create Test快速建立本檔案的測試類。

由於使用的SpringBoot框架,新建立的測試類位於/src/test/java/目錄下,我們舉例說明,比如建立SeckillMapper介面的測試檔案:SeckillMapperTest.java

public class SeckillMapperTest {

    @Autowired
    private SeckillMapper seckillMapper;

    @Test
    public void findAll() {
    }

    @Test
    public void findById() {
    }

    @Test
    public void reduceStock() {
    }
}

以上就是使用IDEA快捷鍵建立的測試類,我們僅以findAll()方法舉例說明一下如何使用SpringBoot的測試類。如下:

此處的原始碼請參看:Github

@RunWith(SpringJUnit4ClassRunner.class)
//@ContextConfiguration("classpath:application.yml")
@SpringBootTest
public class SeckillMapperTest {

    @Autowired
    private SeckillMapper seckillMapper;

    @Test
    public void findAll() {
        List<Seckill> all = seckillMapper.findAll();
        for (Seckill seckill : all) {
            System.out.println(seckill.getTitle());
        }
    }

    @Test
    public void findById() {
    }

    @Test
    public void reduceStock() {
    }
}

SpringBoot的測試類和傳統Spring框架測試類的最大區別就是不再使用@ContextConfiguration()註解去載入配置檔案,取而代之的是使用@SpringBootTest註解。因為SpringBoot已經嚴格規定了配置檔案放在resources目錄下,且一般是.properties.yml結尾。如果你再使用@ContextConfiguration()註解載入配置檔案反而會報錯。

交流

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