seckill秒杀系统
业务核心-数据库商品的操作以及时间上的操作和事务的操作
当用户执行秒杀成功时,应该发生以下两个操作:
- 减库存
- 记录购买明细
mysql实现秒杀难点-竞争问题;
解决:事务和行级锁,以及数据多时快速查询的索引
难点问题:如何高效地处理竞争?
当一个用户在执行秒杀某件商品时,其他也想要秒杀该商品的用户就只能等待,直到上一个用户提交或回滚了事务,他才能够得到该商品的锁执行秒杀操作。这里就涉及到了锁的竞争。
对于MySQL来说,竞争反应到背后的技术是就是事务+行级锁:
start transaction(开启事务)→ update库存数量 → insert购买明细 → commit(提交事务)
MySQL与NoSQL两种数据落地的方案
- MySQL属于关系型数据库,而MySQL内置的事务机制来可以准确的帮我们完成减库存和记录购买明细的过程。MySQL有多种存储引擎,但只有InnoDB存储引擎支持事务。
- InnoDB支持行级锁和表级锁,默认使用行级锁
- NoSQL属于非关系型数据库,近些年来在数据存储方面承担了很大的职责,但是对于事务的支持做的并不是很好,更多追求的是性能、高复用、分布式。
事务机制依然是目前最可靠的数据落地方案
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 '秒杀结束时间', -- start_time timestamp not null default '0000-00-00 00:00:00' comment '开始时间', -- end_time timestamp not null default '0000-00-00 00:00:00' 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(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='秒杀成功明细表';
数据库,报错:
ERROR 1067 (42000): Invalid default value for 'start_time'
找到sql.ini
set session sql_mode='STRICT_TRANS_TABLES,ERROR_FOR_DIVISION_BY_ZERO,NO_AUTO_CREATE_USER,NO_ENGINE_SUBSTITUTION';
重启
ok
创建maven项目 pop.xml文件,
<?xml version="1.0" encoding="UTF-8"?> <project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> <modelVersion>4.0.0</modelVersion> <groupId>com.yang</groupId> <artifactId>seckill</artifactId> <version>1.0-SNAPSHOT</version> <packaging>war</packaging> <name>seckill Maven Webapp</name> <!-- FIXME change it to the project's website --> <url>http://www.example.com</url> <properties> <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding> <maven.compiler.source>1.7</maven.compiler.source> <maven.compiler.target>1.7</maven.compiler.target> </properties> <dependencies> <dependency> <groupId>junit</groupId> <artifactId>junit</artifactId> <version>4.12</version> <scope>test</scope> </dependency> <!-- 1日志依赖slf4j+logbck--> <!-- https://mvnrepository.com/artifact/org.slf4j/slf4j-api --> <dependency> <groupId>org.slf4j</groupId> <artifactId>slf4j-api</artifactId> <version>1.7.30</version> </dependency> <dependency> <groupId>ch.qos.logback</groupId> <artifactId>logback-core</artifactId> <version>1.3.0-alpha5</version> </dependency> <!-- 实现slf4j接口整合--> <dependency> <groupId>ch.qos.logback</groupId> <artifactId>logback-classic</artifactId> <version>1.3.0-alpha5</version> </dependency> <!-- 2 数据库相关依赖--> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <version>8.0.23</version> </dependency> <dependency> <groupId>com.mchange</groupId> <artifactId>c3p0</artifactId> <version>0.9.5.2</version> </dependency> <!-- 3 dao层的依赖 Mybatis依赖--> <dependency> <groupId>org.mybatis</groupId> <artifactId>mybatis</artifactId> <version>3.5.2</version> </dependency> <dependency> <groupId>org.mybatis</groupId> <artifactId>mybatis-spring</artifactId> <version>2.0.2</version> </dependency> <!-- 4 servlet web相关依赖--> <!-- https://mvnrepository.com/artifact/taglibs/standard --> <dependency> <groupId>taglibs</groupId> <artifactId>standard</artifactId> <version>1.1.2</version> </dependency> <dependency> <groupId>javax.servlet</groupId> <artifactId>jstl</artifactId> <version>1.2</version> </dependency> <dependency> <groupId>com.fasterxml.jackson.core</groupId> <artifactId>jackson-databind</artifactId> <version>2.12.1</version> </dependency> <dependency> <groupId>javax.servlet</groupId> <artifactId>javax.servlet-api</artifactId> <version>4.0.1</version> </dependency> <!-- 5 spring核心依赖--> <dependency> <groupId>org.springframework</groupId> <artifactId>spring-core</artifactId> <version>5.3.3</version> </dependency> <dependency> <groupId>org.springframework</groupId> <artifactId>spring-beans</artifactId> <version>5.3.3</version> </dependency> <dependency> <groupId>org.springframework</groupId> <artifactId>spring-context</artifactId> <version>5.3.3</version> </dependency> <!-- 6 spring-dao层依赖--> <dependency> <groupId>org.springframework</groupId> <artifactId>spring-jdbc</artifactId> <version>5.3.3</version> </dependency> <!-- spring 事务--> <dependency> <groupId>org.springframework</groupId> <artifactId>spring-tx</artifactId> <version>5.3.3</version> </dependency> <!-- 7 spring-web层依赖--> <dependency> <groupId>org.springframework</groupId> <artifactId>spring-web</artifactId> <version>5.3.3</version> </dependency> <dependency> <groupId>org.springframework</groupId> <artifactId>spring-webmvc</artifactId> <version>5.3.3</version> </dependency> <!-- 8 spring-test相关依赖--> <dependency> <groupId>org.springframework</groupId> <artifactId>spring-test</artifactId> <version>5.3.3</version> </dependency> </dependencies> <build> <finalName>seckill</finalName> <pluginManagement><!-- lock down plugins versions to avoid using Maven defaults (may be moved to parent pom) --> <plugins> <plugin> <artifactId>maven-clean-plugin</artifactId> <version>3.1.0</version> </plugin> <!-- see http://maven.apache.org/ref/current/maven-core/default-bindings.html#Plugin_bindings_for_war_packaging --> <plugin> <artifactId>maven-resources-plugin</artifactId> <version>3.0.2</version> </plugin> <plugin> <artifactId>maven-compiler-plugin</artifactId> <version>3.8.0</version> </plugin> <plugin> <artifactId>maven-surefire-plugin</artifactId> <version>2.22.1</version> </plugin> <plugin> <artifactId>maven-war-plugin</artifactId> <version>3.2.2</version> </plugin> <plugin> <artifactId>maven-install-plugin</artifactId> <version>2.5.2</version> </plugin> <plugin> <artifactId>maven-deploy-plugin</artifactId> <version>2.8.2</version> </plugin> </plugins> </pluginManagement> </build> </project>
其实这里面有一些是可以省略不写的,因为有些包会自动依赖其它的包(Maven的传递性依赖)。这里面可以省略的依赖有:spring-core;spring-beans(上面这两个spring-context会自动依赖);
spring-context,spring-jdbc(mybatis-spring会依赖);spring-web(spring-webmvc会依赖);logback-core(logback-classic会依赖)
在src/main/java
包下创建com.yang.entity包,接着建立Seckill
实体类
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.yang.entity包下,接着建立SuccessKilled
实体类
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 + "]"; } }
创建实体类对应的DAO层接口(也就是Mapper接口,DAO针对的是具体实体来操作的“实体的增删改查”)
在src/main/java
下建立com.yang.dao
包,在包下建立SeckillDao
接口
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<Seckill> queryAll(@Param("offset") int offset, @Param("limit") int limit); }
在com.yang.dao
包下建立SuccessKilledDao
接口
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注解区分开来。
然后就是mapper.xml映射文件。
按照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中进行额外的配置。
<build> <finalName>seckill</finalName> <resources> <!--打包时包含源代码包下的资源文件,默认情况下只会打包src/main/java下的源代码 --> <resource> <directory>src/main/java</directory> <includes> <include>**/*.xml</include> </includes> <filtering>false</filtering> </resource> <resource> <directory>src/main/resources</directory> </resource> </resources> </build>
完成上一步之后想在java包下或者resources下都可创建mapper包了
然后就是在resources下创建mybatis-config.xml
(为什么是resources下?)
方便后面spring整合mybatis时找到如:classpath:(这个classpath默认的就是项目大包的java 或者resources包路径)
<?xml version="1.0" encoding="UTF-8" ?> <!DOCTYPE configuration PUBLIC "-//mybatis.org//DTD Config 3.0//EN" "http://mybatis.org/dtd/mybatis-3-config.dtd"> <configuration> <!--配置全局属性 --> <settings> <!--使用jdbc的getGeneratekeys获取自增主键值,默认是false 当inert一条记录时我们是不插入id的,id是通过自增去赋值的 当插入完后想得到该插入记录的id时可以调用jdbc的getGeneratekeys --> <setting name="useGeneratedKeys" value="true" /> <!--使用列别名替换列名 默认值为true(可以不用写出来,这里写出来只是为了讲解该配置的作用) select name as title(实体中的属性名是title) form table; 开启后mybatis会自动帮我们把表中name的值赋到对应实体的title属性中 --> <setting name="useColumnLabel" value="true" /> <!--开启驼峰命名转换Table:create_time到 Entity(createTime) --> <setting name="mapUnderscoreToCamelCase" value="true" /> </settings> </configuration>
在src/main/java
目录下的com.yang.mapper
包里创建SeckillDao.xml
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd"> <!-- namespace:指定为哪个接口提供配置 --> <mapper namespace="com.lewis.dao.SeckillDao"> <!--目的:为dao接口方法提供sql语句配置, 即针对dao接口中的方法编写我们的sql语句 --> <!-- int reduceNumber(long seckillId, Date killTime);--> <!-- 这里id必须和对应的DAO接口的方法名一样 --> <update id="reduceNumber"> UPDATE seckill SET number = number-1 WHERE seckill_id=#{seckillId} AND start_time <![CDATA[ <= ]]> #{killTime} AND end_time >= #{killTime} AND number > 0; </update> <!-- parameterType:使用到的参数类型 正常情况java表示一个类型的包名+类名,这直接写类名,因为后面有一个配置可以简化写包名的过程 --> <select id="queryById" resultType="Seckill" parameterType="long"> <!-- 可以通过别名的方式列明到java名的转换,如果开启了驼峰命名法就可以不用这么写了 select seckill_id as seckillId --> SELECT seckill_id,name,number,create_time,start_time,end_time FROM seckill WHERE seckill_id=#{seckillId} </select> <select id="queryAll" resultType="Seckill"> SELECT * FROM seckill ORDER BY create_time DESC limit #{offset},#{limit} </select> </mapper>
在src/main/java
目录下的com.yang.mapper
包里创建SuccessKilledDao.xml
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd"> <mapper namespace="com.lewis.dao.SuccessKilledDao"> <insert id="insertSuccessKilled"> <!--当出现主键冲突时(即重复秒杀时),会报错;不想让程序报错,加入ignore--> INSERT ignore INTO success_killed(seckill_id,user_phone,state) VALUES (#{seckillId},#{userPhone},0) </insert> <select id="queryByIdWithSeckill" resultType="SuccessKilled"> <!--根据seckillId查询SuccessKilled对象,并携带Seckill对象--> <!--如何告诉mybatis把结果映射到SuccessKill属性同时映射到Seckill属性--> <!--可以自由控制SQL语句--> SELECT sk.seckill_id, sk.user_phone, sk.create_time, sk.state, 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 success_killed sk INNER JOIN seckill s ON sk.seckill_id=s.seckill_id WHERE sk.seckill_id=#{seckillId} and sk.user_phone=#{userPhone} </select> </mapper>
注:上面的s.seckill_id “seckill.seckill_id”表示s.seckill_id这一列的数据是Success_killed实体类里的seckill属性里的seckill_id属性,是一个级联的过程,使用的就是别名只是忽略了as关键字,别名要加上双引号。
整合Spring和MyBatis
在resources
目录下创建一个新的目录spring
(存放所有Spring相关的配置)
在resources包下创建jdbc.properties,用于配置数据库的连接信息
driver=com.mysql.cj.jdbc.Driver
url=jdbc:mysql://localhost:3306/seckill?useUnicode=true&characterEncoding=UTF-8&useSSL=false&serverTimezone=UTC
jdbc.username=root
password=root
在resources/spring
目录下创建Spring关于DAO层的配置文件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 http://www.springframework.org/schema/context/spring-context.xsd"> <!--配置整合mybatis过程 1.配置数据库相关参数--> <context:property-placeholder location="classpath:jdbc.properties"/> <!--2.数据库连接池--> <bean id="dataSource" class="com.mchange.v2.c3p0.ComboPooledDataSource"> <!--配置连接池属性--> <property name="driverClass" value="${driver}" /> <!-- 基本属性 url、user、password --> <property name="jdbcUrl" value="${url}" /> <property name="user" value="${jdbc.username}" /> <property name="password" value="${password}" /> <!--c3p0私有属性--> <property name="maxPoolSize" value="30"/> <property name="minPoolSize" value="10"/> <!--关闭连接后不自动commit--> <property name="autoCommitOnClose" value="false"/> <!--获取连接超时时间--> <property name="checkoutTimeout" value="1000"/> <!--当获取连接失败重试次数--> <property name="acquireRetryAttempts" value="2"/> </bean> <!--约定大于配置--> <!--3.配置SqlSessionFactory对象--> <bean id="sqlSessionFactory" class="org.mybatis.spring.SqlSessionFactoryBean"> <!--往下才是mybatis和spring真正整合的配置--> <!--注入数据库连接池--> <property name="dataSource" ref="dataSource"/> <!--配置mybatis全局配置文件:mybatis-config.xml--> <property name="configLocation" value="classpath:mybatis-config.xml"/> <!--扫描entity包,使用别名,多个用;隔开--> <property name="typeAliasesPackage" value="com.yang.entity"/> <!--扫描sql配置文件:mapper需要的xml文件--> <property name="mapperLocations" value="classpath:com/yang/mapper/*.xml"/> </bean> <!--4:配置扫描Dao接口包,动态实现DAO接口,注入到spring容器--> <bean class="org.mybatis.spring.mapper.MapperScannerConfigurer"> <!--注入SqlSessionFactory--> <property name="sqlSessionFactoryBeanName" value="sqlSessionFactory"/> <!-- 给出需要扫描的Dao接口--> <property name="basePackage" value="com.yang.dao"/> </bean> <!--redisDao--> <!-- <bean id="redisDao" class="com.yang.dao.cache.RedisDao"> <constructor-arg index="0" value="localhost"/> <constructor-arg index="1" value="6379"/> </bean> --> </beans>
创建测试dao类利用idea自动创建相应包下
然后三个方法都勾上,选上juint4
问题:SpringJUnit4ClassRunner requires JUnit 4.12 or higher.
去maven改junit版本即可;
设计前的分析
分层的必要性
DAO层工作演变为:接口设计+SQL编写(不需要其他杂七杂八的功能)
代码和SQL的分离,方便review(浏览)
DAO拼接等逻辑在Service层完成(DAO只需负责SQL语句,其他都由Service层完成)
一些初学者容易出现的错误,就是喜欢在DAO层进行逻辑的编写,其实DAO就是数据访问的缩写,它只进行数据的访问操作。
关于秒杀地址的暴露
需要有专门一个方法实现秒杀地址输出,避免人为因素提前知道秒杀地址而出现漏洞。
获取秒杀url时,如果不合法,则返回当前时间和秒杀项目的时间;如果合法,才返回md5加密后url,以避免url被提前获知。
使用md5将url加密、校验,防止秒杀的url被篡改。
MD5加密
Spring提供了MD5生成工具。代码如下:
DigestUtils.md5DigestAsHex();
MD5盐值字符串(salt),用于混淆MD5,添加MD5反编译难度
Service层的接口设计
在src/main/java包下建立com.yang.service包,用来存放Service接口;
在src/main/java包下建立com.yang.exception包,用来存放Service层出现的异常类:比如重复秒杀异常、秒杀已关闭异常;
在src/main/java包下建立com.yang.dto包,用来封装Web层和Service层之间传递的数据(即:需要特定的返回的值等)。
(
DTO数据传输层:用于Web层和Service层之间传递的数据封装。
entity:用于业务数据的封装,比如数据库中的数据。
)
定义SeckillService接口
** * 业务接口:站在使用者(程序员)的角度设计接口 三个方面:1.方法定义粒度,方法定义的要非常清楚2.参数,要越简练越好 3.返回类型(return * 类型一定要友好/或者return异常,我们允许的异常) */ public interface SeckillService { /** * 查询全部的秒杀记录 * * @return */ List<Seckill> getSeckillList(); /** * 查询单个秒杀记录 * * @param seckillId * @return */ Seckill getById(long seckillId); // 再往下,是我们最重要的行为的一些接口 /** * 在秒杀开启时输出秒杀接口的地址,否则输出系统时间和秒杀时间 * * @param seckillId 秒杀商品Id * @return 根据对应的状态返回对应的状态实体 */ Exposer exportSeckillUrl(long seckillId); /** * 执行秒杀操作,有可能失败,有可能成功,所以要抛出我们允许的异常 * * @param seckillId 秒杀的商品ID * @param userPhone 手机号码 * @param md5 md5加密值 * @return 根据不同的结果返回不同的实体信息 */ SeckillExecution executeSeckill(long seckillId, long userPhone, String md5) throws SeckillException, RepeatKillException, SeckillCloseException; }
在我们需要的业务开启秒杀接口的时候需要返回一个DTO数据传输层:用于Web层和Service层之间传递的数据封装。
在dto包中创建Exposer.java,用于封装秒杀的地址信息(主要是封装一下返回信息)
/** * 暴露秒杀地址(接口)DTO */ public class Exposer { // 是否开启秒杀 private boolean exposed; // 加密措施 private String md5; //id为seckillId的商品的秒杀地址 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; } 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; } @Override public String toString() { return "Exposer{" + "exposed=" + exposed + ", md5='" + md5 + '\'' + ", seckillId=" + seckillId + ", now=" + now + ", start=" + start + ", end=" + end + '}'; } }
在dto包中创建SeckillExecution.java,用于封装秒杀是否成功的结果(该对象用来返回给页面)
/** * 封装执行秒杀后的结果:是否秒杀成功 */ public class SeckillExecution { private long seckillId; //秒杀执行结果的状态 private int state; //状态的明文标识 private String stateInfo; //当秒杀成功时,需要传递秒杀成功的对象回去 private SuccessKilled successKilled; //秒杀成功返回所有信息 public SeckillExecution(long seckillId, int state, String stateInfo, SuccessKilled successKilled) { this.seckillId = seckillId; this.state = state; this.stateInfo = stateInfo; this.successKilled = successKilled; } //秒杀失败 public SeckillExecution(long seckillId, int state, String stateInfo) { this.seckillId = seckillId; this.state = state; this.stateInfo = stateInfo; } 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; } }
在exception包中创建秒杀过程中可能出现的异常类
定义一个基础的异常类SeckillException,继承自RuntimeException
/** * 秒杀相关的所有业务异常 */ public class SeckillException extends RuntimeException { public SeckillException(String message) { super(message); } public SeckillException(String message, Throwable cause) { super(message, cause); } }
重复秒杀异常,继承自SeckillException
/** * 重复秒杀异常,是一个运行期异常,不需要我们手动try catch * Mysql只支持运行期异常的回滚操作 */ public class RepeatKillException extends SeckillException { public RepeatKillException(String message) { super(message); } public RepeatKillException(String message, Throwable cause) { super(message, cause); } }
秒杀已关闭异常,继承自SeckillException
/**
* 秒杀关闭异常,当秒杀结束时用户还要进行秒杀就会出现这个异常
*/
public class SeckillCloseException extends SeckillException{ public SeckillCloseException(String message) { super(message); } public SeckillCloseException(String message, Throwable cause) { super(message, cause); } }
Service层接口的实现
在com.yang.service
包下再建立impl
包,用来存放接口的实现类SeckillServiceImpl
public class SeckillServiceImpl implements SeckillService { //日志对象 private Logger logger= LoggerFactory.getLogger(this.getClass()); //加入一个混淆字符串(秒杀接口)的salt,为了我避免用户猜出我们的md5值,值任意给,越复杂越好 private final String salt="aksehiucka24sf*&%&^^#^%$"; //注入Service依赖 @Autowired //@Resource private SeckillDao seckillDao; @Autowired //@Resource private SuccessKilledDao successKilledDao; public List<Seckill> getSeckillList() { return seckillDao.queryAll(0,4); } public Seckill getById(long seckillId) { return seckillDao.queryById(seckillId); } public Exposer exportSeckillUrl(long seckillId) { Seckill seckill=seckillDao.queryById(seckillId); if (seckill==null) //说明查不到这个秒杀产品的记录 { return new Exposer(false,seckillId); } //若是秒杀未开启 Date startTime=seckill.getStartTime(); Date endTime=seckill.getEndTime(); //系统当前时间 Date nowTime=new Date(); if (startTime.getTime()>nowTime.getTime() || endTime.getTime()<nowTime.getTime()) { return new Exposer(false,seckillId,nowTime.getTime(),startTime.getTime(),endTime.getTime()); } //秒杀开启,返回秒杀商品的id、用给接口加密的md5 String md5=getMD5(seckillId); return new Exposer(true,md5,seckillId); } private String getMD5(long seckillId) { String base=seckillId+"/"+salt; String md5= DigestUtils.md5DigestAsHex(base.getBytes()); return md5; } //秒杀是否成功,成功:减库存,增加明细;失败:抛出异常,事务回滚 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 updateCount=seckillDao.reduceNumber(seckillId,nowTime); if (updateCount<=0) { //没有更新库存记录,说明秒杀结束 throw new SeckillCloseException("seckill is closed"); }else { //否则更新了库存,秒杀成功,增加明细 int insertCount=successKilledDao.insertSuccessKilled(seckillId,userPhone); //看是否该明细被重复插入,即用户是否重复秒杀 if (insertCount<=0) { throw new RepeatKillException("seckill repeated"); }else { //秒杀成功,得到成功插入的明细记录,并返回成功秒杀的信息 SuccessKilled successKilled=successKilledDao.queryByIdWithSeckill(seckillId,userPhone); return new SeckillExecution(seckillId,1,"秒杀成功",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()); } } }
在以上代码中,我们捕获了运行时异常,原因是Spring的事务默认是发生了RuntimeException才会回滚,发生了其他异常不会回滚,所以在最后的catch块里通过throw new SeckillException("seckill inner error :"+e.getMessage());将编译期异常转化为运行期异常。
另外,在代码里还存在着硬编码的情况,比如秒杀结果返回的state和stateInfo参数信息是输出给前端的,这些字符串应该考虑用常量枚举类封装起来,方便重复利用,也易于维护。
在之前创建的spring
包下创建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 http://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="com.lewis.service" /> <!--配置事务管理器 --> <bean id="transactionManager" class="org.springframework.jdbc.datasource.DataSourceTransactionManager"> <!--注入数据库连接池 --> <property name="dataSource" ref="dataSource" /> </bean> <!--配置基于注解的声明式事务 默认使用注解来管理事务行为 --> <tx:annotation-driven transaction-manager="transactionManager" /> </beans>
配置web.xml
<web-app xmlns="http://java.sun.com/xml/ns/javaee" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://java.sun.com/xml/ns/javaee http://java.sun.com/xml/ns/javaee/web-app_3_0.xsd" version="3.0" metadata-complete="true"> <!--用maven创建的web-app需要修改servlet的版本为3.0 --> <!--配置DispatcherServlet --> <servlet> <servlet-name>seckill-dispatcher</servlet-name> <servlet-class>org.springframework.web.servlet.DispatcherServlet</servlet-class> <!-- 配置SpringMVC 需要配置的文件 spring-dao.xml,spring-service.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>
在src/main/resources/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 http://www.springframework.org/schema/beans/spring-beans.xsd http://www.springframework.org/schema/context http://www.springframework.org/schema/context/spring-context.xsd http://www.springframework.org/schema/mvc http://www.springframework.org/schema/mvc/spring-mvc.xsd"> <!--配置spring mvc--> <!--1,开启springmvc注解模式 a.自动注册DefaultAnnotationHandlerMapping,AnnotationMethodHandlerAdapter b.默认提供一系列的功能:数据绑定,数字和日期的format@NumberFormat,@DateTimeFormat c:xml,json的默认读写支持--> <mvc:annotation-driven/> <!--2.静态资源默认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相关的controller--> <context:component-scan base-package="com.lewis.web"/> </beans>
在java包下新建com.yang.web
包,在该包下新建SeckillController.java
@Controller @RequestMapping("/seckill")//url:模块/资源/{}/细分 public class SeckillController { @Autowired private SeckillService seckillService; @RequestMapping(value = "/list",method = RequestMethod.GET) public String list(Model model) { //list.jsp+mode=ModelAndView //获取列表页 List<Seckill> list=seckillService.getSeckillList(); model.addAttribute("list",list); return "list"; } @RequestMapping(value = "/{seckillId}/detail",method = RequestMethod.GET) public String detail(@PathVariable("seckillId") Long seckillId, Model model) { if (seckillId == null) { return "redirect:/seckill/list"; } Seckill seckill=seckillService.getById(seckillId); if (seckill==null) { return "forward:/seckill/list"; } model.addAttribute("seckill",seckill); return "detail"; } //ajax ,json暴露秒杀接口的方法 @RequestMapping(value = "/{seckillId}/exposer", method = RequestMethod.GET, 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); }catch (Exception e) { e.printStackTrace(); result=new SeckillResult<Exposer>(false,e.getMessage()); } return result; } @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 = "userPhone",required = false) Long userPhone) { if (userPhone==null) { return new SeckillResult<SeckillExecution>(false,"未注册"); } try { SeckillExecution execution = seckillService.executeSeckill(seckillId, userPhone, md5); return new SeckillResult<SeckillExecution>(true, execution); }catch (RepeatKillException e1) { SeckillExecution execution=new SeckillExecution(seckillId, SeckillStatEnum.REPEAT_KILL); return new SeckillResult<SeckillExecution>(true,execution); }catch (SeckillCloseException e2) { SeckillExecution execution=new SeckillExecution(seckillId, SeckillStatEnum.END); return new SeckillResult<SeckillExecution>(true,execution); } catch (Exception e) { SeckillExecution execution=new SeckillExecution(seckillId, SeckillStatEnum.INNER_ERROR); return new SeckillResult<SeckillExecution>(true,execution); } } //获取系统时间 @RequestMapping(value = "/time/now",method = RequestMethod.GET) @ResponseBody public SeckillResult<Long> time() { Date now=new Date(); return new SeckillResult<Long>(true,now.getTime()); } }
SpringMVC在处理Cookie时有个小问题:如果找不到对应的Cookie会报错,所以设置为required=false
,将Cookie是否存在的逻辑判断放到代码中来判断。
并发性上不去是因为当多个线程同时访问一行数据时,产生了事务,因此产生写锁,每当一个获取了事务的线程把锁释放,另一个排队线程才能拿到写锁,QPS(Query Per Second每秒查询率)和事务执行的时间有密切关系,事务执行时间越短,并发性越高,这也是要将费时的I/O操作移出事务的原因。
在上图中,红色的部分就表示会发生高并发的地方,绿色部分表示对于高并发没有影响。
Redis属于NoSQL,即非关系型数据库,它是key-value型数据库,是直接在内存中进行存取数据的,所以有着很高的性能。
利用Redis可以减轻MySQL服务器的压力,减少了跟数据库服务器的通信次数。秒杀的瓶颈就在于跟数据库服务器的通信速度(MySQL本身的主键查询非常快)
Redis有很多客户端,我们的项目是用Java语言写的,自然选择对应Java语言的客户端,而官网最推荐我们的Java客户端是Jedis,在pom.xml里配置了Jedis依赖就可以使用它了,记得要先开启Redis的服务器,Jedis才能连接到服务器。
由于Jedis并没有实现内部序列化操作,而Java内置的序列化机制性能又不高,我们是一个秒杀系统,需要考虑高并发优化,在这里我们采用开源社区提供的更高性能的自定义序列化工具protostuff。
在Redis中减库存,那么用户也可能通过缓存来减库存,这样库存会不一致,所以要通过mysql的事务来保证一致性。
比如一个热点商品所有人都在抢,那么会在同一时间对数据表中的一行数据进行大量的update set操作。
行级锁在commit之后才释放,所以优化方向是减少行级锁的持有时间。
首先是在更新操作的时候给行加锁,插入并不会加锁,如果更新操作在前,那么就需要执行完更新和插入以后事务提交或回滚才释放锁。而如果插入在前,更新在后,那么只有在更新时才会加行锁,之后在更新完以后事务提交或回滚释放锁。
在这里,插入是可以并行的,而更新由于会加行级锁是串行的。
也就是说是更新在前加锁和释放锁之间两次的网络延迟和GC,如果插入在前则加锁和释放锁之间只有一次的网络延迟和GC,也就是减少的持有锁的时间。
这里先insert并不是忽略了库存不足的情况,而是因为insert和update是在同一个事务里,光是insert并不一定会提交,只有在update成功才会提交,所以并不会造成过量插入秒杀成功记录。