手摸手带你写项目----秒杀系统(一)

博客地址: 手摸手带你写项目----秒杀系统(一)
所有文章会第一时间在博客更新!

后面的时间我会手摸手带大家一起写几个实战性的项目。主要希望能应用上之前梳理的那些知识点,同时让没有写过项目的同学对实战项目有一定的认识。

小明问:手摸手?你这项目是正经项目吗?
我:我做这个的,能教给你不正经项目?doge

当然基于个人的认识不足,肯定有写的不好的地方,希望同学们能在评论区指出0 0。

1. 项目介绍

秒杀系统其实大家在日常生活中接触很多,12306抢票、特价商品抢购、拼多多拼团等等等等。秒杀系统的特点就是短时间高并发大流量。特别是类似于12306、淘宝等客量大的平台,对于部分热门活动的QPS可能会高达上千万次,甚至在春节期间,12306的QPS会高达上亿次。

而这对于我们一般使用的利于MySQL等关系型数据库来说,它们的QPS也就在数万级(单机情况下),肯定是远远无法达到系统需求的。

那么这个时候,系统的技术选型以及系统设计就非常重要了。同时为了应对短时间、大流量的高并发请求,代码的实现细节也十分重要。

一旦代码实现出现逻辑上的问题,轻则系统崩溃,活动无法进行下去,出现线上事故;重则出现商品大量超卖情况,那只能自己主动辞职了0 0。

有的同学说,那我是不是只要解决了超卖问题,系统能正常运行就没问题了呢?也不尽然,我们还要防黄牛党用脚本抢购商品。

本来公司组织的秒杀活动是用来引流的,赚不到什么钱,结果还全被黄牛抢完了,正常用户抢不到,用户粘性变差,达不到引流效果,同样不行。

所以一个秒杀系统看似简单,里面需要注意的细节非常多。

那么,一个完整的秒杀系统的架构是怎样的呢?这里偷一张敖丙大佬的图:

手摸手带你写项目----秒杀系统(一)_第1张图片

图片出处: 敖丙带你设计【秒杀系统】

下面我们开始一步一步的来实现一个秒杀系统。其中可能会用到乐观锁、Redis缓存、令牌桶限流等技术手段。

注意,这里我们的重点放在实现秒杀系统的流程上,所以对于一个正常的商城系统中的权限管理、用户管理等这里暂不考虑。

1.项目创建

首先我们创建一个IDEA项目,这里我的项目环境如下:

  • 系统环境:WIndows10专业版
  • JDK版本:JDK1.8
  • MySQL版本:MySQL 5.7.28
  • Redis版本:redis-3.0.504
  • JMeter版本(用于项目压测):apache-jmeter-5.4.1

第一个版本在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

项目基础搭建完成之后,下面我们来创建数据库。

2. 数据库创建

其实正常来分析一个项目,应该从系统设计出发,确定好多个主体的属性以及关系之后,再根据这些属性和关系来划分成数据库中的一个一个表,这里由于篇幅原因,就不再做具体分析,感兴趣的同学可以找我私聊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表中插入一条测试数据。

image.png

注意:这里的count表示商品的总量,sale表示已售商品数,price表明商品单价,version表示版本号,后面用于使用乐观锁来解决超卖问题。

3. 实现 entity层、Dao层

根据数据库的字段,我们在项目中创建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>

4. 实现Contrller层,并根据Controller层的需要实现Service层

注意,Controller层的作用是尽可能的只做数据校验和异常处理,不要将业务逻辑放在Controller层中;业务逻辑应该都在Service层中处理完成。

那么一个正常的秒杀系统中下单的流程是怎样的呢?我认为对于后台逻辑来说,可以简单的分为三步:

  1. 检查库存是否足够
  2. 创建订单
  3. 返回订单信息

那么这三步在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;
    }
}

5. 启动项目

启动项目前,需要在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的商品。

返回结果如下表明代码编写无误。

手摸手带你写项目----秒杀系统(一)_第2张图片

多次刷新你会发现,好像整个系统没有问题啊?

但是其实这里只是debug环境、单机情况下访问接口,相当于一个用户单线程下单商品,当然没问题,但是多线程情况下呢?

这里我们就需要使用到JMeter压力测试工具。

这里有关JMeter的介绍以及如何使用不多做赘述,这里有一篇文章介绍的比较详细了。

JMeter教程

我们启动JMeter的GUI界面,创建一个Thread Group:

手摸手带你写项目----秒杀系统(一)_第3张图片

修改线程数为1000:

手摸手带你写项目----秒杀系统(一)_第4张图片

然后右键Thread Group,创建一个HTTP请求:

手摸手带你写项目----秒杀系统(一)_第5张图片

然后填入接口信息:

手摸手带你写项目----秒杀系统(一)_第6张图片

需要填入的地方我都用红框标注出来了。

然后我们添加BeanShell Sampler,并添加如下语句:

prev.setDataEncoding(“utf-8”);

手摸手带你写项目----秒杀系统(一)_第7张图片

这里是为了解决返回的http请求的中文乱码问题。

然后添加返回结果树来查看每个线程请求后的返回信息:

手摸手带你写项目----秒杀系统(一)_第8张图片

好,所有准备工作完成后,这里我们点击左上角的开始按钮,开始测试:

手摸手带你写项目----秒杀系统(一)_第9张图片

中间会询问是否保存当前测试文件,选否即可。

等待运行结束后,这里我们可以在View Results Tree中看见请求的返回信息:

手摸手带你写项目----秒杀系统(一)_第10张图片

好,这里我们返回我们的数据库,可以看到,我们的stock表中,sale的值是250没问题:

手摸手带你写项目----秒杀系统(一)_第11张图片

但是,查看stock_order表就会发现,问题大了,id序号是从2578到2984:

手摸手带你写项目----秒杀系统(一)_第12张图片

image.png

卖出了整整406件!远远超出了我们设定的库存数量250件!

tips:这里每次测试的实际卖出数量都有可能不同,但是发生超卖的情况非常大。测试线程数越多,超卖的数量有可能越多。

这可怎么办?!下一篇,我们将详细介绍,如何解决超卖问题。

我们下期再见0 0!。

6.源代码地址

源代码如下,会根据项目进度不定期更新:

github地址: 秒杀项目实战
gitte地址:秒杀项目实战

你可能感兴趣的:(项目实战,数据库,redis,mysql)