# Java高并发秒杀API(一)之业务分析与DAO层
>本SSM实战项目使用了Maven进行依赖管理,如果有不清楚Maven是什么的可以参考[这篇文章](http://blog.csdn.net/lewky_liu/article/details/78138381)
## 1. 创建Maven项目和依赖
### 1.1 创建项目前需要先安装Maven,并设置好环境变量
* [Maven下载](http://download.csdn.net/download/lewky_liu/10000144)
* 设置环境变量
* 新建变量`MAVEN_HOME`,值为Maven的目录`X:\XXX\apache-maven-XXX`
* 将`%MAVEN_HOME%\bin`添加到`Path`变量下
* 运行CMD,输入`mvn -v`后可以看到Maven的版本信息等则表示安装成功
### 1.2 创建Maven项目有两种方式,如下
**第一种创建方式:使用命令行手动创建**
```bash
mvn archetype:generate -DgroupId=com.lewis.seckill -DartifactId=seckill -Dpackage=com.lewis.seckill -Dversion=1.0-SNAPSHOT -DarchetypeArtifactId=maven-archetype-webapp
```
在视频中使用的是`archetype:create`,该方法已被废弃,请使用`archetype:generate`来创建。命令行执行后会创建一个`maven-archetype-webapp`骨架的Maven项目,其中`groupId`是项目组织唯一的标识符,实际对应JAVA的包的结构;`artifactId`是项目的唯一的标识符,实际对应项目的名称;`package`一般是`groupId`+`artifactId`,是自动生成的,可以修改
**第二种创建方式:借助IDE工具的Maven插件来创建项目**
>Eclipse安装Maven插件
* 不知道怎么Maven插件的请参考[该博文](http://blog.csdn.net/wode_dream/article/details/38052639),推荐使用link方式手工安装的方式
* 如果是手工安装Maven插件的,可能会缺少pom.xml 图形化编辑工具,请另外添加进去,具体情况请参考[该博文](http://www.micmiu.com/software/build/eclipse-m2e-plugins/)
* 已经安装了Maven插件的请走下一个步骤
* `File`→`New`→`Other...`→`Maven Project`→`Next`,进入如下界面
![Maven1](/images/posts/project/seckill/Maven1.jpg)
* 点击`Next`,选择要构建的骨架`maven-archetype-webapp`,如下图
![Maven2](/images/posts/project/seckill/Maven2.jpg)
* 点击`Next`,填写`groupId=com.lewis.seckill`,`DartifactId=seckill`,`package=com.lewis.seckill`(根据实际情况填写),然后`Finish`
>如果是第一次使用Eclipse的Maven插件来创建Maven项目的可能会遇到一些问题,可以参考[该博文](http://blog.csdn.net/lewky_liu/article/details/78138381)
### 1.3 修改pom.xml文件
当创建完Maven项目后会在根目录下有一个pom.xml文件,Maven项目通过pom.xml进行项目依赖的管理,如果没有该xml文件,Eclipse不会将该项目当作一个Maven项目
>添加项目需要的jar包依赖
```xml
4.0.0
com.lewis
seckill
war
0.0.1-SNAPSHOT
seckill Maven Webapp
http://maven.apache.org
junit
junit
4.11
test
org.slf4j
slf4j-api
1.7.12
ch.qos.logback
logback-core
1.1.1
ch.qos.logback
logback-classic
1.1.1
mysql
mysql-connector-java
5.1.35
runtime
c3p0
c3p0
0.9.1.1
org.mybatis
mybatis
3.3.0
org.mybatis
mybatis-spring
1.2.3
taglibs
standard
1.1.2
jstl
jstl
1.2
com.fasterxml.jackson.core
jackson-databind
2.5.4
javax.servlet
javax.servlet-api
3.1.0
org.springframework
spring-core
4.1.7.RELEASE
org.springframework
spring-beans
4.1.7.RELEASE
org.springframework
spring-context
4.1.7.RELEASE
org.springframework
spring-jdbc
4.1.7.RELEASE
org.springframework
spring-tx
4.1.7.RELEASE
org.springframework
spring-web
4.1.7.RELEASE
org.springframework
spring-webmvc
4.1.7.RELEASE
org.springframework
spring-test
4.1.7.RELEASE
redis.clients
jedis
2.7.3
com.dyuproject.protostuff
protostuff-core
1.0.8
com.dyuproject.protostuff
protostuff-runtime
1.0.8
seckill
src/main/java
**/*.xml
false
src/main/resources
```
>关于maven依赖的简化写法
教学视频中老师写了很多的依赖,但其实这里面有一些是可以省略不写的,因为有些包会自动依赖其它的包(Maven的传递性依赖)。这里面可以省略的依赖有:spring-core;spring-beans(上面这两个spring-context会自动依赖);spring-context,spring-jdbc(mybatis-spring会依赖);spring-web(spring-webmvc会依赖);logback-core(logback-classic会依赖)
>有想要了解Maven的依赖范围与传递性依赖的请参考[该博文](http://blog.csdn.net/lewky_liu/article/details/78145211)
## 2. 秒杀业务分析
### 2.1 业务分析
>秒杀业务的核心是对库存的处理,其业务流程如下图
![1.png](/images/posts/project/seckill/1.png)
> 用户针对库存业务分析
当用户执行秒杀成功时,应该发生以下两个操作:
* 减库存
* 记录购买明细
这两个操作属于一个完整事务,通过事务来实现数据落地
>为什么需要事务?
* 减库存却没有记录购买明细,会导致商品少卖
* 记录购买明细却没有减库存,会导致商品超卖
在实际中,以上都是很严重的事故,会给商家或买家带来损失,这是不能被允许的。一旦发生这种事故,事故责任很自然的就会去找设计实现业务的程序员
>如何实现数据落地?
有**MySQL与NoSQL**两种数据落地的方案
* MySQL属于关系型数据库,而MySQL内置的事务机制来可以准确的帮我们完成减库存和记录购买明细的过程。MySQL有多种存储引擎,但只有InnoDB存储引擎支持事务。InnoDB支持行级锁和表级锁,默认使用行级锁
* NoSQL属于非关系型数据库,近些年来在数据存储方面承担了很大的职责,但是对于事务的支持做的并不是很好,更多追求的是性能、高复用、分布式。
事务机制依然是目前最可靠的数据落地方案。
> 数据落地与不数据落地
* **落地数据**:就是被持久化的数据,这种数据一般放在硬盘或是其他的持久化存储设备里,例如:图片、系统日志、在页面上显示的数据以及保存在关系数据库里的数据等等,落地数据一定会有一个固定的载体,他们不会瞬时消失的。
* **不落地数据**:一般指存储在内存或者是网络传输里的数据,这些数据是瞬时,使用完毕就会消失,例如:我们在浏览器发送给服务器的请求;从数据库读取出来的一直到页面展示前的数据等等。
* “不落地”传输能够满足用户在性能上的要求。
### 2.2 使用MySQL实现秒杀的难点分析
>难点问题:如何高效地处理竞争?
当一个用户在执行秒杀某件商品时,其他也想要秒杀该商品的用户就只能等待,直到上一个用户提交或回滚了事务,他才能够得到该商品的锁执行秒杀操作。这里就涉及到了锁的竞争。
![2.jpg](/images/posts/project/seckill/2.jpg)
对于MySQL来说,竞争反应到背后的技术是就是事务+行级锁:
start transaction(开启事务)→ update库存数量 → insert购买明细 → commit(提交事务)
在秒杀系统中,在同一时刻会有很多用户在秒杀同一件商品,那么如何高效低处理这些竞争?如何高效地提交事务?这些将在[Java高并发秒杀API(四)之高并发优化](http://blog.csdn.net/lewky_liu/article/details/78166080)进行分析总结。
>实现哪些秒杀功能?
下面先以天猫的秒杀库存系统为例,如下图
![3.jpg](/images/posts/project/seckill/2.jpg)
可以看到,天猫的秒杀库存系统是很复杂的,需要很多工程师共同开发。在这里,我们只实现秒杀相关的功能
* 秒杀接口暴露
* 执行秒杀
* 相关查询
>为什么要进行秒杀接口暴露的操作?
现实中有的用户回通过浏览器插件提前知道秒杀接口,填入参数和地址来实现自动秒杀,这对于其他用户来说是不公平的,我们也不希望看到这种情况
## 3. DAO层设计
### 3.1 创建数据库
源码里有个sql文件夹,可以给出了sql语句;也可以选择自己手写。数据库一共就两个表:秒杀库存表、秒杀成功明细表。
```sql
-- 数据库初始化脚本
-- 创建数据库
CREATE DATABASE seckill;
-- 使用数据库
use seckill;
CREATE TABLE seckill(
`seckill_id` BIGINT NOT NUll AUTO_INCREMENT COMMENT '商品库存ID',
`name` VARCHAR(120) NOT NULL COMMENT '商品名称',
`number` int NOT NULL COMMENT '库存数量',
`create_time` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`start_time` TIMESTAMP NOT NULL COMMENT '秒杀开始时间',
`end_time` TIMESTAMP NOT NULL COMMENT '秒杀结束时间',
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 DEFAULT CHARSET=utf8 COMMENT='秒杀库存表';
-- 初始化数据
INSERT into seckill(name,number,start_time,end_time)
VALUES
('1000元秒杀iphone6',100,'2016-01-01 00:00:00','2016-01-02 00:00:00'),
('800元秒杀ipad',200,'2016-01-01 00:00:00','2016-01-02 00:00:00'),
('6600元秒杀mac book pro',300,'2016-01-01 00:00:00','2016-01-02 00:00:00'),
('7000元秒杀iMac',400,'2016-01-01 00:00:00','2016-01-02 00:00:00');
-- 秒杀成功明细表
-- 用户登录认证相关信息(简化为手机号)
CREATE TABLE success_killed(
`seckill_id` BIGINT NOT NULL COMMENT '秒杀商品ID',
`user_phone` BIGINT NOT NULL COMMENT '用户手机号',
`state` TINYINT NOT NULL DEFAULT -1 COMMENT '状态标识:-1:无效 0:成功 1:已付款 2:已发货',
`create_time` TIMESTAMP NOT NULL COMMENT '创建时间',
PRIMARY KEY(seckill_id,user_phone),/*联合主键*/
KEY idx_create_time(create_time)
)ENGINE=INNODB DEFAULT CHARSET=utf8 COMMENT='秒杀成功明细表';
```
>秒杀成功明细表为何使用联合主键
之所以使用联合主键,是为了能够过滤重复插入,可以通过`insert ignore into`语句来避免用户重复秒杀同一件商品。这样当有重复记录就会忽略,语句执行后返回数字0。
>可能存在的问题
安装视频里的建表过程,可能会出现建表失败的情况。原因是当你给一个timestamp设置为on update current_timestamp的时候,其他的timestamp字段需要显式设定default值。
但是如果你有两个timestamp字段,但是只把第一个设定为current_timestamp而第二个没有设定默认值,MySQL也能成功建表,但是反过来就不行。这是mysql5.5版本对timestamp的处理。
为了解决这个问题,将create_time放到start_time和end_time的前面,还有的mysql版本需要将三个时间戳都设置默认值。
### 3.2 创建数据表对应的实体类
>在`src/main/java`包下创建com.lewis.entity包,接着建立`Seckill`实体类
```java
public class Seckill {
private Long seckillId;
private String name;
private Integer number;
private Date createTime;
private Date startTime;
private Date endTime;
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 == null ? null : name.trim();
}
public Integer getNumber() {
return number;
}
public void setNumber(Integer number) {
this.number = number;
}
public Date getCreateTime() {
return createTime;
}
public void setCreateTime(Date createTime) {
this.createTime = createTime;
}
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;
}
@Override
public String toString() {
return "Seckill [seckillId=" + seckillId + ", name=" + name + ", number=" + number + ", createTime=" + createTime + ", startTime="
+ startTime + ", endTime=" + endTime + "]";
}
}
```
>在com.lewis.entity包下,接着建立`SuccessKilled`实体类
```java
public class SuccessKilled {
private Byte state;
private Date createTime;
private Long seckillId;
private Long userPhone;
// 多对一,因为一件商品在库存中有很多数量,对应的购买明细也有很多。
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 Byte getState() {
return state;
}
public void setState(Byte state) {
this.state = state;
}
public Date getCreateTime() {
return createTime;
}
public void setCreateTime(Date createTime) {
this.createTime = createTime;
}
@Override
public String toString() {
return "SuccessKilled [state=" + state + ", createTime=" + createTime + ", seckillId=" + seckillId
+ ", userPhone=" + userPhone + "]";
}
}
```
### 3.3 创建实体类对应的DAO层接口(也就是Mapper接口,DAO针对的是具体实体来操作的“实体的增删改查”)
>在`src/main/java`下建立`com.lewis.dao`包,在包下建立`SeckillDao`接口
```java
public interface SeckillDao {
/**
* 减库存
*
* @param seckillId
* @param killTime
* @return 更新的记录行数,如果返回值<1则表示更新失败
*/
int reduceNumber(@Param("seckillId") long seckillId, @Param("killTime") Date killTime);
/**
* 根据id查询秒杀商品
*
* @param seckillId
* @return
*/
Seckill queryById(long seckillId);
/**
* 根据偏移量查询秒杀商品列表
*
* @param offset
* @param limit
* @return
*/
List queryAll(@Param("offset") int offset, @Param("limit") int limit);
}
```
>在`com.lewis.dao`包下建立`SuccessKilledDao`接口
```java
public interface SuccessKilledDao {
/**
* 插入购买明细,可过滤重复
*
* @param seckillId
* @param userphone
* @return 插入的行数,如果返回值<1则表示插入失败
*/
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);
}
```
>为什么有的方法形参前有@Param,有的却没有?
从上面的代码可以发现,当方法的形参在两个及两个以上时,需要在参数前加上@Param,如果不加上该注解会在之后的测试运行时报错。这是Sun提供的默认编译器(javac)在编译后的Class文件中会丢失参数的实际名称,方法中的形参会变成无意义的arg0、arg1等,在只有一个参数时就无所谓,但当参数在两个和两个以上时,传入方法的参数就会找不到对应的形参。因为Java形参的问题,所以在多个基本类型参数时需要用@Param注解区分开来。
### 3.4 基于MyBatis实现DAO接口
>MyBatis怎么用?SQL写在哪里?
Mybatis有两种提供SQL的方式:XML提供SQL、注解提供SQL(注解是java5.0之后提供的一个新特性)。
对于实际的使用中建议使用XML文件的方式提供SQL。如果通过注解的方式提供SQL,由于注解本身还是java源码,这对于修改和调整SQL其实是非常不方便的,一样需要重新编译类,当我们写复杂的SQL尤其拼接逻辑时,注解处理起来就会非常繁琐。而XML提供了很多的SQL拼接和处理逻辑的标签,可以非常方便的帮我们去做封装。
>如何去实现DAO接口?
Mapper自动实现DAO(也就是DAO只需要设计接口,不需要去写实现类,MyBatis知道我们的参数、返回类型是什么,同时也有SQL文件,它可以自动帮我们生成接口的实现类来帮我们执行参数的封装,执行SQL,把我们的返回结果集封装成我们想要的类型) 。
第二种是通过API编程方式实现DAO接口(MyBatis通过给我们提供了非常多的API,跟其他的ORM和JDBC很像)
在实际开发中建议使用Mapper自动实现DAO,这样可以直接只关注SQL如何编写,如何去设计DAO接口,帮我们节省了很多的维护程序,所有的实现都是MyBatis自动完成。
>创建一个目录存放Mybatis的SQL映射
按照Maven的规范,SQL映射文件应该放在`src/main/resources`包下,在该包下建立`mapper`目录,用来存放映射DAO接口的XML文件。这样Maven在编译时就会自动将`src/main/resources`下的这些配置文件编译进来。
我们也可以按照原本的习惯,在`src/main/java`下建立`com.lewis.mapper`包,将这些SQL映射存放到这里。由于Maven默认不会编译`src/main/java`下除源码以外的文件,所以需要在pom.xml中进行额外的配置。
```xml
seckill
src/main/java
**/*.xml
false
src/main/resources
```
在本项目中,我是采用的第二种方式存放Mybatis的SQL映射。(只是将映射DAO的mapper文件放在java包下,其他的关于Spring、MyBatis等的配置文件还是放在resources包下)
>在`src/main/resources`目录下配置mybatis-config.xml(配置MyBatis的全局属性)
打开MyBatis的[官方文档](http://www.mybatis.org/mybatis-3/zh/index.html)(MyBatis的官方文档做的非常友好,提供了非常多版本的国际化支持),选择`
入门`,找到MyBatis全局配置,里面有XML的规范(XML的标签约束dtd文件),拷入到项目的MyBatis全局配置文件中,开始配置MyBatis,如下:
```xml
```
>在`src/main/java`目录下的`com.lewis.mapper`包里创建SeckillDao.xml
```xml
UPDATE seckill
SET number = number-1
WHERE seckill_id=#{seckillId}
AND start_time
#{killTime}
AND end_time >= #{killTime}
AND number > 0;
```
>在`src/main/java`目录下的`com.lewis.mapper`包里创建SuccessKilledDao.xml
```xml
INSERT ignore INTO success_killed(seckill_id,user_phone,state)
VALUES (#{seckillId},#{userPhone},0)
```
注:上面的s.seckill_id “seckill.seckill_id”表示s.seckill_id这一列的数据是Success_killed实体类里的seckill属性里的seckill_id属性,是一个级联的过程,使用的就是别名只是忽略了as关键字,别名要加上双引号。
>为什么要用``把`<=`给包起来
CDATA指的是不应由 XML 解析器进行解析的文本数据,在XML元素中,`<`和`&`是非法的:
* `<`会产生错误,因为解析器会把该字符解释为新元素的开始。
* `&`也会产生错误,因为解析器会把该字符解释为字符实体的开始。(字符实体:比如` `表示一个空格)
所以在这里我们需要使用``来告诉XML`<=`不是XML的语言。
### 3.5 整合Spring和MyBatis
在`resources`目录下创建一个新的目录`spring`(存放所有Spring相关的配置)
>在resources包下创建jdbc.properties,用于配置数据库的连接信息
```
driver=com.mysql.jdbc.Driver
url=jdbc:mysql://localhost:3306/seckill?useUnicode=true&characterEncoding=utf-8
jdbc.username=root
password=123
```
>在`resources/spring`目录下创建Spring关于DAO层的配置文件spring-dao.xml
```xml
```
>关于数据库连接池的配置可能出现的问题
在jdbc.properties里使用的是`jdbc.username`,而不是`username`或者`name`,这是因为后两个属性名可能会与全局变量冲突,导致连接的数据库用户名变成了电脑的用户名,所以使用了`jdbc.username`。
>相关链接
关于Spring的XML配置文件的头部文件的说明可以参考[这篇文章](http://blog.csdn.net/lewky_liu/article/details/78157747)
### 3.6 DAO层单元测试
有不知道Eclipse如何直接进行生成快速的测试单元的,可以看看[这篇文章](http://blog.csdn.net/jj_nan/article/details/64134781)
使用Eclipse工具直接生成测试单元,这些测试代码按照Maven规范放到`src/test/java`包下。在生成的测试代码里测试我们的方法,测试的具体代码如下:
>`SeckillDaoTest.java`
```java
package com.lewis.dao;
import static org.junit.Assert.*;
import java.util.Date;
import java.util.List;
import javax.annotation.Resource;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.test.context.ContextConfiguration;
import org.springframework.test.context.junit4.SpringJUnit4ClassRunner;
import com.lewis.entity.Seckill;
/**
* 配置Spring和Junit整合,junit启动时加载springIOC容器 spring-test,junit
*/
@RunWith(SpringJUnit4ClassRunner.class)
// 告诉junit spring的配置文件
@ContextConfiguration({ "classpath:spring/spring-dao.xml" })
public class SeckillDaoTest {
// 注入Dao实现类依赖
@Resource
private SeckillDao seckillDao;
@Test
public void testQueryById() {
long seckillId = 1000;
Seckill seckill = seckillDao.queryById(seckillId);
System.out.println(seckill.getName());
System.out.println(seckill);
}
@Test
public void testQueryAll() {
List seckills = seckillDao.queryAll(0, 100);
for (Seckill seckill : seckills) {
System.out.println(seckill);
}
}
@Test
public void testReduceNumber() {
long seckillId = 1000;
Date date = new Date();
int updateCount = seckillDao.reduceNumber(seckillId, date);
System.out.println(updateCount);
}
}
```
>测试说明
先左键单击要测试的那个方法名,再右键点击选择`Debug As`可以单独对该方法进行单元测试。三个方法都测试通过,但是对于最后一个方法会发现数据库中该商品数量并没有减少,这是因为我们设置了秒杀时间,当前时间不满足秒杀时间,所以不会秒杀成功减少数量。
如果之前没有在DAO接口的多参数方法里在形参前加上@Param注解,那么在这里进行单元测试时,MyBatis会报绑定参数失败的错误,因为无法找到参数。这是因为Java没有保存行参的记录,Java在运行的时候会把`queryAll(int offset,int limit)`中的参数变成这样`queryAll(int arg0,int arg1)`,导致MyBatis无法识别这两个参数。
>`SuccessKilledDaoTest.java`
```java
package com.lewis.dao;
import static org.junit.Assert.*;
import javax.annotation.Resource;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.test.context.ContextConfiguration;
import org.springframework.test.context.junit4.SpringJUnit4ClassRunner;
import com.lewis.entity.SuccessKilled;
@RunWith(SpringJUnit4ClassRunner.class)
// 告诉junit spring的配置文件
@ContextConfiguration({ "classpath:spring/spring-dao.xml" })
public class SuccessKilledDaoTest {
@Resource
private SuccessKilledDao successKilledDao;
@Test
public void testInsertSuccessKilled() {
long seckillId = 1000L;
long userPhone = 13476191877L;
int insertCount = successKilledDao.insertSuccessKilled(seckillId, userPhone);
System.out.println("insertCount=" + insertCount);
}
@Test
public void testQueryByIdWithSeckill() {
long seckillId = 1000L;
long userPhone = 13476191877L;
SuccessKilled successKilled = successKilledDao.queryByIdWithSeckill(seckillId, userPhone);
System.out.println(successKilled);
System.out.println(successKilled.getSeckill());
}
}
```
>测试说明
测试方法同上,测试结果通过,另外由于我们使用了联合主键,在insert时使用了ignore关键字,所以对于`testInsertSuccessKilled()`重复插入同一条数据是无效的,会被过滤掉,确保了一个用户不能重复秒杀同一件商品。
>本节结语
至此,关于Java高并发秒杀API的DAO层的开发与测试已经完成,接下来进行Service层的开发、测试,详情可以参考[Java高并发秒杀API(二)之Service层](http://blog.csdn.net/lewky_liu/article/details/78162149)。