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

/ / 1 条评论 / 1927 浏览

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

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

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

起步

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

如果你对SpringBoot框架或是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阶段的开发,下面我们强调一下小技巧:

pom依赖

    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>2.0.5.RELEASE</version>
        <relativePath/> <!-- lookup parent from repository -->
    </parent>

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

    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-redis</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-jdbc</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-thymeleaf</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <dependency>
            <groupId>org.mybatis.spring.boot</groupId>
            <artifactId>mybatis-spring-boot-starter</artifactId>
            <version>1.3.2</version>
        </dependency>

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-devtools</artifactId>
            <scope>runtime</scope>
        </dependency>
        <dependency>
            <groupId>mysql</groupId>
            <artifactId>mysql-connector-java</artifactId>
            <scope>runtime</scope>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>

        <!-- alibaba的druid数据库连接池 -->
        <dependency>
            <groupId>com.alibaba</groupId>
            <artifactId>druid-spring-boot-starter</artifactId>
            <version>1.1.9</version>
        </dependency>

        <!-- redis客户端 -->
        <dependency>
            <groupId>redis.clients</groupId>
            <artifactId>jedis</artifactId>
        </dependency>

        <dependency>
            <groupId>junit</groupId>
            <artifactId>junit</artifactId>
            <version>4.12</version>
            <scope>test</scope>
        </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 '秒杀订单表';

解释

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

注意

DAO层开发

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


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

image

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

分析了上面的功能,下面我们开始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.插入购买明细。此处需要注意的是:

XML映射

    <update id="reduceStock">
        UPDATE seckill
        SET stock_count = stock_count - 1
        WHERE seckill_id = #{seckillId}
        AND start_time &lt;= #{killTime}
        AND end_time &gt;= #{killTime}
        AND stock_count &gt; 0
    </update>

    <insert id="insertOrder">
        INSERT ignore INTO seckill_order(seckill_id, money, user_phone)
        VALUES (#{seckillId}, #{money}, #{userPhone})
    </insert>

SQL语句相对不是很复杂。减库存:执行update语句,令stock_count字段依次减一,并且当前要在一系列where条件的限制下;新增订单信息:保存订单数据,这里为接口防刷用联合主键seckillId, userPhone,如果同一个用户多次抢购同一件商品导致主键冲突会直接报错,为了避免系统不直接报错设计了ignore实现主键冲突就直接返回0表示该条SQL执行失败。

拓展

上面我使用了&lt;&gt;的语法其实代表的是>= <=这种符号,因为在Mybatis中编写的SQL语句如果直接使用>=<=这种判断条件可能会报错,我这里提供一种简单的解决方案就是用这种英文符号代替:

原符号 替换符号
< <
<= <=
> >
>= >=
& &
'
"

order表中findById方法

之前在SeckillOrder.java实体类中我们注入了Seckill属性,用于可以根据查询seckill_order表的同时查询到其对应的seckill表数据,对应的接口定义如下:

    /**
     * 根据秒杀商品ID查询订单明细数据并得到对应秒杀商品的数据,因为我们再SeckillOrder中已经定义了一个Seckill的属性
     *
     * @param seckillId
     * @return
     */
    SeckillOrder findById(long seckillId);

对应的SQL如下:

    <select id="findById" resultType="SeckillOrder">
        SELECT
          so.seckill_id,
          so.user_phone,
          so.money,
          so.create_time,
          so.state,
          s.seckill_id "seckill.seckill_id",
          s.title "seckill.title",
          s.cost_price "seckill.cost_price",
          s.create_time "seckill.create_time",
          s.start_time "seckill.start_time",
          s.end_time "seckill.end_time",
          s.stock_count "seckill.stock_count"
        FROM seckill_order so
        INNER JOIN seckill s ON so.seckill_id = s.seckill_id
        WHERE so.seckill_id = #{seckillId}
    </select>

这个SQL看似复杂些,但是就是仅仅的多表(两张表)查询语句:根据seckill_order表中的seckill_id字段查询seckill表中seckill_id字段值对应的数据(也就是说:对于多表查询,其实两张表之间必然存在一定的字段关联关系,不一定是外键关联,当然我们也不建议用外键关联两张表)。

其中findById的SQL中类似s.seckill_id "seckill.seckill_id"语句其实是s.seckill_id as "seckill.seckill_id",这里省略了as(别名);而INNER JOIN语句正是查询若两张表中中又相同字段的匹配值就根据两张表关联字段查询两张表的数据。这也可以使用<resultMap>中的<association>标签来实现,用于查询两张关联表的数据,如:

  <resultMap id="findById" type="SeckillOrder">
      <id column="seckill_id" property="seckillId"/>
      <result column="user_phone" property="userPhone"/>
      ...
      <association property="seckill" javaType="Seckill">
          <id column="seckill_id" property="seckillId"/>
          <result column="title" property="title"/>
          ...
      </association>
  </resultMap>

如以上也是一种映射另外一张表数据的方式(当然使用这种方式在写SQL的时候需要指定限制条件where s.seckill_id = so.seckill_id强调两张表中的seckill_id字段值相同)。

测试

在编写了Mybatis的映射接口和XML映射文件,我们可以编写一个测试类来测试一下接口和XML配置是否正确。由于我们使用IDEA开发工具,打开接口文件用快捷键Alt + Enter(我这里用的Mac系统)显示一个面板,选择Create Test快速创建本文件的测试类。

由于使用的SpringBoot框架,新创建的测试类位于/src/test/java/目录下,我们举例说明,比如创建SeckillMapper接口的测试文件:SeckillMapperTest.java

public class SeckillMapperTest {

    @Autowired
    private SeckillMapper seckillMapper;

    @Test
    public void findAll() {
    }

    @Test
    public void findById() {
    }

    @Test
    public void reduceStock() {
    }
}

以上就是使用IDEA快捷键创建的测试类,我们仅以findAll()方法举例说明一下如何使用SpringBoot的测试类。如下:

此处的源码请参看:Github

@RunWith(SpringJUnit4ClassRunner.class)
//@ContextConfiguration("classpath:application.yml")
@SpringBootTest
public class SeckillMapperTest {

    @Autowired
    private SeckillMapper seckillMapper;

    @Test
    public void findAll() {
        List<Seckill> all = seckillMapper.findAll();
        for (Seckill seckill : all) {
            System.out.println(seckill.getTitle());
        }
    }

    @Test
    public void findById() {
    }

    @Test
    public void reduceStock() {
    }
}

SpringBoot的测试类和传统Spring框架测试类的最大区别就是不再使用@ContextConfiguration()注解去加载配置文件,取而代之的是使用@SpringBootTest注解。因为SpringBoot已经严格规定了配置文件放在resources目录下,且一般是.properties.yml结尾。如果你再使用@ContextConfiguration()注解加载配置文件反而会报错。


image

image

image


交流

如果大家有兴趣,欢迎大家加入我的Java交流群:671017003 ,一起交流学习Java技术。博主目前一直在自学JAVA中,技术有限,如果可以,会尽力给大家提供一些帮助,或是一些学习方法,当然群里的大佬都会积极给新手答疑的。所以,别犹豫,快来加入我们吧!


联系

If you have some questions after you see this article, you can contact me or you can find some info by clicking these links.

  1. https://tumo.tycoding.cn

    回复