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框架不熟悉,我想推荐一下我的几个小项目帮助你更好的理解:


项目设计

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
.
├── 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依赖

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
<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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
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如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
/*
* 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):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
/**
* 减库存。
* 对于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映射

1
2
3
4
5
6
7
8
9
10
11
12
13
<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表数据,对应的接口定义如下:

1
2
3
4
5
6
7
/**
* 根据秒杀商品ID查询订单明细数据并得到对应秒杀商品的数据,因为我们再SeckillOrder中已经定义了一个Seckill的属性
*
* @param seckillId
* @return
*/
SeckillOrder findById(long seckillId);

对应的SQL如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<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>标签来实现,用于查询两张关联表的数据,如:

1
2
3
4
5
6
7
8
9
10
<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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class SeckillMapperTest {

@Autowired
private SeckillMapper seckillMapper;

@Test
public void findAll() {
}

@Test
public void findById() {
}

@Test
public void reduceStock() {
}
}

以上就是使用IDEA快捷键创建的测试类,我们仅以findAll()方法举例说明一下如何使用SpringBoot的测试类。如下:

此处的源码请参看:Github

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
@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中,技术有限,如果可以,会尽力给大家提供一些帮助,或是一些学习方法,当然群里的大佬都会积极给新手答疑的。所以,别犹豫,快来加入我们吧!


联系

If you have some questions after you see this article, you can contact me or you can find some info by clicking these links.

坚持原创技术分享,您的支持将鼓励我继续创作!