SpringBoot实现Java高并发秒杀系统之DAO层开发(一)

秒杀系统在如今电商项目中是很常见的,最近在学习电商项目时讲到了秒杀系统的实现,于是打算使用SpringBoot框架学习一下秒杀系统(本项目基于慕课网的一套免费视频教程:Java高并发秒杀API,视频教程中讲解的很详细,非常感谢这位讲师)。也是因为最近学习了SpringBoot框架(GitHub教程:SpringBoot入门之CRUD ),觉得SpringBoot框架确实比传统SSM框架方便了很多,于是更深层次练习使用SpringBoot框架,注意:SpringBoot不是对Spring功能上的增强,而是提供了一种快速使用Spring的方式。 如果你熟悉了SSM框架,学习SpringBoot框架也是很Easy的。

本项目的源码请参看:springboot-seckill 如果觉得不错可以star一下哦(#.#)

本项目一共分为四个模块来讲解,具体的开发教程请看我的博客文章:

  • SpringBoot实现Java高并发秒杀系统之DAO层开发(一)

  • SpringBoot实现Java高并发秒杀系统之Service层开发(二)

  • SpringBoot实现Java高并发秒杀系统之Web层开发(三)

  • SpringBoot实现Java高并发秒杀系统之并发优化(四)

起步

首先我们需要搭建SpringBoot项目开发环境,IDEA搭建SpringBoot项目的具体教程请看我的:博文。

如果你对SpringBoot框架或是SSM框架不熟悉,我想推荐一下我的几个小项目帮助你更好的理解:

  • SpringBoot起步之环境搭建

  • SpringBoot-Mybatis入门之CRUD

  • 手把手教你整合SSM框架

  • SSM框架入门之环境搭建


项目设计

.
├── README  -- Doc文档
├── db  -- 数据库约束文件
├── mvnw
├── mvnw.cmd
├── pom.xml  -- 项目依赖
└── src
    ├── main
    │   ├── java
    │   │   └── cn
    │   │       └── tycoding
    │   │           ├── SpringbootSeckillApplication.java  -- SpringBoot启动器
    │   │           ├── controller  -- MVC的web层
    │   │           ├── dto  -- 统一封装的一些结果属性,和entity类似
    │   │           ├── entity  -- 实体类
    │   │           ├── enums  -- 手动定义的字典枚举参数
    │   │           ├── exception  -- 统一的异常结果
    │   │           ├── mapper  -- Mybatis-Mapper层映射接口,或称为DAO层
    │   │           ├── redis  -- redis,jedis 相关配置
    │   │           └── service  -- 业务层
    │   └── resources
    │       ├── application.yml  -- SpringBoot核心配置
    │       ├── mapper  -- Mybatis-Mapper层XML映射文件
    │       ├── static  -- 存放页面静态资源,可通过浏览器直接访问
    │       │   ├── css
    │       │   ├── js
    │       │   └── lib
    │       └── templates  -- 存放Thymeleaf模板引擎所需的HTML,不能在浏览器直接访问
    │           ├── page
    │           └── public  -- HTML页面公共组件(头部、尾部)
    └── test  -- 测试文件

SpringBoot

之前我们在SpringBoot-Mybatis入门之CRUD中已经详细讲解了SpringBoot框架的开发流程,还是觉得一句话说的特别好:SpringBoot不是对对Spring功能上的增强,而是提供了一种快速使用Spring的方式。所以用SSM阶段的知识足够了SpringBoot阶段的开发,下面我们强调一下小技巧:

  • SpringBoot不需要配置注解扫描,之前我们配置扫描可能使用注解(@Service,@Component,@Controller等)的包路径。默认创建SpringBoot项目自动生成的Application.java启动器类会自动扫描其下的所有注解。

  • SpringBoot项目中静态资源都放在resources目录下,其中static目录中的数据可以直接通过浏览器访问,多用来放CSS、JS、img,但是不用来放html页面;其中templates用来存放HTML页面,但是需要在SpringBoot的配置文件(application.yml)中配置spring.thymeleaf.prefix标识Thymeleaf模板引擎渲染的页面位置。

  • HTML页面通过Thymeleaf的加持,为HTML页面赋予了很多功能,此时的HTML页面类似于JSP页面。访问后端存入域对象(session,request…)中的数据,可以通过th:text="${key}"获得,在JS中也可以通过[[${key}]]获得。

  • Thymeleaf提供了类似JSP页面的功能:public-component:

    ,main-component:
    (其中path表示public-component相对于templates的路径,/header表示component文件名,最后的header表示th:fragment中定义的名称)。

pom依赖

    <parent>
        <groupId>org.springframework.bootgroupId>
        <artifactId>spring-boot-starter-parentartifactId>
        <version>2.0.5.RELEASEversion>
        <relativePath/> 
    parent>

    <properties>
        <project.build.sourceEncoding>UTF-8project.build.sourceEncoding>
        <project.reporting.outputEncoding>UTF-8project.reporting.outputEncoding>
        <java.version>1.8java.version>
    properties>

    <dependencies>
        <dependency>
            <groupId>org.springframework.bootgroupId>
            <artifactId>spring-boot-starter-data-redisartifactId>
        dependency>
        <dependency>
            <groupId>org.springframework.bootgroupId>
            <artifactId>spring-boot-starter-jdbcartifactId>
        dependency>
        <dependency>
            <groupId>org.springframework.bootgroupId>
            <artifactId>spring-boot-starter-thymeleafartifactId>
        dependency>
        <dependency>
            <groupId>org.springframework.bootgroupId>
            <artifactId>spring-boot-starter-webartifactId>
        dependency>
        <dependency>
            <groupId>org.mybatis.spring.bootgroupId>
            <artifactId>mybatis-spring-boot-starterartifactId>
            <version>1.3.2version>
        dependency>

        <dependency>
            <groupId>org.springframework.bootgroupId>
            <artifactId>spring-boot-devtoolsartifactId>
            <scope>runtimescope>
        dependency>
        <dependency>
            <groupId>mysqlgroupId>
            <artifactId>mysql-connector-javaartifactId>
            <scope>runtimescope>
        dependency>
        <dependency>
            <groupId>org.springframework.bootgroupId>
            <artifactId>spring-boot-starter-testartifactId>
            <scope>testscope>
        dependency>

        
        <dependency>
            <groupId>com.alibabagroupId>
            <artifactId>druid-spring-boot-starterartifactId>
            <version>1.1.9version>
        dependency>

        
        <dependency>
            <groupId>redis.clientsgroupId>
            <artifactId>jedisartifactId>
        dependency>

        <dependency>
            <groupId>junitgroupId>
            <artifactId>junitartifactId>
            <version>4.12version>
            <scope>testscope>
        dependency>
    dependencies>

JavaBean实体类配置

此处源码请看:GitHub

Seckill.java

public class Seckill implements Serializable {

    private long seckillId; //商品ID
    private String title; //商品标题
    private String image; //商品图片
    private BigDecimal price; //商品原价格
    private BigDecimal costPrice; //商品秒杀价格

    @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
    private Date createTime; //创建时间

    @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
    private Date startTime; //秒杀开始时间

    @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
    private Date endTime; //秒杀结束时间

    private long stockCount; //剩余库存数量
}

SeckillOrder.java

public class SeckillOrder implements Serializable {

    private long seckillId; //秒杀到的商品ID
    private BigDecimal money; //支付金额

    private long userPhone; //秒杀用户的手机号

    @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
    private Date createTime; //创建时间

    private boolean status; //订单状态, -1:无效 0:成功 1:已付款

    private Seckill seckill; //秒杀商品,和订单是一对多的关系
}

注意实体类中Date类型数据都用了@DateTimeFormat()(来自springframework)和@JsonFormat()(来自jackson)标识可以实现Controller在返回JSON数据(用@ResponseBody标识的方法或@RestController标识的类)的时候能将Date类型的参数值(经Mybatis查询得到的数据是英文格式的日期,因为实体类中是Date类型)转换为注解中指定的格式返回给页面(相当于经过了一层SimpleDateFormate)。

其次要注意在编写实体类的时候尽量养成习惯继承Serializable接口。在SeckillOrder中我们注入了Seckill类作为一个属性,目的是为了可以使用多表查询的方式从seckill_order表中查询出来对应的seckill表数据。

表设计

创建完成了SpringBoot项目,首先我们需要初始化数据库,秒杀系统的建表SQL如下:

/*
 *  mysql-v: 5.7.22
 */

-- 创建数据库
-- CREATE DATABASE seckill DEFAULT CHARACTER SET utf8;

DROP TABLE IF EXISTS `seckill`;
DROP TABLE IF EXISTS `seckill_order`;

-- 创建秒杀商品表
CREATE TABLE `seckill`(
  `seckill_id` bigint NOT NULL AUTO_INCREMENT COMMENT '商品ID',
  `title` varchar (1000) DEFAULT NULL COMMENT '商品标题',
  `image` varchar (1000) DEFAULT NULL COMMENT '商品图片',
  `price` decimal (10,2) DEFAULT NULL COMMENT '商品原价格',
  `cost_price` decimal (10,2) DEFAULT NULL COMMENT '商品秒杀价格',
  `stock_count` bigint DEFAULT NULL COMMENT '剩余库存数量',
  `start_time` timestamp NOT NULL DEFAULT '1970-02-01 00:00:01' COMMENT '秒杀开始时间',
  `end_time` timestamp NOT NULL DEFAULT '1970-02-01 00:00:01' 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` (`end_time`)
) CHARSET=utf8 ENGINE=InnoDB COMMENT '秒杀商品表';

-- 创建秒杀订单表
CREATE TABLE `seckill_order`(
  `seckill_id` bigint NOT NULL COMMENT '秒杀商品ID',
  `money` decimal (10, 2) DEFAULT NULL COMMENT '支付金额',
  `user_phone` bigint NOT NULL COMMENT '用户手机号',
  `create_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '创建时间',
  `state` tinyint NOT NULL DEFAULT -1 COMMENT '状态:-1无效 0成功 1已付款',
  PRIMARY KEY (`seckill_id`, `user_phone`) /*联合主键,保证一个用户只能秒杀一件商品*/
) CHARSET=utf8 ENGINE=InnoDB COMMENT '秒杀订单表';

解释

秒杀系统的表设计还是相对简单清晰的,这里我们只考虑秒杀系统的业务表,不涉及其他的表,所以整个系统主要涉及两张表:秒杀商品表、订单表。当然实际情况肯定不止这两张表(比如付款相关表,但是我们并未实现这个功能),也不止表中的这些字段。这里我们需要特别注意以下几点:

注意

  • 1.我这里使用的Mysql版本是5.7.22,在Mysql5.7之后timestamp默认值不能再是0000 00-00 00:00:00,具体的介绍请看:mysql官方文档。即 TIMESTAMP has a range of ‘1970-01-01 00:00:01’ UTC to ‘2038-01-19 03:14:07’ UTC.

  • 2.timestamp类型用来实现自动为新增行字段设置当前系统时间;且使用timestamp的字段必须给timestamp设置默认值,而在Mysql中date, datetime等类型都是无法实现默认设置当前系统时间值的功能(DEFAULT CURRENT_TIMESTAMP)的,所以我们必须使用timestamp类型,否则你要给字段传进来系统时间。

  • 3.decimal类型用于在数据库中设置精确的数值,比如decimal(10,2)表示可以存储10位且有2位小数的数值。

  • 4.tinyint类型用于存放int类型的数值,但是若用Mybatis作为DAO层框架,Mybatis会自动为tinyint类型的数据转换成true或false(0:false; 1 or 1+:true)。

  • 5.在订单表seckill_order中我们设计了联合主键:PRIMARY KEY (seckill_id, user_phone),目的是为了避免单个用户重复购买同一件商品(一个用户只能秒杀到一次同一件商品)。

  • 6.无论是创建数据库还是创建表我们都应该养成一个习惯就是指定character=utf-8,避免中文数据乱码;其次还应该指定表的储存引擎是InnoDB,MySQL提供了两种储存引擎:InnoDB, MyISAM。但是只有InnoDB是支持事务的,且InnoDB相比MyISAM在并发上更具有高性能的优点。

DAO层开发

DAO层是我们常说的三层架构(Web层-业务层-持久层)中与数据库交互的持久层,但是实际而言,架构是这样设计的,但是并不代表着实际项目中就一定存在一个dao文件夹,特别是现阶段我们使用的Spring-Mybatis框架。Mybatis提供了一种接口代理开发模式,也就是我们需要提供一个interface接口,其他和数据库交互的SQL编写放到对应的XML文件中(但是需要进行相关的数据库参数配置,并且Mybatis规定了使用这种开发模式必须保持接口和XML文件名称对应)。于是在本项目中就没有出现dao整个文件夹,取而代之的是mapper这个文件夹,我感觉更易识别出为Mybatis的映射接口文件。其实在实际项目中考虑到项目的大小和复杂程度,daomapper可能是同时存在的,因为service可能并不满足项目的设计,即为dao接口创建实现类,在实现类中再调用mapper接口来实现功能模块的扩展。


DAO层开发,即DAO层接口开发,主要设计需要和数据库交互的数据有哪些?应该用什么返回值类型接收查询到的数据?所以包含的方法有哪些?带着这些问题,我们先看一下秒杀系统的业务流程:

由上图可以看出,相对与本项目而言和数据库打交道的主要涉及两个操作:1.减库存(秒杀商品表);2.记录购买明细(订单表)。

  • 减库存,顾名思义就是减少当前被秒杀到的商品的库存数量,这也是秒杀系统中一个处理难点的地方。实现减库存即count-1,但是我们需要考虑Mysql的事务特性引发的种种问题、需要考虑如何避免同一用户重复秒杀的行为。

  • 如果减库存的业务解决了那么记录购买明细的业务就相对简单很多了,我们需要记录购买用户的姓名、手机号、购买的商品ID等。因为本项目中不涉及支付功能,所以记录用户的购买订单的业务并不复杂。

分析了上面的功能,下面我们开始DAO层接口的编写(源码请看:GitHub):

    /**
     * 减库存。
     * 对于Mapper映射接口方法中存在多个参数的要加@Param()注解标识字段名称,不然Mybatis不能识别出来哪个字段相互对应
     *
     * @param seckillId 秒杀商品ID
     * @param killTime  秒杀时间
     * @return 返回此SQL更新的记录数,如果>=1表示更新成功
     */
    int reduceStock(@Param("seckillId") long seckillId, @Param("killTime") Date killTime);

    /**
     * 插入购买订单明细
     *
     * @param seckillId 秒杀到的商品ID
     * @param money     秒杀的金额
     * @param userPhone 秒杀的用户
     * @return 返回该SQL更新的记录数,如果>=1则更新成功
     */
    int insertOrder(@Param("seckillId") long seckillId, @Param("money") BigDecimal money, @Param("userPhone") long userPhone);

但从接口设计上我们无非关注的就是这两个方法:1.减库存;2.插入购买明细。此处需要注意的是:

  • 对于SpringBoot系统,DAO(Mapper)层的接口需要使用@Mapper注解标识。因为SpringBoot系统中接口的XML文件不在/java目录下而是在/resources目录下。

  • 对于Mapper接口方法中存在传递多个参数的情况需要使用@Param()标识这个参数的名称,目的是为了帮助Mybatis识别传递的参数,不然Mybatis的XML中用的#{}不能识别出来你传递的参数名称是谁和谁对应的,类似于Controller层中常用的@RequestParam()注解。

  • 小技巧: 之前我们做insert和update操作时直接用void作为方法返回值,实际上虽然Mybatis的