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實現Java高并發秒殺系統之DAO層開發(一)
-
SpringBoot實現Java高并發秒殺系統之Service層開發(二)
-
SpringBoot實現Java高并發秒殺系統之Web層開發(三)
-
SpringBoot實現Java高并發秒殺系統之并發優化(四)
起步
首先我們需要搭建SpringBoot項目開發環境,IDEA搭建SpringBoot項目的具體教程請看我的:博文。
如果你對SpringBoot框架或是SSM框架不熟悉,我想推薦一下我的幾個小項目幫助你更好的理解:
-
SpringBoot起步之環境搭建
-
SpringBoot-Mybatis入門之CRUD
-
手把手教你整合SSM框架
-
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實體類配置
此處源碼請看:GitHub
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的映射接口文件。其實在實際項目中考慮到項目的大小和復雜程度,dao和mapper可能是同時存在的,因為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 <= #{killTime} AND end_time >= #{killTime} AND stock_count > 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執行失敗。
拓展
上面我使用了<、>的語法其實代表的是>= <=這種符號,因為在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()注解加載配置文件反而會報錯。
總結
以上是生活随笔為你收集整理的SpringBoot实现Java高并发秒杀系统之DAO层开发(一)的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 2018-2019-1 20165226
- 下一篇: HanLP 关键词提取算法分析详解