基于 Java 的秒杀系统的开发
前言
使用码云做图床的时候一定要一张一张的传图啊,不然一张图片直接影分身
首先感谢大佬的无私奉献,愿意将自己的经验和技术分享给我们。贴上大佬的教程指北 教程页
项目代码见我的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());
}
}
至此开发完毕
七、总结
(先占个位,毕竟这里面涉及到的好多东西都没有搞得非常透彻,写总结也只能写个空空荡荡的。)