博客地址: 手摸手带你写项目----秒杀系统(一)
所有文章会第一时间在博客更新!
后面的时间我会手摸手带大家一起写几个实战性的项目。主要希望能应用上之前梳理的那些知识点,同时让没有写过项目的同学对实战项目有一定的认识。
小明问:手摸手?你这项目是正经项目吗?
我:我做这个的,能教给你不正经项目?doge
当然基于个人的认识不足,肯定有写的不好的地方,希望同学们能在评论区指出0 0。
秒杀系统其实大家在日常生活中接触很多,12306抢票、特价商品抢购、拼多多拼团等等等等。秒杀系统的特点就是短时间、高并发、大流量。特别是类似于12306、淘宝等客量大的平台,对于部分热门活动的QPS可能会高达上千万次,甚至在春节期间,12306的QPS会高达上亿次。
而这对于我们一般使用的利于MySQL等关系型数据库来说,它们的QPS也就在数万级(单机情况下),肯定是远远无法达到系统需求的。
那么这个时候,系统的技术选型以及系统设计就非常重要了。同时为了应对短时间、大流量的高并发请求,代码的实现细节也十分重要。
一旦代码实现出现逻辑上的问题,轻则系统崩溃,活动无法进行下去,出现线上事故;重则出现商品大量超卖情况,那只能自己主动辞职了0 0。
有的同学说,那我是不是只要解决了超卖问题,系统能正常运行就没问题了呢?也不尽然,我们还要防黄牛党用脚本抢购商品。
本来公司组织的秒杀活动是用来引流的,赚不到什么钱,结果还全被黄牛抢完了,正常用户抢不到,用户粘性变差,达不到引流效果,同样不行。
所以一个秒杀系统看似简单,里面需要注意的细节非常多。
那么,一个完整的秒杀系统的架构是怎样的呢?这里偷一张敖丙大佬的图:
图片出处: 敖丙带你设计【秒杀系统】
下面我们开始一步一步的来实现一个秒杀系统。其中可能会用到乐观锁、Redis缓存、令牌桶限流等技术手段。
注意,这里我们的重点放在实现秒杀系统的流程上,所以对于一个正常的商城系统中的权限管理、用户管理等这里暂不考虑。
首先我们创建一个IDEA项目,这里我的项目环境如下:
第一个版本在pom.xml中加入了lombok,以及druid用于做数据库连接池。完整的pom文件如下:
<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 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0modelVersion>
<parent>
<groupId>org.springframework.bootgroupId>
<artifactId>spring-boot-starter-parentartifactId>
<version>2.5.5version>
<relativePath/>
parent>
<groupId>cn.codinglemongroupId>
<artifactId>demoartifactId>
<version>0.0.1-SNAPSHOTversion>
<name>demoname>
<description>Demo project for Spring Bootdescription>
<properties>
<java.version>1.8java.version>
properties>
<dependencies>
<dependency>
<groupId>org.springframework.bootgroupId>
<artifactId>spring-boot-starter-webartifactId>
dependency>
<dependency>
<groupId>org.mybatis.spring.bootgroupId>
<artifactId>mybatis-spring-boot-starterartifactId>
<version>2.2.0version>
dependency>
<dependency>
<groupId>org.springframework.bootgroupId>
<artifactId>spring-boot-devtoolsartifactId>
<scope>runtimescope>
<optional>trueoptional>
dependency>
<dependency>
<groupId>mysqlgroupId>
<artifactId>mysql-connector-javaartifactId>
<scope>runtimescope>
dependency>
<dependency>
<groupId>org.projectlombokgroupId>
<artifactId>lombokartifactId>
<optional>trueoptional>
dependency>
<dependency>
<groupId>org.springframework.bootgroupId>
<artifactId>spring-boot-starter-testartifactId>
<scope>testscope>
dependency>
<dependency>
<groupId>com.alibabagroupId>
<artifactId>druidartifactId>
<version>1.2.6version>
dependency>
dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.bootgroupId>
<artifactId>spring-boot-maven-pluginartifactId>
<configuration>
<excludes>
<exclude>
<groupId>org.projectlombokgroupId>
<artifactId>lombokartifactId>
exclude>
excludes>
configuration>
plugin>
plugins>
build>
project>
application.yml的配置参数如下:
server:
port: 8989
servlet:
context-path: /miaosha
spring:
datasource:
type: com.alibaba.druid.pool.DruidDataSource
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://localhost:3306/miaosha
username: root
password: ****
mybatis:
mapper-locations: classpath:mapper/*.xml
type-aliases-package: cn.codinglemon.demo.entity
logging:
level:
root: info
cn:
codinglemon:
dao: debug
项目基础搭建完成之后,下面我们来创建数据库。
其实正常来分析一个项目,应该从系统设计出发,确定好多个主体的属性以及关系之后,再根据这些属性和关系来划分成数据库中的一个一个表,这里由于篇幅原因,就不再做具体分析,感兴趣的同学可以找我私聊0 0。为了尽可能的简化项目,这里只有两张表,库存表stock和订单表stock_order。
具体SQL代码如下:
-- stock表
CREATE TABLE `stock` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`name` varchar(50) CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL DEFAULT '' COMMENT '商品名称',
`count` int(11) NOT NULL DEFAULT 0 COMMENT '库存',
`sale` int(11) NOT NULL DEFAULT 0 COMMENT '已售',
`price` decimal(10, 2) NOT NULL DEFAULT 0.00 COMMENT '单价',
`version` int(11) NOT NULL DEFAULT 0 COMMENT '乐观锁使用的版本号',
PRIMARY KEY (`id`) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 2 CHARACTER SET = utf8 COLLATE = utf8_general_ci ROW_FORMAT = Dynamic;
SET FOREIGN_KEY_CHECKS = 1;
-- stock_order表
CREATE TABLE `stock_order` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`sid` int(11) NOT NULL DEFAULT 0 COMMENT '库存id',
`name` varchar(30) CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL DEFAULT '' COMMENT '商品名称',
`count` int(11) NULL DEFAULT NULL COMMENT '数量',
`total_price` decimal(10, 2) NULL DEFAULT NULL COMMENT '总价',
`create_time` datetime NOT NULL ON UPDATE CURRENT_TIMESTAMP COMMENT '创建时间',
PRIMARY KEY (`id`) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 2578 CHARACTER SET = utf8 COLLATE = utf8_general_ci ROW_FORMAT = Dynamic;
SET FOREIGN_KEY_CHECKS = 1;
数据库创建完成后,我们在stock表中插入一条测试数据。
注意:这里的count表示商品的总量,sale表示已售商品数,price表明商品单价,version表示版本号,后面用于使用乐观锁来解决超卖问题。
根据数据库的字段,我们在项目中创建entity包,并将两个表对应的类创建,代码如下:
package cn.codinglemon.demo.entity;
import lombok.Data;
/**
* @author zry
* @date 2021-9-29 16:28
*/
@Data
public class Stock {
private Integer id;
private String name;
private Integer count;
private Integer sale;
//这里的价格使用long字段来表示,price的值等于商品输入价格*10,以此来解决计算机浮点数计算精度问题
private long price;
private Integer version;
}
因为使用了lombok,所以直接在类上使用@Data注解,它会自动帮我们加上getter、setter方法。
另外,这里的商品单价用long字段,是为了解决计算机浮点数计算的精度问题。商品实际价格为price/10。
package cn.codinglemon.demo.entity;
import lombok.Data;
import java.util.Date;
/**
* @author zry
* @date 2021-9-29 16:29
*/
@Data
public class StockOrder {
private Integer id;
private Integer sid;
private String name;
private Integer count;
//这里的价格使用long字段来表示,totalPrice的值等于商品输入价格*10,以此来解决计算机浮点数计算精度问题
private long totalPrice;
private Date createTime;
}
StockOrder 中的totalPrice同理。
entity层实现完成后,这里我们来实现Dao层,具体代码如下:
StockDao :
package cn.codinglemon.demo.dao;
import cn.codinglemon.demo.entity.Stock;
import org.apache.ibatis.annotations.Param;
/**
* @author zry
* @date 2021-9-29 16:30
*/
public interface StockDao {
Stock selectById(@Param("id")Integer id);
int sale(@Param("id")Integer id,@Param("sale")Integer sale);
}
与之对应的xml:
DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd" >
<mapper namespace="cn.codinglemon.demo.dao.StockDao">
<select id="selectById" resultType="cn.codinglemon.demo.entity.Stock">
select id,name,count,sale,price,version from stock where id = #{id}
select>
<update id="sale" >
update stock set sale = #{sale} where id = #{id} and count >= #{sale}
update>
mapper>
StockOrderDao:
package cn.codinglemon.demo.dao;
import cn.codinglemon.demo.entity.StockOrder;
/**
* @author zry
* @date 2021-9-29 16:31
*/
public interface StockOrderDao {
int createOrder(StockOrder stockOrder);
}
与之对应的xml:
DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd" >
<mapper namespace="cn.codinglemon.demo.dao.StockOrderDao">
<insert id="createOrder" useGeneratedKeys="true" keyProperty="id">
insert into stock_order values(#{id},#{sid},#{name},#{count},#{totalPrice},NOW());
insert>
mapper>
注意,Controller层的作用是尽可能的只做数据校验和异常处理,不要将业务逻辑放在Controller层中;业务逻辑应该都在Service层中处理完成。
那么一个正常的秒杀系统中下单的流程是怎样的呢?我认为对于后台逻辑来说,可以简单的分为三步:
那么这三步在Controller层中如何体现呢?实际编写代码如下:
package cn.codinglemon.demo.controller;
import cn.codinglemon.demo.Response.StockResponseEnum;
import cn.codinglemon.demo.entity.Stock;
import cn.codinglemon.demo.entity.StockOrder;
import cn.codinglemon.demo.service.StockOrderService;
import cn.codinglemon.demo.service.StockService;
import cn.codinglemon.demo.util.ResponseBean;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
/**
* @author zry
* @date 2021-9-29 16:42
*/
@RestController
@CrossOrigin
@RequestMapping("/stock")
public class StockController {
@Autowired
private StockService stockService;
@Autowired
private StockOrderService stockOrderService;
@GetMapping("/kill")
public ResponseBean kill(@RequestParam("id")Integer id,@RequestParam("count")Integer count){
ResponseBean responseBean = new ResponseBean();
//检查库存是否足够
if(stockService.checkStock(id,count)){
//创建订单
Stock stock = stockService.selectById(id);
StockOrder stockOrder = new StockOrder();
stockOrder.setName(stock.getName());
stockOrder.setSid(stock.getId());
stockOrder.setTotalPrice(count*stock.getPrice());
stockOrder.setCount(count);
stockOrder = stockOrderService.createOrder(stockOrder);
if(stockOrder !=null){
//返回订单信息
responseBean.setCode(StockResponseEnum.StOCK_SUCCESS.getCode());
responseBean.setMsg(StockResponseEnum.StOCK_SUCCESS.getMessage());
responseBean.setData(stockOrder);
} else {
responseBean.setCode(StockResponseEnum.STOCK_NOT_ENOUGH.getCode());
responseBean.setMsg(StockResponseEnum.STOCK_NOT_ENOUGH.getMessage());
}
}else {
responseBean.setCode(StockResponseEnum.STOCK_NOT_ENOUGH.getCode());
responseBean.setMsg(StockResponseEnum.STOCK_NOT_ENOUGH.getMessage());
}
return responseBean;
}
}
这里我们创建了一个ResponseBean来做统一的接口返回处理,其代码如下:
package cn.codinglemon.demo.Response;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.ToString;
/**
* @author zry
* @date 2020-10-17 15:57:35
* 返回给前台的数据对象
*/
@Data
@AllArgsConstructor
@NoArgsConstructor
@ToString
public class ResponseBean {
private Integer code;
private String msg;
private Object data;
}
另外创建了一个Enum用于存放状态码和状态信息,代码如下:
package cn.codinglemon.demo.Response;
/**
* @author zry
* @date 2021-9-29 20:16
*/
public enum StockResponseEnum {
StOCK_SUCCESS(20001,"下单商品成功"),
STOCK_NOT_ENOUGH(20002,"商品库存不足");
;
StockResponseEnum(Integer code,String message) {
this.code =code;
this.message =message;
}
private int code;
private String message;
public int getCode() {
return this.code;
}
public String getMessage() {
return this.message;
}
public StockResponseEnum setMessage(String message) {
this.message = message;
return this;
}
}
这里我们还没有实现的是StockService以及StockOrderService。StockService中分别有两个方法,第一个是检查库存,第二个是根据id获取Stock对象。
StockOrderService中就一个创建订单的方法。
两个service代码如下:
package cn.codinglemon.demo.service;
import cn.codinglemon.demo.entity.Stock;
/**
* @author zry
* @date 2021-9-29 18:39
*/
public interface StockService {
boolean checkStock(Integer id,Integer count);
Stock selectById(Integer id);
}
对应的实现类:
package cn.codinglemon.demo.service.impl;
import cn.codinglemon.demo.dao.StockDao;
import cn.codinglemon.demo.entity.Stock;
import cn.codinglemon.demo.service.StockService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.util.ObjectUtils;
/**
* @author zry
* @date 2021-9-29 18:42
*/
@Service
public class StockServiceImpl implements StockService {
@Autowired
private StockDao stockDao;
@Override
public boolean checkStock(Integer id,Integer count) {
Stock stock = stockDao.selectById(id);
if(!ObjectUtils.isEmpty(stock)){
//判断当前库存是否充足
return stock.getCount() >= stock.getSale() + count;
}
return false;
}
@Override
public Stock selectById(Integer id) {
return stockDao.selectById(id);
}
}
package cn.codinglemon.demo.service;
import cn.codinglemon.demo.entity.StockOrder;
/**
* @author zry
* @date 2021-9-29 18:48
*/
public interface StockOrderService {
StockOrder createOrder(StockOrder stockOrder);
}
对应的实现类:
package cn.codinglemon.demo.service.impl;
import cn.codinglemon.demo.dao.StockDao;
import cn.codinglemon.demo.dao.StockOrderDao;
import cn.codinglemon.demo.entity.Stock;
import cn.codinglemon.demo.entity.StockOrder;
import cn.codinglemon.demo.service.StockOrderService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
/**
* @author zry
* @date 2021-9-29 18:49
*/
@Service
public class StockOrderServiceImpl implements StockOrderService {
@Autowired
private StockOrderDao stockOrderDao;
@Autowired
private StockDao stockDao;
@Override
@Transactional(propagation = Propagation.REQUIRED)
public StockOrder createOrder(StockOrder stockOrder) {
Stock stock = stockDao.selectById(stockOrder.getSid());
//查看能否找到商品
if( stock != null){
boolean changeStock = stockDao.sale(stockOrder.getSid(),stockOrder.getCount()+stock.getSale()) > 0;
//判断更新库存成功
if(changeStock){
boolean result = stockOrderDao.createOrder(stockOrder) >0;
//判断订单是否成功存入数据库
if(result){
return stockOrder;
}
}
}
return null;
}
}
启动项目前,需要在Application类上添加如下注解,用于扫描Dao层。
package cn.codinglemon.demo;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
//添加注解,扫描dao层
@MapperScan("cn.codinglemon.demo.dao")
public class DemoApplication {
public static void main(String[] args) {
SpringApplication.run(DemoApplication.class, args);
}
}
这里第一版的代码基本完成,我们启动项目看看。
如果启动后或访问接口有问题,可以查看控制台错误信息或者对照我的代码来检查一下。所有代码我会在我的github以及gitee上更新,地址在文章末尾。
我们访问接口:
http://localhost:8989/miaosha/stock/kill?id=1&count=1
这里会下单一个id为1的商品。
返回结果如下表明代码编写无误。
多次刷新你会发现,好像整个系统没有问题啊?
但是其实这里只是debug环境、单机情况下访问接口,相当于一个用户单线程下单商品,当然没问题,但是多线程情况下呢?
这里我们就需要使用到JMeter压力测试工具。
这里有关JMeter的介绍以及如何使用不多做赘述,这里有一篇文章介绍的比较详细了。
JMeter教程
我们启动JMeter的GUI界面,创建一个Thread Group:
修改线程数为1000:
然后右键Thread Group,创建一个HTTP请求:
然后填入接口信息:
需要填入的地方我都用红框标注出来了。
然后我们添加BeanShell Sampler,并添加如下语句:
prev.setDataEncoding(“utf-8”);
这里是为了解决返回的http请求的中文乱码问题。
然后添加返回结果树来查看每个线程请求后的返回信息:
好,所有准备工作完成后,这里我们点击左上角的开始按钮,开始测试:
中间会询问是否保存当前测试文件,选否即可。
等待运行结束后,这里我们可以在View Results Tree中看见请求的返回信息:
好,这里我们返回我们的数据库,可以看到,我们的stock表中,sale的值是250没问题:
但是,查看stock_order表就会发现,问题大了,id序号是从2578到2984:
卖出了整整406件!远远超出了我们设定的库存数量250件!
tips:这里每次测试的实际卖出数量都有可能不同,但是发生超卖的情况非常大。测试线程数越多,超卖的数量有可能越多。
这可怎么办?!下一篇,我们将详细介绍,如何解决超卖问题。
我们下期再见0 0!。
源代码如下,会根据项目进度不定期更新:
github地址: 秒杀项目实战
gitte地址:秒杀项目实战