前言
使用碼雲做圖床的時候一定要一張一張的傳圖啊,不然一張圖片直接影分身
首先感謝大佬的無私奉獻,願意將自己的經驗和技術分享給我們。貼上大佬的教程指北 教程頁
項目代碼見我的github Modeus
經過了長達半個月的跟班學習,跟着老師做還用了這么長的時間,原因無非是自己對於知識的掌握不牢靠,知識面的狹窄。同時,在不斷的學習和修改自己編寫中出現的bug之后,對於項目的開發和對於代碼的理解都提高了一個檔次!至此,秒殺系統的開發告一段落,那么是時候對其進行分析和總結了!
一、工欲善其事,必先利其器
該項目使用到的工具包括但不限於 IDEA 編輯器,Maven 項目管理器,Spring + MVC + BootStrap 框架,語言主要使用 Java 語言和 JS,日志使用的是 slf4j 的最新版 logback,數據庫連接池用的 c3p0,單元測試用的 JUnit 4。
1.1 IDEA 的配置
IDEA 是對 Java 開發非常友好的編輯器,利用它做開發能達到事半功倍的效果。
IDEA 本體的安裝及配置網上教程很多,這里不做贅述。那么我們需要在IDEA 中自行安裝及配置的就只有 Maven 了(其實也不用, IDEA 已經將 Maven 做了整合,但是我用的時候出現了一些問題,故采用自己安裝配置的 Maven ,同時自己安裝配置也能保證使用的是最新的版本)。Go on >>>
1.2 Maven 的安裝及配置
官網下載最新的壓縮包解壓即可,配置環境變量,新建一個變量
再在 path 目錄下添加 %MAVEN_HOME%\bin
打開 powershell 輸入 mvn -version
出現
打開解壓后的目錄文件下的 conf 文件夾,修改其中的 settings.xml 配置文件
更改默認的本地倉庫地址(目錄自己新建)
更換鏡像網站(提升下載速度)
<!-- 注意添加到 mirrors 標簽域內 -->
<!--設置阿里雲鏡像-->
<mirror>
<id>alimaven</id>
<mirrorOf>central</mirrorOf>
<name>aliyun maven</name>
<url>http://maven.aliyun.com/nexus/content/repositories/central/</url>
</mirror>
聲明 JDK 版本
<!-- 注意添加到 profiles 標簽域內 -->
<!--java版本-->
<profile>
<id>jdk-1.8</id>
<activation>
<activeByDefault>true</activeByDefault>
<jdk>1.8</jdk>
</activation>
<properties>
<maven.compiler.source>1.8</maven.compiler.source>
<maven.compiler.target>1.8</maven.compiler.target>
<maven.compiler.compilerVersion>1.8</maven.compiler.compilerVersion>
</properties>
</profile>
保存配置,在 powershell 中輸入 maven 倉庫更新指令 mvn help:system
,出現
打開自建的倉庫目錄可見
注意:
這里下載的並不是所有將來可能會用到的 JAR ,以后若使用到未下載的 JAR 包,IDEA 會提示更新倉庫(下面有講到)
有了 Maven 這個項目管理神器,我們可以將精力放在對於程序的開發上面來,不需要為各種框架的依賴而煩惱。下面講如何使用 IDEA 和 Maven 進行我們的開發工作。
1.3 使用 IDEA 創建自己的項目並利用 Maven 引入需要的依賴
新建一個項目,取名為 seckill ,並在其中添加 maven 框架依賴,選擇使用原型創建
使用自行解壓的版本,不使用與 IDEA 綁定的版本
IDEA 中環境自動配置的 Servlet2.3 jsp 的 el 表達式是不工作的,手動更換版本,把 WEB-INF 下的 web.xml 更改為
<?xml version="1.0" encoding="UTF-8"?>
<web-app xmlns="http://xmlns.jcp.org/xml/ns/javaee"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://xmlns.jcp.org/xml/ns/javaee http://xmlns.jcp.org/xml/ns/javaee/web-app_4_0.xsd"
version="4.0">
</web-app>
手動向 maven 的配置文件中添加相應的依賴,IDEA 添加時可能因為沒有相應的 JAR 包而報錯標紅,但是並不影響,在全部添加完畢后點擊 maven 的同步按鈕就會自動的將需要的 JAR 包添加到本地目錄里面。(后面需要用到的依賴也一並在此添加了)
<dependencies>
<dependency>
<groupId>junit</groupId>
<artifactId>junit</artifactId>
<version>4.12</version>
<scope>test</scope>
</dependency>
<!-- 補全項目依賴 -->
<!-- 1、日志 java 日志:slf4j,log4j,logback,common-logging
slf4j 是規范/接口
日志實現:log4j,logback,common-logging
使用:slf4j + logback
-->
<!-- 添加 slf4j 依賴,不然 logback 不能用 -->
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-api</artifactId>
<version>1.7.5</version>
</dependency>
<!-- 添加 logback-core 核心依賴 -->
<dependency>
<groupId>ch.qos.logback</groupId>
<artifactId>logback-core</artifactId>
<version>1.2.3</version>
</dependency>
<!-- 添加 logback-classic 依賴,實現並整合 slf4j 接口 -->
<dependency>
<groupId>ch.qos.logback</groupId>
<artifactId>logback-classic</artifactId>
<version>1.2.3</version>
</dependency>
<!-- 2、添加數據庫相關依賴 -->
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<version>8.0.22</version>
<scope>runtime</scope>
</dependency>
<!-- 優化鏈接反饋 -->
<!-- https://mvnrepository.com/artifact/com.mchange/c3p0 -->
<!-- <dependency>-->
<!-- <groupId>c3p0</groupId>-->
<!-- <artifactId>c3p0</artifactId>-->
<!-- <version>0.9.1.2</version>-->
<!-- </dependency>-->
<dependency>
<groupId>com.mchange</groupId>
<artifactId>c3p0</artifactId>
<version>0.9.5.5</version>
</dependency>
<!-- 3、DAO層:MyBatis 依賴 -->
<dependency>
<groupId>org.mybatis</groupId>
<artifactId>mybatis</artifactId>
<version>3.5.6</version>
</dependency>
<!-- MyBatis 自身實現的 Spring 依賴 -->
<dependency>
<groupId>org.mybatis</groupId>
<artifactId>mybatis-spring</artifactId>
<version>2.0.6</version>
</dependency>
<!-- Servlet Web 相關依賴 -->
<!-- 引入相關 jsp 標簽 -->
<dependency>
<groupId>taglibs</groupId>
<artifactId>standard</artifactId>
<version>1.1.2</version>
</dependency>
<dependency>
<groupId>jstl</groupId>
<artifactId>jstl</artifactId>
<version>1.2</version>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<version>2.9.5</version>
</dependency>
<dependency>
<groupId>javax.servlet</groupId>
<artifactId>javax.servlet-api</artifactId>
<version>4.0.0</version>
</dependency>
<!-- 4、Spring 依賴 -->
<!-- 1)核心依賴 -->
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-core</artifactId>
<version>5.2.12.RELEASE</version>
</dependency>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-beans</artifactId>
<version>5.2.12.RELEASE</version>
</dependency>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-context</artifactId>
<version>5.2.12.RELEASE</version>
</dependency>
<!-- 2) spring DAO 層依賴 -->
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-jdbc</artifactId>
<version>5.2.12.RELEASE</version>
</dependency>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-tx</artifactId>
<version>5.2.12.RELEASE</version>
</dependency>
<!-- 3)spring web 相關依賴 -->
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-web</artifactId>
<version>5.2.12.RELEASE</version>
</dependency>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-webmvc</artifactId>
<version>5.2.12.RELEASE</version>
</dependency>
<!-- 4)spring test 相關依賴 -->
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-test</artifactId>
<version>5.2.12.RELEASE</version>
</dependency>
<!-- redis 客戶端:Jedis 依賴 -->
<dependency>
<groupId>redis.clients</groupId>
<artifactId>jedis</artifactId>
<version>3.5.1</version>
</dependency>
<!-- 序列化操作依賴 -->
<dependency>
<groupId>com.dyuproject.protostuff</groupId>
<artifactId>protostuff-core</artifactId>
<version>1.1.6</version>
</dependency>
<dependency>
<groupId>com.dyuproject.protostuff</groupId>
<artifactId>protostuff-runtime</artifactId>
<version>1.1.6</version>
</dependency>
<dependency>
<groupId>commons-collections</groupId>
<artifactId>commons-collections</artifactId>
<version>3.2</version>
</dependency>
</dependencies>
1.4 Mybatis 的配置
從 Maven 配置文件中可以看到,我們引入了對 Mybatis 框架的支持
那么接下來就是對 Mybatis 的配置
在 main 文件夾下新建 resources 文件夾用於存放我們的各種配置文件,並新建 mybatis-config.xml
<!-- 標准的 xml 文檔開頭 -->
<?xml version="1.0" encoding="UTF-8" ?>
<!-- Mybatis 配置文件開頭,不用記,去官網的開發文檔上隨便找個示例 copy 過來就行 -->
<!DOCTYPE configuration
PUBLIC "-//mybatis.org//DTD Config 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-config.dtd">
<configuration>
<!-- 配置全局屬性 -->
<settings>
<!-- 配置使用 JDBC 的 getGenerationKeys 獲取數據庫的自增主鍵值 -->
<setting name="useGeneratedKeys" value="true"/>
<!-- 使用列別名替換列名 ,默認為 true -->
<setting name="useColumnLabel" value="true"/>
<!-- 開啟駝峰命名轉換,這個屬性會幫我們將 seckillId < - > seckill_id 互相轉換 -->
<setting name="mapUnderscoreToCamelCase" value="true"/>
</settings>
</configuration>
順便把 logback 也配置了
logback.xml
<?xml version="1.0" encoding="UTF-8" ?>
<configuration>
<appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>%d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern>
</encoder>
</appender>
<root level="debug">
<appender-ref ref="STDOUT" />
</root>
</configuration>
至此,項目初期的開發條件便算是備齊了,那么下一步進行我們業務邏輯的分析,為之后的開發理清楚頭緒。
二、秒殺系統業務邏輯梳理
2.1 整體業務邏輯梳理
從上圖中可以看到,我們的秒殺系統存在着三個角色:商家、倉庫和用戶。
- 商家:負責向倉庫中添加和調整商品的數量和價格;
- 倉庫:進行貨物的暫時存儲以及將貨物展現給用戶瀏覽的作用;
- 用戶:則扮演着購買者的角色,對商品發起秒殺聲明占有權;
將其抽象到開發過程中,對應關系則可以表示為:
- 商家 -> 數據庫管理人員
- 倉庫 -> 數據庫
- 用戶 -> 買家
可以看出,倉庫(數據庫)在中間扮演者橋梁的作用,將買賣雙方鏈接在了一起,同時也是整個業務的核心部件,一起的操作都是圍繞着對數據庫的操作進行的。同樣的以后的優化也是對數據庫的操作進行着優化。
2.2 用戶層邏輯梳理
用戶在整個業務中產生的事務操作,可以籠統的概括為以上的行為。即:
/**
*用戶發起秒殺 -> 平台對用戶信息進行核實 -> 核驗成功進入購買邏輯*(不成功則拒絕訪問) -> 記錄購買行為,以及減庫存操作 -> 事務管*理機制核驗事務是否執行成功 -> 成功則進行數據落地(秒殺成功),反之則進行事務回滾(秒殺失敗)
*/
在上面的秒殺過程中便產生了購買行為,那么在本業務中什么是購買行為呢?如下:
購買行為中記錄了購買者的各項標志性信息,以供我們去控制購買行為正常合理的發生。對於用戶產生的購買行為的管控也是本業務開發的難點。
上圖描述的是多個用戶對同一款商品進行秒殺產生的高並發問題,是后面開發的需要優化的問題。
2.3 業務功能的梳理
這里做一個簡單的系統的功能分析。同樣的,對於功能的梳理也是站在業務邏輯的角色層面上決定要開發什么功能。
數據庫:DAO 的接口設計、數據庫表的設計、如何通過 MyBatis 實現 DAO
后端:Service 的接口設計,通過 Spring 去管理 Service 的接口、通過 Spring 的聲明式事務控制簡化 Service 層的事務控制
前端:Web 設計、前端交互界面的設計
經過這幾節的內容,相信我們對於這個業務已經有了初步的認識,下面進入到我們的開發流程。
三、DAO 層的開發
整個秒殺系統都是圍繞着對數據庫的操作進行開發,那么至關重要的便是對於數據層的接口設計,也就是我們的 DAO 層接口設計。在我們設計接口的時候應該站在使用者(也就是用戶)的角度來思考接口應該怎么樣去設計,應該包含哪些功能。在這個秒殺系統中必不可少的是用戶進行秒殺后引發的一系列事務,那么如何將具體的操作抽象為我們的方法,就是我們需要考慮的事情了。
3.1 數據庫設計
根據我們上面提到的,數據庫的作用是為了存儲商品信息和購買成功的買家明細,那么數據庫的設計也應該圍繞着這兩個方面設計:
- 對於商品:
- 商品的唯一性標識,也就是商品的 ID
- 商品的名稱 name
- 商品的價格price(本系統未涉及)
- 商品的秒殺開始( start_time )和結束時間(end_time)
- 商品的創建時間(create_time)
- 商品的數量 (number)
- 對於買家:
- 買家的唯一性標識,手機號碼 Phone
- 買家所購買的商品 ID
- 買家購買的時間 create_time
那么有了對各個表單的屬性梳理,接下來就是去創建我們的數據庫了。
3.1.1 項目的創建
在 main 文件夾下新建 sql 文件夾,里面用來存放項目要用到的 sql 文件,如圖:
在文件夾下新建我們的 sql 文件 schema.sql (這里我使用的是 MySQL 8.0 版本數據庫,讀者可自行選擇)
3.1.2 數據庫的創建
-- 創建數據庫
CREATE DATABASE seckill;
-- 使用數據庫
USE seckill;
-- 創建秒殺商品表單,
-- 這里需要注意的是,對數據庫字段的引用需要使用反引號``(也就是鍵盤 esc 下面的那個,不然會報語法錯誤),COMMENT 注釋用 '' 平常的單引號
-- 在創建完畢表單過后可以使用 show create table table_name 來查看具體創建表單的 sql 語句是什么
CREATE TABLE seckill;
(
`seckill_id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '商品id',
`name` VARCHAR(120) NOT NULL COMMENT '商品名',
`number` BIGINT NOT NULL COMMENT '商品數量',
`start_time` TIMESTAMP NOT NULL COMMENT '開始時間',
`end_time` TIMESTAMP NOT NULL COMMENT '結束時間',
`create_time` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '創建時間', -- 當 TIMESTAMP 類型設置了 DEFAULT CURRENT_TIMESTAMP 屬性過后,如果不指定值,則會用當前時間進行填充
PRIMARY KEY (seckill_id),
KEY idx_start_time (start_time),
KEY idx_end_time (end_time),
KEY idx_create_time (create_time)
)ENGINE = InnoDB
AUTO_INCREMENT = 1000 -- 讓表中的自增字段從 1000 開始自增
DEFAULT CHARSET = utf8 COMMENT = '秒殺庫存表' -- 注意:字符編碼格式是 utf8 不是 utf-8
-- 初始化數據
-- create_time 是自動填充,所以我們這里就不管了
INSERT INTO seckill (name,number,start_time,end_time)
values ('1000元秒殺IPhone 12', 100, '2021-01-18 00:00:00', '2021-01-19 00:00:00'),
('10元秒殺垃圾袋', 1000, '2021-01-16 00:40:00', '2021-01-19 00:00:00'),
('36元秒殺大米', 10000, '2021-01-17 08:00:00', '2021-01-20 00:00:00'),
('500元秒殺大會員', 999, '2021-01-18 10:00:00', '2021-01-25 00:00:00');
-- 創建秒殺成功明細表,記錄用戶的相關信息
CREATE TABLE success_killed
(
`seckill_id` BIGINT NOT NULL COMMENT '商品 ID ', -- 這里不設置自增是因為后面的 ID 值都是記錄傳過來的值,所以不需要自增
`user_phone` BIGINT NOT NOLL COMMENT '用戶手機號',
`state` TINYINT NOT NULL DEFAULT 0 COMMENT '秒殺單狀態', -- 這里設計了一個狀態標識碼,用來識別秒殺單的狀態,具體狀態值后面通過枚舉類來設計,先寫個0占位
`create_time` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '創建時間', -- 跟上面一樣,自動填充
PRIMARY KEY (seckill_id,user_phone), -- 聯合主鍵,保證秒殺信息的唯一性
KEY idx_create_time (create_time)
)ENGINE = InnoDB
DEFAULT CHARSET = utf8 COMMENT = '秒殺成功明細表';
右鍵執行,有錯改錯,無錯則可以 show 一下創建的 sql 語句,可以看到
同理,success_killed 表單也可以看一下
然后查看一下表單里的數據
success_killed 表單中因為我們並沒有進行添加所以應該是空的。
下面進行兩個表單對應的數據實體開發。
3.2 數據庫對應實體的開發
數據實體的開發對應的是相應的數據庫,數據庫中的字段也就對應着實體中的變量。
在main文件夾下創建目錄 java.org.seckill.entity 用來存放我們的數據實體。然后新建兩個數據庫對應的實體類 SecKill 和 SuccessKilled ,聲明變量並創建相應的 getter 和setter 方法。為了后面的顯示方便,重寫 toString 方法
SecKill
package org.seckill.entity;
import java.util.Date;
public class SecKill {
private long secKillId;
private String name;
private int number;
private Date startTime;
private Date endTime;
private Date createTime;
public long getSecKillId() {
return secKillId;
}
public void setSecKillId(long secKillId) {
this.secKillId = secKillId;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public int getNumber() {
return number;
}
public void setNumber(int number) {
this.number = number;
}
public Date getStartTime() {
return startTime;
}
public void setStartTime(Date startTime) {
this.startTime = startTime;
}
public Date getEndTime() {
return endTime;
}
public void setEndTime(Date endTime) {
this.endTime = endTime;
}
public Date getCreateTime() {
return createTime;
}
public void setCreateTime(Date createTime) {
this.createTime = createTime;
}
@Override
public String toString() {
return "SecKill{" +
"secKillId=" + secKillId +
", name='" + name + '\'' +
", number=" + number +
", startTime=" + startTime +
", endTime=" + endTime +
", createTime=" + createTime +
'}';
}
}
SuccessKilled
package org.seckill.entity;
import java.util.Date;
public class SuccessKilled {
private long secKillId;
private long userPhone;
private short state;
private Date createTime;
private SecKill secKill;
public SecKill getSecKill() {
return secKill;
}
public void setSecKill(SecKill secKill) {
this.secKill = secKill;
}
public long getSecKillId() {
return secKillId;
}
public void setSecKillId(long secKillId) {
this.secKillId = secKillId;
}
public long getUserPhone() {
return userPhone;
}
public void setUserPhone(long userPhone) {
this.userPhone = userPhone;
}
public short getState() {
return state;
}
public void setState(short state) {
this.state = state;
}
public Date getCreateTime() {
return createTime;
}
public void setCreateTime(Date createTime) {
this.createTime = createTime;
}
@Override
public String toString() {
return "SuccessKilled{" +
"secKillId=" + secKillId +
", userPhone=" + userPhone +
", state=" + state +
", createTime=" + createTime +
'}';
}
}
3.3 數據庫訪問對象 DAO 層的開發
對數據庫訪問對象的開發應該圍繞着對相應的實體進行操作,那么我們的開發目的就很明確了,無非提供對實體的增刪改查操作。有了這個思路,下面就來進行我們的開發。
3.3.1 DAO 層接口的開發
首先創建目錄 main.org.seckill.dao
用來存放我們的接口
SecKillDao
package org.seckill.dao;
import org.apache.ibatis.annotations.Param;
import org.seckill.entity.SecKill;
import java.util.Date;
import java.util.List;
import java.util.Map;
// 這里的方法名中的參數名前面都加了個 @Param 的注解,意思是這個參數被引用的時候的參數名,建議都加上
// 一般來說接口是為了為相應的實體進行操作的
public interface SecKillDao {
/**
* 減少庫存
* @param secKillId 秒殺商品ID
* @param killTime 秒殺時間
* @return
*/
int reduceNumber(@Param("secKillId") long secKillId,@Param("killTime") Date killTime);
/**
* 根據 ID 查詢秒殺對象
* @param secKillId
* @return
*/
SecKill queryById(@Param("secKillId") long secKillId);
/**
* 根據偏移量查詢秒殺列表
* @param offset
* @param limit
* @return
*/
List<SecKill> queryAll(@Param("offset") int offset,@Param("limit") int limit);
}
SuccessKilledDao
package org.seckill.dao;
import org.apache.ibatis.annotations.Param;
import org.seckill.entity.SuccessKilled;
import java.util.Date;
public interface SuccessKilledDao {
/**
* 插入購買明細,可過濾重復
* @param secKillId
* @param userPhone
* @return
*/
int insertSuccessKilled(@Param("secKillId") long secKillId , @Param("userPhone") long userPhone );
/**
* 根據 id 查詢 SuccessKilled 並攜帶秒殺產品對象實體
* @param secKillId
* @return
*/
SuccessKilled queryByIdWithSecKill(@Param("secKillId") long secKillId,@Param("userPhone") long userPhone);
}
接口的實現我們交由 Mybatis 來幫我們實現,那么接下來就利用 Mybatis 來幫我們實現我們的接口。
3.3.2 Mybatis 實現 DAO 接口
我們這里只用簡單的實現一下接口就行了,如果想要要了解更多更深入的應用,建議去翻閱 Mybatis 的官方文檔。
在 resources 文件夾下創建 mapper 文件夾存放 Mybatis 的 xml 配置文件
SecKillDao.xml
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<!-- mapper 的
namespace 屬性值是我們將要實現接口的地址
id 屬性值是我們實現的接口中的方法名
resultType 屬性值是着實現的方法的返回值類型
parameterType 屬性值是參數類型,可以省略
-->
<mapper namespace="org.seckill.dao.SecKillDao">
<update id="reduceNumber">
# 需要注意的是,mapper 中包含的 sql 語句只能是單條語句,也就是不能有分號,不然會報錯,血的教訓,這也是我為什么要注釋而不是刪掉的原因
# use seckill;
update
seckill.seckill s
set number = number - 1
where seckill_id = #{secKillId}
and start_time <![CDATA[ <= ]]> #{killTime}
and end_time >= #{killTime}
and number > 0
</update>
<select id="queryById" resultType="SecKill" parameterType="long">
# use seckill;
select seckill_id,name,number,start_time,end_time,create_time
from seckill.seckill
where seckill_id = #{secKillId}
</select>
<select id="queryAll" resultType="SecKill">
# use seckill;
select `seckill_id`, `name`, `number`, `start_time`, `end_time`, `create_time`
from seckill.seckill
order by `create_time` desc
limit #{offset,jdbcType=INTEGER},#{limit,jdbcType=INTEGER}
</select>
</mapper>
SuccessKilled.xml
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="org.seckill.dao.SuccessKilledDao">
<insert id="insertSuccessKilled">
# 這里的 ignore 限定了插入記錄不能重復
insert ignore into seckill.success_killed(seckill_id, user_phone)
values (#{secKillId}, #{userPhone})
</insert>
<select id="queryByIdWithSecKill" resultType="SuccessKilled">
select sk.seckill_id,
sk.user_phone,
sk.state,
sk.create_time,
s.seckill_id "seckill.seckill_id",
s.name "seckill.name",
s.number "seckill.number",
s.start_time "seckill.start_time",
s.end_time "seckill.end_time",
s.create_time "seckill.create_time"
from seckill.success_killed sk
<!-- 聯合查詢 將 seckill 別名命名為 s ,將 successed_kill 別名命名為 sk -->
inner join seckill.seckill s on sk.seckill_id = s.seckill_id
where sk.seckill_id = #{secKillId} and sk.user_phone = #{userPhone}
</select>
</mapper>
接口的實現到此告一段落,但是我們會發現這跟我們用 JDBC 的實現接口的時候並不一樣,最大的不一樣就是沒有數據庫連接的操作!那么是我們省略掉了?Mybatis 連這個都幫我們做了?猜對了一半, Mybatis 當然不可能聰明到能夠讀取我們的心理,從而直接猜到我們需要鏈接的數據庫是哪個,甚至連用戶名密碼都知道了,如果真是這樣,那人工智能覺醒就在現在~
回到項目上來,如果 Mybatis 沒有幫我們做數據庫的連接,那意味着我們還是要自己手動配置,也就是我們下面要說到的:Spring 整合 Mybatis。
3.4 Spring 整合 Mybatis
有 Spring 基礎的伙伴們應該都知道 Spring 是拿來干什么的。當我們在 Spring 中配置好了我們的 bean 對象過后,可以通過注解或者 xml 的方式將創建好的 bean 對象注入到我們需要的地方。
我們通過上面的 Mybatis 對 DAO 接口進行了實現,省去了我們創建實現類的操作,那么問題來了,Mybatis 實現的類要怎么去使用呢?這就需要用到 Spring 了,我們通過配置創建實現類的 bean 對象,那么在需要用到實現類的時候,通過注解的方式就可以將實現類注入到我們需要的地方。
3.4.1 Spring 的配置
在 resources 文件夾下創建一個數據庫參數的 jdbc.properties 文件
# properties 里面的所有數據都是以鍵值對的方式進行存儲的,value 值都不用雙引號,並且后面不能跟空格,輸完直接回車
# 數據庫驅動
driver = com.mysql.cj.jdbc.Driver
# 數據庫連接
# jdbc:mysql://127.0.0.1:3306/test?useUnicode=true&characterEncoding=UTF-8&serverTimezone=UTC 解決時區和中文亂碼問題
jdbcUrl = jdbc:mysql://localhost:3306/seckill?serverTimezone=UTC
# 自行輸入自己的,輸入注意事項見第一行
# USER_NAME
user_name = xxxxx
# PWD
pwd = xxxxxxx
新建一個文件夾 spring 用來存放 Spring 的配置文件
spring-dao.xml
<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns:context="http://www.springframework.org/schema/context"
xsi:schemaLocation="http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans.xsd http://www.springframework.org/schema/context https://www.springframework.org/schema/context/spring-context.xsd">
<!-- 定義參數掃描地址,用來注入類似於 JDBC 連接池的參數的 -->
<context:property-placeholder location="classpath:jdbc.properties"/>
<bean id="dataSource" class="com.mchange.v2.c3p0.ComboPooledDataSource">
<!-- 配置連接池屬性 -->
<property name="driverClass" value="${driver}"/>
<property name="jdbcUrl" value="${jdbcUrl}"/>
<property name="user" value="${user_name}"/>
<property name="password" value="${pwd}"/>
<!-- c3p0 連接池的私有屬性 -->
<property name="maxPoolSize" value="30"/>
<property name="minPoolSize" value="10"/>
<!-- 關閉連接后不自動提交,默認是 false ,但還是自己配置一遍,告訴自己有這么個玩意 -->
<property name="autoCommitOnClose" value="false"/>
<!-- 設定連接超時時間,默認為 0,也就是無限等待 -->
<property name="checkoutTimeout" value="1000"/>
<!-- 設定連接失敗后重試次數 -->
<property name="acquireRetryAttempts" value="2"/>
</bean>
<!-- 配置 SqlSessionFactory 對象 -->
<bean id="sqlSessionFactory" class="org.mybatis.spring.SqlSessionFactoryBean">
<!-- 注入數據庫連接池,會在生成 bean 對象時自動注入 dataSource 對象 -->
<property name="dataSource" ref="dataSource"/>
<!-- 配置 MyBatis 全局配置文件的位置 -->
<property name="configLocation" value="classpath:mybatis-config.xml"/>
<!-- 掃描 entity 包下的類,並生成他們的別名 -->
<property name="typeAliasesPackage" value="org.seckill.entity"/>
<!-- 掃描 sql 配置文件:mapper 需要的 xml 文件 -->
<property name="mapperLocations" value="classpath:mapper/*.xml"/>
</bean>
<!-- 配置掃描 Dao 接口包,動態實現 Dao 接口,注入到 Spring 容器中 -->
<bean class="org.mybatis.spring.mapper.MapperScannerConfigurer">
<!-- 注入 sqlSessionFactory 對象 -->
<property name="sqlSessionFactoryBeanName" value="sqlSessionFactory"/>
<!-- 給出需要掃描的 Dao 接口包 -->
<property name="basePackage" value="org.seckill.dao"/>
</bean>
</beans>
3.5 單元測試
在 DAO 接口界面右鍵生成單元測試類,選擇全部方法
另外一個接口也一樣。生成后編寫測試方法
SecKillDaoTest
package org.seckill.dao;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.seckill.entity.SecKill;
import org.springframework.test.context.ContextConfiguration;
import org.springframework.test.context.junit4.SpringJUnit4ClassRunner;
import javax.annotation.Resource;
import static org.junit.Assert.*;
/**
* 配置 Spring 和 JUnit 整合,JUnit 啟動時加載 SpringIOC 容器
*/
@RunWith(SpringJUnit4ClassRunner.class)
@ContextConfiguration("classpath:spring/spring-dao.xml")
public class SecKillDaoTest {
@Resource
private SecKillDao secKillDao;
@Test
public void queryById() {
long id = 1000;
SecKill secKill = secKillDao.queryById(id);
System.out.println(secKill.getName());
System.out.println(secKill);
}
@Test
public void reduceNumber() {
}
@Test
public void queryAll() {
System.out.println(secKillDao.queryAll(0,2));
}
}
SuccessKilledDaoTest
package org.seckill.dao;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.seckill.entity.SuccessKilled;
import org.springframework.test.context.ContextConfiguration;
import org.springframework.test.context.junit4.SpringJUnit4ClassRunner;
import javax.annotation.Resource;
import static org.junit.Assert.*;
@RunWith(SpringJUnit4ClassRunner.class)
@ContextConfiguration("classpath:spring/spring-dao.xml")
public class SuccessKilledDaoTest {
@Resource
private SuccessKilledDao successKilledDao;
@Test
public void insertSuccessKilled() {
successKilledDao.insertSuccessKilled(1000,13228217105L);
}
@Test
public void queryByIdWithSecKill() {
SuccessKilled successKilled = successKilledDao.queryByIdWithSecKill(1000,13228217105L);
System.out.println(successKilled);
System.out.println(successKilled.getSecKill());
}
/**
* SuccessKilled{secKillId=1000,
* userPhone=13228217105,
* state=0,
* createTime=Mon Jan 25 19:23:26 CST 2021}
*
* SecKill{secKillId=1000,
* name='1000元秒殺IPhone 12',
* number=100,
* startTime=Mon
* Jan 18 08:00:00 CST 2021,
* endTime=Tue Jan 19 08:00:00 CST 2021,
* createTime=Mon Jan 18 00:50:38 CST 2021}
*/
}
如果測試出現錯誤可以參照我的錯誤信息以及修改方法進行修改 在 Spring 整合 Mybatis 中出現的問題及解決方案 ,但應該是不會出現我同樣的錯誤,畢竟我都改了是吧^^
其實就我們現在這個層面,最難的反而不是邏輯代碼的開發,而是環境的配置,因為不熟練。熟而生巧,道阻且長。
那么下面進入到我們 service 層的開發流程
四、SERVICE 的開發
前面提到,DAO 層的編寫主要是為了實現對數據庫的操作。而提供對 DAO 邏輯操作的代碼則應該放在 Service 層中來編寫,這樣可以讓我們的項目結構更加的清晰分明。也方便以后的維護和更改。
4.1 Service 接口的設計
Service 層的編寫跟 DAO 層的編寫大同小異,首先都應該對其應該提供的功能進行分析,這樣能幫助我們更好的去設計編寫 Service 層的代碼。
對於接口的設計,開發人員需要站在使用者的角度去設計接口。也就是用戶將要使用到那些功能,從這個角度去設計接口,可以減少我們編寫的接口冗余度。同時,對於接口的開發應該注意三方面:
- 方法定義的粒度:我們設計的方法的功能應該越明確越好,不要想着用一個方法就可以把所有事情一起干了
- 參數:一個方法的參數應該越簡單越好,這樣方便我們對數據進行更好的處理
- 返回值:返回值也應該越簡約越好,如果需要返回很多不同類型的數據,我們可以考慮將其封裝成一個類;同樣的,返回值也可以是一個異常,在開發中某些異常是允許存在的,他可以幫助我們更好的去跟蹤程序動態
那么基於以上的原則,我們這個簡單的秒殺系統的功能分析就不算太難了:
- getSeckillList() & getById():考慮到 web 端的商品總單和單個商品詳情頁的需求,應該設計查詢方法
- exportSeckillUrl():顯示商品的詳細秒殺信息(如果開啟返回秒殺接口,反之則返回開始時間)
- executeSeckill():當秒殺開始后執行秒殺操作
一個最基本的秒殺系統應該擁有的功能已經分析完畢,下面進行具體代碼的編寫。
4.2 Service 層涉及代碼編寫
先創建出需要的項目目錄結構,用以存放我們的代碼。
- DTO 包:用以存放 web 與 server 通信數據的實體類,跟 entity 中存放的類似,只不過通信的對象不一樣
- exception 包:用來存放我們自定義的異常類
- service 包(下屬創建 impl 包,用來存放接口實現類):用來存放 service 邏輯代碼
在 service 包下新建 SeckillService 接口
SeckillService
package org.seckill.service;
import org.seckill.dto.Exposer;
import org.seckill.dto.SeckillExecution;
import org.seckill.entity.SecKill;
import org.seckill.exception.RepeatKillException;
import org.seckill.exception.SeckillCloseException;
import org.seckill.exception.SeckillException;
import java.util.List;
/**
* 業務接口:站在使用者的角度設計接口
* 如何實現站在使用者角度進行開發:
* 1、方法定義粒度:也就是具體到每一個功能
* 2、參數:簡單明確的傳遞
* 3、返回類型:類型友好,簡單(return/異常)
*/
public interface SeckillService {
/**
* 查詢所有的秒殺庫存
*
* @return
*/
List<SecKill> getSeckillList();
/**
* 查詢單個的秒殺商品
*
* @param seckillId
* @return
*/
SecKill getById(long seckillId);
/**
* 秒殺開啟時輸出秒殺接口,
* 否則輸出系統時間和秒殺時間
*
* @param seckillId
*/
Exposer exportSeckillUrl(long seckillId);
/**
* 秒殺執行操作
* @param seckillId
* @param userPhone
* @param md5
*/
SeckillExecution executeSeckill(long seckillId, long userPhone, String md5)
throws SeckillException, RepeatKillException, SeckillCloseException;
/**
* 執行秒殺操作 by 存儲過程
* @param seckillId
* @param userPhone
* @param md5
* @return
* @throws SeckillException
* @throws RepeatKillException
* @throws SeckillCloseException
*/
SeckillExecution executeSeckillProcedure(long seckillId, long userPhone, String md5);
}
上面新引入了兩個數據類,因為這屬於 web - server 的通信,所以將其放進 dto 包內。同時也拋出了三個自定義異常,在 exception 中編寫我們的自定義異常。
Exposer
package org.seckill.dto;
/**
* 暴露秒殺接口 DTO
*/
public class Exposer {
// 秒殺開啟標識
private boolean exposed;
// md5 加密
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;
}
@Override
public String toString() {
return "Exposer{" +
"exposed=" + exposed +
", md5='" + md5 + '\'' +
", seckillId=" + seckillId +
", now=" + now +
", start=" + start +
", end=" + end +
'}';
}
public boolean isExposed() {
return exposed;
}
public void setExposed(boolean exposed) {
this.exposed = exposed;
}
public String getMd5() {
return md5;
}
public void setMd5(String md5) {
this.md5 = md5;
}
public long getSeckillId() {
return seckillId;
}
public void setSeckillId(long seckillId) {
this.seckillId = seckillId;
}
public long getNow() {
return now;
}
public void setNow(long now) {
this.now = now;
}
public long getStart() {
return start;
}
public void setStart(long start) {
this.start = start;
}
public long getEnd() {
return end;
}
public void setEnd(long end) {
this.end = end;
}
}
SeckillExecution
package org.seckill.dto;
import org.seckill.entity.SuccessKilled;
import org.seckill.enums.SeckillStatEnum;
/**
* 封裝秒殺執行后的結果
*/
public class SeckillExecution {
private long seckillId;
// 秒殺執行結果狀態
private int state;
// 狀態表示
private String stateInfo;
// 秒殺成功對象
private SuccessKilled successKilled;
public SeckillExecution(long seckillId, SeckillStatEnum statEnum, SuccessKilled successKilled) {
this.seckillId = seckillId;
this.state = statEnum.getState();
this.stateInfo = statEnum.getStateInfo();
this.successKilled = successKilled;
}
public SeckillExecution(long seckillId, SeckillStatEnum statEnum) {
this.seckillId = seckillId;
this.state = statEnum.getState();
this.stateInfo = statEnum.getStateInfo();
}
@Override
public String toString() {
return "SeckillExecution{" +
"seckillId=" + seckillId +
", state=" + state +
", stateInfo='" + stateInfo + '\'' +
", successKilled=" + successKilled +
'}';
}
public long getSeckillId() {
return seckillId;
}
public void setSeckillId(long seckillId) {
this.seckillId = seckillId;
}
public int getState() {
return state;
}
public void setState(int state) {
this.state = state;
}
public String getStateInfo() {
return stateInfo;
}
public void setStateInfo(String stateInfo) {
this.stateInfo = stateInfo;
}
public SuccessKilled getSuccessKilled() {
return successKilled;
}
public void setSuccessKilled(SuccessKilled successKilled) {
this.successKilled = successKilled;
}
}
SeckillException
package org.seckill.exception;
/**
* 秒殺業務異常
*/
public class SeckillException extends RuntimeException{
public SeckillException(String message) {
super(message);
}
public SeckillException(String message, Throwable cause) {
super(message, cause);
}
}
RepeatKillException
package org.seckill.exception;
/**
* 重復秒殺異常(運行期異常)
*/
public class RepeatKillException extends SeckillException{
public RepeatKillException(String message) {
super(message);
}
public RepeatKillException(String message, Throwable cause) {
super(message, cause);
}
}
SeckillCloseException
package org.seckill.exception;
/**
* 秒殺關閉異常
*/
public class SeckillCloseException extends SeckillException{
public SeckillCloseException(String message) {
super(message);
}
public SeckillCloseException(String message, Throwable cause) {
super(message, cause);
}
}
4.3 Service 接口的實現
在實現類中我們會使用標志狀態的常量數據,所以先創建一個枚舉類來簡化數據操作
新建 enums 包,在包下新建 SeckillStatEnum 枚舉類
SeckillStatEnum
package org.seckill.enums;
/**
* 使用枚舉表述常量數據字段
*/
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 static SeckillStatEnum stateOf(int index) {
for (SeckillStatEnum state : values()) {
if (state.getState() == index) {
return state;
}
}
return null;
}
public int getState() {
return state;
}
public void setState(int state) {
this.state = state;
}
public String getStateInfo() {
return stateInfo;
}
public void setStateInfo(String stateInfo) {
this.stateInfo = stateInfo;
}
}
創建好枚舉類過后,下面進行實現類的編寫
SeckillServiceImpl
package org.seckill.service.impl;
import org.apache.commons.collections.MapUtils;
import org.seckill.dao.SecKillDao;
import org.seckill.dao.SuccessKilledDao;
import org.seckill.dao.cache.RedisDao;
import org.seckill.dto.Exposer;
import org.seckill.dto.SeckillExecution;
import org.seckill.entity.SecKill;
import org.seckill.entity.SuccessKilled;
import org.seckill.enums.SeckillStatEnum;
import org.seckill.exception.RepeatKillException;
import org.seckill.exception.SeckillCloseException;
import org.seckill.exception.SeckillException;
import org.seckill.service.SeckillService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.DigestUtils;
import java.util.Date;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@Service
public class SeckillServiceImpl implements SeckillService {
// MD5 鹽值字符串,用來混淆 MD5
private final String slat = "njxdchviangaidjvoamv#@%#%¥%%&%#%2387453";
private Logger logger = LoggerFactory.getLogger(this.getClass());
// 進行 Spring 注入
@Autowired
private SecKillDao secKillDao;
@Autowired
private SuccessKilledDao successKilledDao;
@Override
public List<SecKill> getSeckillList() {
return secKillDao.queryAll(0, 10); // 返回從第 0 條數據開始的 10 條數據
}
@Override
public SecKill getById(long seckillId) {
return secKillDao.queryById(seckillId);
}
@Override
public Exposer exportSeckillUrl(long seckillId) {
seckill = secKillDao.queryById(seckillId); // 通過傳來的 id 參數獲取對應的 seckill 對象
if (seckill == null) {
return new Exposer(false, seckillId);
} else {
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());
}
}
// 進行md5加密,不可逆
String md5 = getMD5(seckillId);
return new Exposer(true, md5, seckillId);
}
/**
* 使用存儲過程完成秒殺,這里是后面的高並發優化內容,存儲過程還沒有創建,所以這個方法暫時是沒用的
*
* @param seckillId
* @param userPhone
* @param md5
* @return
* @throws SeckillException
* @throws RepeatKillException
* @throws SeckillCloseException
*/
@Override
public SeckillExecution executeSeckillProcedure(long seckillId, long userPhone, String md5) {
if (md5 == null || !md5.equals(getMD5(seckillId))) {
// 當md5驗證失敗時,返回數據篡改異常
return new SeckillExecution(seckillId, SeckillStatEnum.DATA_REWRITE);
}
Date killTime = new Date();
Map<String, Object> map = new HashMap<>();
map.put("seckillId", seckillId);
map.put("phone", userPhone);
map.put("killTime", killTime);
map.put("result", null);
// 存儲過程執行完畢,result被賦值
try {
secKillDao.killByProcedure(map);
// 獲取result
Integer result = MapUtils.getInteger(map, "result", -2);
if (result == 1) {
SuccessKilled sk = successKilledDao.queryByIdWithSecKill(seckillId, userPhone);
return new SeckillExecution(seckillId, SeckillStatEnum.SUCCESS, sk);
} else {
return new SeckillExecution(seckillId, SeckillStatEnum.stateOf(result));
}
} catch (Exception e) {
logger.error(e.getMessage(), e);
return new SeckillExecution(seckillId, SeckillStatEnum.INNER_ERROR);
}
}
@Override
@Transactional
/**
* 使用注解控制事務方法 的優點:
* 1:開發團隊達成一致的約定,明確標注事務方法的編程風格
* 2:保證事務方法的執行時間盡可能的短,不要穿插其他的網絡操作或者剝離到事務方法外
* 3:不是所有的方法都需要事務,如果只有一條修改操作,或者只有只讀操作不需要事務控制
*/
public SeckillExecution executeSeckill(long seckillId, long userPhone, String md5) throws SeckillException, RepeatKillException, SeckillCloseException {
if (md5 == null || !md5.equals(getMD5(seckillId))) {
throw new SeckillException("seckill data rewrite");
}
// 執行秒殺業務邏輯:減庫存 + 記錄購買行為
Date nowTime = new Date();
try {
// 記錄購買行為
int insertCount = successKilledDao.insertSuccessKilled(seckillId, userPhone);
// 唯一:seckillId , userPhone
if (insertCount <= 0) {
// 重復秒殺
throw new RepeatKillException("seckill repeated");
} else {
// 熱點商品競爭
int updateCount = secKillDao.reduceNumber(seckillId, nowTime);
if (updateCount < 0) {
// 沒有更新到記錄,秒殺結束
throw new SeckillCloseException("seckill is closed");
} else {
// 秒殺成功
SuccessKilled successKilled = successKilledDao.queryByIdWithSecKill(seckillId, userPhone);
return new SeckillExecution(seckillId, SeckillStatEnum.SUCCESS, successKilled);
}
}
} catch (SeckillCloseException e1) {
throw e1;
} catch (RepeatKillException e2) {
throw e2;
} catch (Exception e) {
logger.error(e.getMessage(), e);
// 所有編譯器異常轉化為運行期異常
throw new SeckillException("seckill inner error " + e.getMessage());
}
}
private String getMD5(long seckillId) {
String base = seckillId + "/" + slat;
String md5 = DigestUtils.md5DigestAsHex(base.getBytes());
return md5;
}
}
4.4 使用 Spring 進行依賴注入
在 spring 的配置文件夾中新建一個配置文件 spring-service.xml
spring-service.xml
<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns:context="http://www.springframework.org/schema/context" xmlns:tx="http://www.springframework.org/schema/tx"
xsi:schemaLocation="http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans.xsd http://www.springframework.org/schema/context https://www.springframework.org/schema/context/spring-context.xsd http://www.springframework.org/schema/tx http://www.springframework.org/schema/tx/spring-tx.xsd">
<!-- 配置對 service 包下的所有類進行包掃描 -->
<context:component-scan base-package="org.seckill.service"/>
<!-- 配置聲明式事務管理器 -->
<bean id="transactionManager" class="org.springframework.jdbc.datasource.DataSourceTransactionManager">
<!-- 注入數據庫連接池,在Spring讀取配置文件時會導入 -->
<property name="dataSource" ref="dataSource"/>
</bean>
<!-- 配置基於注解的聲明式事務
默認使用注解來管理事務行為-->
<tx:annotation-driven transaction-manager="transactionManager"/>
</beans>
4.5 進行單元測試
進行單元測試之前,先配置一下 logback (這里只做簡單的配置,如果想要了解更加詳細的配置可以轉到官方文檔了解 Logback 中文文檔)
<?xml version="1.0" encoding="UTF-8" ?>
<configuration>
<appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>%d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern>
</encoder>
</appender>
<root level="debug">
<appender-ref ref="STDOUT" />
</root>
</configuration>
生成測試類 SeckillServiceIpmlTest
SeckillServiceIpmlTest
package org.seckill.service.impl;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.seckill.dto.Exposer;
import org.seckill.dto.SeckillExecution;
import org.seckill.entity.SecKill;
import org.seckill.exception.RepeatKillException;
import org.seckill.exception.SeckillCloseException;
import org.seckill.service.SeckillService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.test.context.ContextConfiguration;
import org.springframework.test.context.junit4.SpringJUnit4ClassRunner;
import java.util.List;
@RunWith(SpringJUnit4ClassRunner.class)
@ContextConfiguration({"classpath:spring/spring-dao.xml",
"classpath:spring/spring-service.xml"})
public class SeckillServiceImplTest {
private final Logger logger = LoggerFactory.getLogger(this.getClass());
@Autowired
private SeckillService seckillService;
@Test
public void getSeckillList() {
List<SecKill> list = seckillService.getSeckillList();
logger.info("list = {}", list);
}
@Test
public void getById() {
long id = 1000;
SecKill secKill = seckillService.getById(id);
logger.info("seckill = {}", secKill);
}
@Test
/**
* Exposer{exposed=true,
* md5='81c003e266701ca48287cd4c588fcac8',
* seckillId=1002,
* now=0,
* start=0,
* end=0}
*/
public void exportSeckillUrl() {
long id = 1003;
Exposer exposer = seckillService.exportSeckillUrl(id);
if (exposer.isExposed()) {
long phone = xxxxxxxx;
String md5 = exposer.getMd5();
try {
SeckillExecution execution = seckillService.executeSeckill(id, phone, md5);
logger.info("result={}", execution);
} catch (RepeatKillException e) {
logger.error(e.getMessage());
} catch (SeckillCloseException e) {
logger.error(e.getMessage());
}
} else {
// 秒殺未開啟
logger.warn("exposer = {}", exposer);
}
}
@Test
public void executeSeckillProcedure() {
long seckillId = 1002;
long phone = xxxxxxxxx;
Exposer exposer = seckillService.exportSeckillUrl(seckillId);
if (exposer.isExposed()) {
String md5 = exposer.getMd5();
SeckillExecution execution = seckillService.executeSeckillProcedure(seckillId, phone, md5);
logger.info(execution.getStateInfo());
}
}
// @Test
/**
* result=SeckillExecution{
* seckillId=1002,
* state=1,
* stateInfo='秒殺成功',
* successKilled=SuccessKilled{secKillId=1002,
* userPhone=xxxxxxxxx,
* state=-1,
* createTime=Tue Jan 26 23:16:53 CST 2021}}
*/
// public void executeSeckill() {
//
// }
}
單元測試通過后,進入web 層的開發。
五、WEB 層的開發
5.1 web 層的設計
web 層對應的是項目中的前端,本秒殺系統設計到的知識主要有以下幾點
web 層的流程邏輯
5.2 SpringMVC 的配置
在 webapp 文件夾下創建 web.xml 配置文件
web.xml
<?xml version="1.0" encoding="UTF-8"?>
<web-app xmlns="http://xmlns.jcp.org/xml/ns/javaee"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://xmlns.jcp.org/xml/ns/javaee http://xmlns.jcp.org/xml/ns/javaee/web-app_4_0.xsd"
version="4.0">
<!-- 配置 DispatcherServlet -->
<servlet>
<servlet-name>seckill-dispatcher</servlet-name>
<servlet-class>org.springframework.web.servlet.DispatcherServlet</servlet-class>
<!-- 配置 SpringMVC 需要加載的配置文件
spring-dao.xml,spring-server.xml,spring-web.xml
整合順序:Mybatis -> Spring -> springMVC -->
<init-param>
<param-name>contextConfigLocation</param-name>
<param-value>classpath:spring/spring-*.xml</param-value>
</init-param>
</servlet>
<servlet-mapping>
<servlet-name>seckill-dispatcher</servlet-name>
<!-- 默認匹配所有的請求 -->
<url-pattern>/</url-pattern>
</servlet-mapping>
</web-app>
在 spring 的配置文件夾下創建 spring-web.xml
<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns:context="http://www.springframework.org/schema/context"
xmlns:mvc="http://www.springframework.org/schema/mvc"
xsi:schemaLocation="http://www.springframework.org/schema/beans
https://www.springframework.org/schema/beans/spring-beans.xsd
http://www.springframework.org/schema/context
https://www.springframework.org/schema/context/spring-context.xsd http://www.springframework.org/schema/mvc https://www.springframework.org/schema/mvc/spring-mvc.xsd">
<!-- 配置 SpringMVC -->
<!-- 1:開啟SpringMVC注解模式
簡化配置:
(1)自動注冊 DefaultAnnotationHandlerMapping , AnnotationMethodHandlerAdapter
(2)默認提供了一系列功能:數據綁定,數字和日期的格式化format @NumberFormat,@DataTimeFormat,
xml,json 默認讀寫支持-->
<mvc:annotation-driven/>
<!-- servlet-mapping 映射路徑:"/"
靜態資源默認servlet配置
1:加入對靜態資源的處理:js,gif,png
2:允許使用“/”做整體處理-->
<mvc:default-servlet-handler/>
<!-- 3:配置jsp,顯示ViewResolver -->
<bean class="org.springframework.web.servlet.view.InternalResourceViewResolver">
<property name="viewClass" value="org.springframework.web.servlet.view.JstlView"/>
<property name="prefix" value="/WEB-INF/jsp/"/>
<property name="suffix" value=".jsp"/>
</bean>
<!-- 4:掃描web相關的bean -->
<context:component-scan base-package="org.seckill.web"/>
</beans>
5.3 使用 SpringMVC 實現 Restful 接口
新建一個 web 包,用來存放 spring 的 Controller 類
SeckillController
package org.seckill.web;
import org.seckill.dto.Exposer;
import org.seckill.dto.SeckillExecution;
import org.seckill.dto.SeckillResult;
import org.seckill.entity.SecKill;
import org.seckill.enums.SeckillStatEnum;
import org.seckill.exception.RepeatKillException;
import org.seckill.exception.SeckillCloseException;
import org.seckill.service.SeckillService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.*;
import java.util.Date;
import java.util.List;
@Controller
//@RequestMapping(value = "/seckill/")// url:/模塊/資源/{id}/細分 /seckill/list
// 這里寫在越外面的 @RequestMapping 在構成的 url 越靠前
public class SeckillController {
private Logger logger = LoggerFactory.getLogger(this.getClass());
@Autowired
private SeckillService seckillService;
@RequestMapping(value = "/list", method = RequestMethod.GET)
public String list(Model model) {
// 獲取列表頁
List<SecKill> list = seckillService.getSeckillList();
model.addAttribute("list", list);
//list.jsp + model = ModelAndView
return "list"; // /WEB-INF/jsp/"list".jsp
}
@RequestMapping(value = "/{secKillId}/detail", method = RequestMethod.GET)
public String detail(@PathVariable("secKillId") Long seckillId, Model model) {
if (seckillId == null) { // 當id不存在時,直接重定向到 list 頁
return "redirect:/list";
}
SecKill secKill = seckillService.getById(seckillId);
if (secKill == null) { // 當id對應的 seckill 對象不存在時,將請求轉發到 list
return "forward:/list";
}
model.addAttribute("secKill", secKill); // 返回數據 model
return "detail";
}
// ajax json
@RequestMapping(value = "/{seckillId}/exposer",
method = RequestMethod.POST,
produces = {"application/json;charset=UTF-8"})
@ResponseBody
public SeckillResult<Exposer> exposer(@PathVariable("seckillId") Long seckillId) {
SeckillResult<Exposer> result;
try {
Exposer exposer = seckillService.exportSeckillUrl(seckillId);
result = new SeckillResult<Exposer>(true, exposer); // 對應 state,exposer 構造器
} catch (Exception e) {
logger.error(e.getMessage(), e);
result = new SeckillResult<Exposer>(false, e.getMessage()); // 對應 state,error 構造器
}
return result;
}
@RequestMapping(value = "/{seckillId}/{md5}/execution",
method = RequestMethod.POST,
produces = {"application/json;charset=UTF-8"})
@ResponseBody // 告訴 Spring 將結果封裝成 json
public SeckillResult<SeckillExecution> execute(@PathVariable("seckillId") Long seckillId,
@PathVariable("md5") String md5,
@CookieValue(value = "killPhone", required = false) Long phone) {
if (phone == null) {
return new SeckillResult<SeckillExecution>(false, "未注冊");
}
SeckillResult<SeckillExecution> result;
try {
SeckillExecution execution = seckillService.executeSeckill(seckillId, phone, md5);
// 啟用存儲過程處理
//SeckillExecution execution = seckillService.executeSeckillProcedure(seckillId, phone, md5);
return new SeckillResult<SeckillExecution>(true, execution);
} catch (SeckillCloseException e) {
SeckillExecution execution = new SeckillExecution(seckillId, SeckillStatEnum.END);
return new SeckillResult<SeckillExecution>(false, execution);
} catch (RepeatKillException e) {
SeckillExecution execution = new SeckillExecution(seckillId, SeckillStatEnum.REPEAT_KILL);
return new SeckillResult<SeckillExecution>(false, execution);
} catch (Exception e) {
logger.error(e.getMessage(), e);
SeckillExecution execution = new SeckillExecution(seckillId, SeckillStatEnum.INNER_ERROR);
return new SeckillResult<SeckillExecution>(false, execution);
}
}
@RequestMapping(value = "/time/now",method = RequestMethod.GET)
@ResponseBody
public SeckillResult<Long> time(){
Date now = new Date();
return new SeckillResult(true,now.getTime());
}
}
在 DTO 包下新建一個 Result 類,用來封裝 json 的數據
SeckillResult
package org.seckill.dto;
//所有的 ajax 請求返回類型,封裝 json 結果
public class SeckillResult<T> {
private boolean success;
private T data;
private String error;
public SeckillResult(boolean success, String error) {
this.success = success;
this.error = error;
}
public SeckillResult(boolean success, T data) {
this.success = success;
this.data = data;
}
public boolean isSuccess() {
return success;
}
public void setSuccess(boolean success) {
this.success = success;
}
public T getData() {
return data;
}
public void setData(T data) {
this.data = data;
}
public String getError() {
return error;
}
public void setError(String error) {
this.error = error;
}
}
5.4 使用 bootstrap 開發頁面
Boot CDN Bootstrap 的 API 地址
Bootstrap 的頁面開發就是使用他提供的各種 API 進行簡單代碼編寫的過程,如果想要更加深層次的理解,可以參見以上的三個網站,這里只貼出系統需要的代碼
如圖新建文件,common 中存放的各 jsp 文件共用的部分
head.jsp
<%--
Created by IntelliJ IDEA.
User: 23720
Date: 2021/1/27
Time: 16:03
To change this template use File | Settings | File Templates.
--%>
<%@ page contentType="text/html;charset=UTF-8" language="java" %>
<html>
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1">
<!-- 上述3個meta標簽*必須*放在最前面,任何其他內容都*必須*跟隨其后! -->
<!-- Bootstrap -->
<link href="https://cdn.jsdelivr.net/npm/bootstrap@3.3.7/dist/css/bootstrap.min.css" rel="stylesheet">
<!-- HTML5 shim 和 Respond.js 是為了讓 IE8 支持 HTML5 元素和媒體查詢(media queries)功能 -->
<!-- 警告:通過 file:// 協議(就是直接將 html 頁面拖拽到瀏覽器中)訪問頁面時 Respond.js 不起作用 -->
<!--[if lt IE 9]>
<script src="https://cdn.jsdelivr.net/npm/html5shiv@3.7.3/dist/html5shiv.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/respond.js@1.4.2/dest/respond.min.js"></script>
<![endif]-->
</head>
<body>
</body>
</html>
tag.jsp
<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core"%>
<%@ taglib prefix="fmt" uri="http://java.sun.com/jsp/jstl/fmt" %>
detail.jsp
<%@ page contentType="text/html;charset=UTF-8" language="java" %>
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<title>秒殺詳情頁</title>
<%@include file="common/head.jsp" %>
</head>
<body>
<div class="container">
<div class="panel panel-default text-center">
<div class="panel-heading">
<h1>${secKill.name}</h1>
</div>
</div>
<div class="panel-body">
<h2 class="text-danger text-center">
<%-- 顯示time圖標--%>
<span class="glyphicon glyphicon-time"></span>
<%-- 顯示倒計時--%>
<span class="glyphicon" id="seckill-box"></span>
</h2>
</div>
</div>
<%-- 登陸彈出框,輸入電話--%>
<div id="killPhoneModal" class="modal fade">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h3 class="modal-title text-center">
<span class="glyphicon glyphicon-phone">秒殺電話</span>
</h3>
</div>
<div class="modal-body">
<div class="row">
<div class="col-xs-8 col-xs-offset-2">
<input type="text" name="killPhone" id="killPhoneKey"
placeholder="填手機號" class="from-control"/>
</div>
</div>
</div>
<div class="modal-footer">
<%--驗證信息--%>
<span id="killPhoneMessage" class="glyphicon"></span>
<button type="button" id="killPhoneBtn" class="btn btn-success">
<span class="glyphicon glyphicon-phone"></span>
submit
</button>
</div>
</div>
</div>
</div>
</body>
<!-- 新 Bootstrap 核心 CSS 文件 -->
<link href="https://cdn.staticfile.org/twitter-bootstrap/3.3.7/css/bootstrap.min.css" rel="stylesheet">
<!-- jQuery文件。務必在bootstrap.min.js 之前引入 -->
<script src="https://cdn.staticfile.org/jquery/2.1.1/jquery.min.js"></script>
<!-- 最新的 Bootstrap 核心 JavaScript 文件 -->
<script src="https://cdn.staticfile.org/twitter-bootstrap/3.4.0/js/bootstrap.min.js"></script>
<%--jQuery cookie操作插件--%>
<script src="https://cdn.bootcss.com/jquery-cookie/1.4.1/jquery.cookie.min.js"></script>
<%--jQuery countDown倒計時插件--%>
<script src="https://cdn.bootcss.com/jquery.countdown/2.2.0/jquery.countdown.min.js"></script>
<%-- 交互邏輯 --%>
<script src="/seckill/resources/script/seckill.js" type="text/javascript"></script>
<script type="text/javascript">
$(function () {
// 使用EL表達式傳入參數
seckill.detail.init({
// 使用EL表達式傳入參數
seckillId: ${secKill.secKillId},
startTime: ${secKill.startTime.time},//毫秒時間
endTime: ${secKill.endTime.time}
});
});
</script>
</html>
list.jsp
<%@ page contentType="text/html;charset=UTF-8" language="java" %>
<%-- 引入 jstl --%>
<%@include file="common/tag.jsp"%>
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<title>秒殺列表頁</title>
<%@include file="common/head.jsp"%>
</head>
<body>
<%-- 頁面顯示部分 --%>
<div class="container">
<div class="panel panel-default">
<div class="panel-heading text-center">
<h2>秒殺列表</h2>
</div>
<div class="panel-body">
<table class="table table-hover">
<thead>
<tr>
<th>名稱</th>
<th>庫存</th>
<th>開啟時間</th>
<th>結束時間</th>
<th>創建時間</th>
<th>詳情頁</th>
</tr>
</thead>
<tbody>
<c:forEach var="sk" items="${list}">
<tr>
<td>${sk.name}</td>
<td>${sk.number}</td>
<td>
<fmt:formatDate value="${sk.startTime}" pattern="yyyy-MM-dd HH:mm:ss"></fmt:formatDate>
</td>
<td>
<fmt:formatDate value="${sk.endTime}" pattern="yyyy-MM-dd HH:mm:ss"></fmt:formatDate>
</td>
<td>
<fmt:formatDate value="${sk.createTime}" pattern="yyyy-MM-dd HH:mm:ss"></fmt:formatDate>
</td>
<td>
<a class="btn btn-info" href="/seckill/${sk.secKillId}/detail" >link</a> <!-- target="_blank" -->
</td>
</tr>
</c:forEach>
</tbody>
</table>
</div>
</div>
</div>
<!-- jQuery文件。務必在bootstrap.min.js 之前引入 -->
<script src="https://cdn.staticfile.org/jquery/2.1.1/jquery.min.js"></script>
<!-- 最新的 Bootstrap 核心 JavaScript 文件 -->
<script src="https://cdn.staticfile.org/twitter-bootstrap/3.3.7/js/bootstrap.min.js"></script></body>
</html>
seckill.js
// 存放主要的交互邏輯
// JavaScript 模塊化
var seckill = {
// 封裝秒殺相關的ajax的url
URL: {
now: function () {
return '/seckill/time/now';
},
exposer: function (seckillId) {
return '/seckill/' + seckillId + '/exposer';
},
execution: function (seckillId, md5) {
return '/seckill/' + seckillId + '/' + md5 + '/execution';
}
},
// 驗證手機號
validatePhone: function (phone) {
if (phone && phone.length == 11 && !isNaN(phone)) {
return true;
} else {
return false;
}
},
handleSeckillkill: function (seckillId,node) {
// 處理秒殺邏輯:獲取秒殺地址,控制顯示邏輯,執行秒殺
node.hide().html('<button class="btn btn-primary btn-lg" id="killBtn">開始秒殺</button>');//按鈕
$.post(seckill.URL.exposer(seckillId), {}, function (result) {
// 在回調函數中,執行交互流程
if (result && result['success']) {
var exposer = result['data'];
if (exposer['exposed']) {
// 開啟
// 獲取地址
var md5 = exposer['md5'];
var killUrl = seckill.URL.execution(seckillId, md5);
console.log('killUrl:' + killUrl);//TODO
// 綁定一次點擊事件
$('#killBtn').one('click', function () {
// 綁定了執行秒殺的操作
//1:禁用按鈕
$(this).addClass('disabled');
// 2:發送秒殺請求
$.post(killUrl, {}, function (result) {
if (result && result['success']) {
var killResult = result['data'];
var state = killResult['state'];
var stateInfo = killResult['stateInfo'];
// 顯示秒殺結果
node.html('<span class="label label-success">' + stateInfo + '</span>');
}
});
});
node.show();
} else {
// 未開啟
var now = exposer['now'];
var start = exposer['start'];
var end = exposer['end'];
// 重新計算計時邏輯
seckill.countDown(seckillId, now, start, end);
}
} else {
console.log('result:' + result);
}
});
},
// 時間判斷
countDown: function (seckillId, nowTime, startTime, endTime) {
var seckillBox = $('#seckill-box');
// 時間判斷
if (nowTime > endTime) {
// 秒殺結束
seckillBox.html('秒殺結束');
} else if (nowTime < startTime) {
// 秒殺未開始,計時事件綁定
var killTime = new Date(startTime + 1000);
seckillBox.countdown(killTime, function (event) {
// 時間格式
var format = event.strftime('秒殺倒計時:%D天 %H時 %M分 %S秒');
seckillBox.html(format);
// 時間完成后調用事件
}).on('finish.countdown', function () {
// 獲取秒殺地址,控制現實邏輯
seckill.handleSeckillkill(seckillId,seckillBox);
});
} else {
// 秒殺開始
seckill.handleSeckillkill(seckillId,seckillBox);
}
},
// 詳情頁秒殺邏輯
detail: {
// 詳情頁初始化
init: function (params) {
// 通過手機驗證和登錄,計時交互
// 規划交互流程
// 在cookie中查找手機號
var killPhone = $.cookie('killPhone');
// 驗證手機號
if (!seckill.validatePhone(killPhone)) {
// 綁定手機號
// 控制輸出
var killPhoneModal = $('#killPhoneModal');
//顯示彈出層
killPhoneModal.modal({
show: true, // 顯示彈出層
backdrop: 'static',//禁止位置關閉
keyboard: false, // 關閉鍵盤事件
});
$('#killPhoneBtn').click(function () {
//
var inputPhone = $('#killPhoneKey').val();
console.log('inputPhone=' + inputPhone);//TODO
if (seckill.validatePhone(inputPhone)) {
// 電話寫入cookie
$.cookie('killPhone', inputPhone, {expiress: 7, path: '/seckill'});
// 刷新頁面
window.location.reload();
} else {
$('#killPhoneMessage').hide().html('<lable class="label label-danger">手機號錯誤!</lable>').show(300);
}
});
}
// 已經登錄
// 計時交互
var startTime = params['startTime'];
var endTime = params['endTime'];
var seckillId = params['seckillId'];
$.get(seckill.URL.now(), {}, function (result) {
if (result && result['success']) {
var nowTime = result['data'];
// 時間判斷
seckill.countDown(seckillId, nowTime, startTime, endTime);
} else {
console.log('result:' + result)
}
});
}
}
}
5.5 Tomcat 的配置和項目的部署
首先在本地安裝 Tomcat
編輯配置
進行 Tomcat 的配置
配置完畢過后就可以嘗試 run 一下了,但在之前要檢查自己相關的服務是否開啟
運行結果應當如圖
點擊 link
對秒殺結束商品
對正在秒殺商品
點擊秒殺按鈕
重復秒殺
並且可以查詢到數據庫相應商品數量 -1
下面進行高並發優化
六、高並發優化
6.1 高並發發生點分析
從用戶操作的發生過程分析,可能出現高並發的地方(紅色高亮)
-
詳情頁:詳情頁展示的是我們的商品信息(商品名、數量、價格、秒殺鏈接),在詳情頁中用戶大量高頻的刷新行為會導致頻繁的訪問系統資源,給系統帶來極大的負擔。這里采用 CDN 來緩解用戶的高頻訪問。
-
系統時間:系統訪問一次內存大概需要 10 ns ,這是一個很短的時間,不會影響到系統的正常運行,故不需要優化。
-
秒殺接口:當秒殺開啟時,大量的對商品的操作請求會涌入服務器,服務器就會去獲取相應的對象,但每次都去訪問數據庫顯然會造成很大的負擔。這里我們采用 Redis 來進行優化
-
秒殺操作
6.2 詳情頁高並發優化
在詳情頁中用戶大量高頻的刷新行為會導致頻繁的訪問系統資源,給系統帶來極大的負擔。這里采用 CDN 來緩解用戶的高頻訪問。
對於 CDN 來說,當用戶訪問特定的資源時,他是去訪問 CDN 服務器,而不是來訪問我們的后端服務器,這樣可以很好的將用戶的訪問分流出去,減少后端服務器的負擔。一般來說公司會去租用別人的 CDN 或者 自己搭建 CDN 服務器
6.3 秒殺地址優化
秒殺地址會隨着時間的變更而變更——當秒殺未開始得時候返回的地址跟開始了返回的地址是不一樣的,是動態更新的,這樣的數據我們不能使用 CDN 的方式來優化訪問。
/**
* 獲取秒殺鏈接的方法
*/
public Exposer exportSeckillUrl(long seckillId) {
seckill = secKillDao.queryById(seckillId); // 通過傳來的 id 參數獲取對應的 seckill 對象
if (seckill == null) {
return new Exposer(false, seckillId);
} else {
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());
}
}
// 進行md5加密,不可逆
String md5 = getMD5(seckillId);
return new Exposer(true, md5, seckillId);
}
/**
* queryById 的 sql 實現語句
*/
<select id="queryById" resultType="SecKill" parameterType="long">
# use seckill;
select seckill_id,name,number,start_time,end_time,create_time
from seckill.seckill
where seckill_id = #{secKillId}
</select>
從代碼中可以知道當秒殺開始后,我們進行秒殺時需要去訪問數據庫獲取相應的商品對象,當大量的請求進入系統時,數據庫需要承受很高的訪問量,而一般的數據庫訪問速度無法滿足秒殺系統這樣擁有大量數據操作並且需要快速響應的需求,那么要優化秒殺地址,也就是要優化對數據對象的獲取操作
這樣我們就可以借用 Redis 來高效的完成對數據庫的操作。
首先創建 Redis 對應的數據類
RedisDao
package org.seckill.dao.cache;
import com.dyuproject.protostuff.LinkedBuffer;
import com.dyuproject.protostuff.ProtostuffIOUtil;
import com.dyuproject.protostuff.runtime.RuntimeSchema;
import org.seckill.entity.SecKill;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import redis.clients.jedis.Jedis;
import redis.clients.jedis.JedisPool;
public class RedisDao {
// 新建Redis連接池,相當於JDBC的connection
private final JedisPool jedisPool;
private Logger logger = LoggerFactory.getLogger(this.getClass());
private RuntimeSchema<SecKill> schema = RuntimeSchema.createFrom(SecKill.class);
public RedisDao(String ip, int port) {
jedisPool = new JedisPool(ip, port);
}
public SecKill getSeckill(long seckillId) {
// redis操作邏輯
try {
Jedis jedis = jedisPool.getResource();
try {
String key = "seckill:" + seckillId;
// Jedis 並沒有進行內部序列化的操作
// get -> byte[] -> 反序列化 -> Object(Seckill)
// 采用自定義序列化
byte[] bytes = jedis.get(key.getBytes());
// 緩存重獲取到
if (bytes != null) {
// 創建一個空對象
SecKill secKill = schema.newMessage();
ProtostuffIOUtil.mergeFrom(bytes, secKill, schema);
// seckill 被反序列
return secKill;
}
} finally {
jedis.close();
}
} catch (Exception e) {
logger.error(e.getMessage(), e);
}
return null;
}
public String putSeckill(SecKill secKill) {
// set Object(Seckill) -> 序列化 -> byte[]
try {
Jedis jedis = jedisPool.getResource();
try {
String key = "seckill:" + secKill.getSecKillId();
byte[] bytes = ProtostuffIOUtil.toByteArray(secKill, schema,
LinkedBuffer.allocate(LinkedBuffer.DEFAULT_BUFFER_SIZE));
int timeout = 60 * 60;
String result = jedis.setex(key.getBytes(), timeout, bytes);
return result;
} finally {
jedis.close();
}
} catch (Exception e) {
logger.error(e.getMessage(), e);
}
return null;
}
}
對 Spring 的配置文件做修改,添加 RedisDao 對應的 bean 對象
spring-dao.xml
<!-- 添加 -->
<bean id="redisDao" class="org.seckill.dao.cache.RedisDao">
<constructor-arg index="0" value="localhost"/>
<constructor-arg index="1" value="6379"/>
</bean>
對秒殺地址的方法進行修改,用上 Redis
SeckillService
/**
* 執行秒殺操作 by 存儲過程
* @param seckillId
* @param userPhone
* @param md5
* @return
* @throws SeckillException
* @throws RepeatKillException
* @throws SeckillCloseException
*/
SeckillExecution executeSeckillProcedure(long seckillId, long userPhone, String md5);
SeckillServiceIpml
@Autowired
private RedisDao redisDao;
@Override
public Exposer exportSeckillUrl(long seckillId) {
// 優化點:緩存優化,一致性維護基於超時
// 1:訪問 redis,嘗試獲取 redis 緩存當中的數據對象
SecKill seckill = redisDao.getSeckill(seckillId);
if (seckill == null) { // 未獲取到說明 redis 緩存當中沒有對應的對象
// 訪問數據庫
seckill = secKillDao.queryById(seckillId);
if (seckill == null) { // 通過 queryById 還是沒有獲取到,說明 id 在數據庫中沒有對應
return new Exposer(false, seckillId);
} else {
// 添加進redis
String result = redisDao.putSeckill(seckill);
}
}
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());
}
// 進行md5加密,不可逆
String md5 = getMD5(seckillId);
return new Exposer(true, md5, seckillId);
}
創建單元測試,檢驗邏輯是否正確(結果輸出應為代碼中注釋樣)
RedisDaoTest
package org.seckill.dao.cache;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.seckill.dao.SecKillDao;
import org.seckill.entity.SecKill;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.test.context.ContextConfiguration;
import org.springframework.test.context.junit4.SpringJUnit4ClassRunner;
import static org.junit.Assert.*;
/**
* OK
* SecKill{secKillId=1002, name='36元秒殺大米',
* number=9994, startTime=Sun Jan 17 16:00:00 CST 2021,
* endTime=Wed Feb 17 16:00:00 CST 2021,
* createTime=Mon Jan 18 00:50:38 CST 2021}
*/
@RunWith(SpringJUnit4ClassRunner.class)
@ContextConfiguration("classpath:spring/spring-dao.xml")
public class RedisDaoTest {
long id = 1002;
@Autowired
private RedisDao redisDao;
@Autowired
private SecKillDao secKillDao;
@Test
public void testRedisDao() throws Exception{
SecKill secKill = redisDao.getSeckill(id);
if (secKill == null){
secKill = secKillDao.queryById(id);
if (secKill != null){
String result = redisDao.putSeckill(secKill);
System.out.println(result);
secKill = redisDao.getSeckill(id);
System.out.println(secKill);
}
}else {
System.out.println(secKill);
}
}
}
6.4 秒殺操作的優化
像我們的商品各項信息這種動態更新的東西一般都是沒辦法使用 CDN 緩存來幫助我們優化訪問的,而對於商品數量的操作需要借助於 MySQL 的事務管理來防止數據的不一致情況發生,並且對於某些非常熱門的商品,在同一時間可能會有很多的請求進來對數據庫的同一行進行操作。這些都是我們可以下手優化的地方。
對於 MySQL 來說,當很多用戶訪問同一行數據時會發生以下的情況
當一條請求進入到隊列時,他會等待上一條請求處理完畢釋放所占有的行級鎖之后才會執行自己的操作,同樣的這條規則適用於隊列中所有的請求。這種等待在訪問量巨大時,將會成為一個致命的存在。我們優化的方向就是減少行級鎖的持有時間
為了避免因為異地服務器帶來的網絡延遲問題,我們可以將對 MySQL 數據庫的操作放到 MySQL 服務器端去執行,這樣可以有效的避免網絡延遲而導致的長時間持有行級鎖的情況的發生。實現的方法有兩種:
創建存儲過程
schema.sql
-- 秒殺執行存儲過程
DELIMITER $$ -- console ; 轉換為 $$
-- 定義存儲過程
-- 參數:in 輸入參數;out 輸出參數
-- row_count() : 返回上一條修改類型sql(delete,insert,update)影響的行數
-- row_count() : 0:未修改數據 >0:修改的行數 <0:sql錯誤、未執行sql
create procedure seckill.execute_seckill(in v_seckill_id bigint, in v_phone bigint,
in v_kill_time timestamp, out r_result int)
begin
declare insert_count int default 0;
start transaction ;
insert ignore into success_killed
(seckill_id, user_phone, create_time)
VALUES (v_seckill_id, v_phone, v_kill_time);
select row_count() into insert_count;
if (insert_count = 0) then
rollback;
set r_result = -1;
else
if (insert_count < 0) then
rollback;
set r_result = -2;
else
update seckill
set number = number - 1
where seckill_id = v_seckill_id
and end_time > v_kill_time
and start_time < v_kill_time
and number > 0;
select ROW_COUNT() into insert_count;
if (insert_count = 0) then
rollback;
set r_result = 0;
elseif (insert_count < 0) then
rollback;
set r_result = -2;
else
commit;
set r_result = 1;
end if;
end if;
end if;
end;
$$ -- 定義結束
show create procedure execute_seckill ;
DELIMITER ;;
set @r_result = -3;
-- 執行存儲過程
call seckill.execute_seckill(1002,13228217106,now(),@r_result);
-- 獲取結果
select @r_result;
-- 存儲過程
-- 1:存儲過程優化:事務行級鎖持有的時間
-- 2:不要過度的依賴存儲過程
-- 3:簡單的邏輯,可以應用存儲過程
修改 SeckillDao
/**
* 使用存儲過程進行秒殺
* @param paramMap
*/
void killByProcedure(Map<String ,Object> paramMap);
添加 Mybatis 配置
SecKillDao.xml
<!-- mybatis調用存儲過程 -->
<select id="killByProcedure" statementType="CALLABLE">
call seckill.execute_seckill(
#{seckillId,jdbcType=BIGINT,mode=IN},
#{phone,jdbcType=BIGINT,mode=IN},
#{killTime,jdbcType=TIMESTAMP,mode=IN},
#{result,jdbcType=INTEGER,mode=OUT}
)
</select>
使用存儲過程進行數據庫操作
SeckillServiceImpl
/**
* 使用存儲過程完成秒殺
*
* @param seckillId
* @param userPhone
* @param md5
* @return
* @throws SeckillException
* @throws RepeatKillException
* @throws SeckillCloseException
*/
@Override
public SeckillExecution executeSeckillProcedure(long seckillId, long userPhone, String md5) {
if (md5 == null || !md5.equals(getMD5(seckillId))) {
// 當md5驗證失敗時,返回數據篡改異常
return new SeckillExecution(seckillId, SeckillStatEnum.DATA_REWRITE);
}
Date killTime = new Date();
Map<String, Object> map = new HashMap<>();
map.put("seckillId", seckillId);
map.put("phone", userPhone);
map.put("killTime", killTime);
map.put("result", null);
// 存儲過程執行完畢,result被賦值
try {
secKillDao.killByProcedure(map);
// 獲取result
Integer result = MapUtils.getInteger(map, "result", -2);
if (result == 1) {
SuccessKilled sk = successKilledDao.queryByIdWithSecKill(seckillId, userPhone);
return new SeckillExecution(seckillId, SeckillStatEnum.SUCCESS, sk);
} else {
return new SeckillExecution(seckillId, SeckillStatEnum.stateOf(result));
}
} catch (Exception e) {
logger.error(e.getMessage(), e);
return new SeckillExecution(seckillId, SeckillStatEnum.INNER_ERROR);
}
}
SeckillController
@RequestMapping(value = "/{seckillId}/{md5}/execution",
method = RequestMethod.POST,
produces = {"application/json;charset=UTF-8"})
@ResponseBody
public SeckillResult<SeckillExecution> execute(@PathVariable("seckillId") Long seckillId,
@PathVariable("md5") String md5,
@CookieValue(value = "killPhone", required = false) Long phone) {
if (phone == null) {
return new SeckillResult<SeckillExecution>(false, "未注冊");
}
SeckillResult<SeckillExecution> result;
try {
// 啟用存儲過程處理
SeckillExecution execution = seckillService.executeSeckillProcedure(seckillId, phone, md5);
return new SeckillResult<SeckillExecution>(true, execution);
} catch (SeckillCloseException e) {
SeckillExecution execution = new SeckillExecution(seckillId, SeckillStatEnum.END);
return new SeckillResult<SeckillExecution>(false, execution);
} catch (RepeatKillException e) {
SeckillExecution execution = new SeckillExecution(seckillId, SeckillStatEnum.REPEAT_KILL);
return new SeckillResult<SeckillExecution>(false, execution);
} catch (Exception e) {
logger.error(e.getMessage(), e);
SeckillExecution execution = new SeckillExecution(seckillId, SeckillStatEnum.INNER_ERROR);
return new SeckillResult<SeckillExecution>(false, execution);
}
}
同樣進行單元測試
SeckillServiceIpmlTest
/**
* 輸出應為 stateInfo 對應字段,並且當秒殺成功后數據庫應有相應變化
*/
@Test
public void executeSeckillProcedure() {
long seckillId = 1001;
long phone = 13228217109l;
Exposer exposer = seckillService.exportSeckillUrl(seckillId);
if (exposer.isExposed()) {
String md5 = exposer.getMd5();
SeckillExecution execution = seckillService.executeSeckillProcedure(seckillId, phone, md5);
logger.info(execution.getStateInfo());
}
}
至此開發完畢
七、總結
(先占個位,畢竟這里面涉及到的好多東西都沒有搞得非常透徹,寫總結也只能寫個空空盪盪的。)