高并发请求测试

前言

  • 本篇是关于高并发请求的测试。
  • 高并发请求的情况下,会发生什么事情呢?本文将在测试中为大家解开这个谜题。

环境配置

  • 项目为SpringBoot项目,使用MyBatis作为持久层框架,依赖如下:

    1. 提供线程池的工具类需要依赖guava包,由Google提供的;
    2. 注意区分数据库连接池和线程池,这俩是不一样的东西。
<dependencies>
<!-- Web模块,可以省略 -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    <!-- MyBatis -->
    <dependency>
        <groupId>org.mybatis.spring.boot</groupId>
        <artifactId>mybatis-spring-boot-starter</artifactId>
        <version>2.1.4</version>
    </dependency>
    <!-- Druid数据库连接池 -->
    <dependency>
        <groupId>com.alibaba</groupId>
        <artifactId>druid-spring-boot-starter</artifactId>
        <version>1.1.22</version>
    </dependency>
    <!-- Guava多线程工具 -->
    <dependency>
        <groupId>com.google.guava</groupId>
        <artifactId>guava</artifactId>
        <version>29.0-jre</version>
    </dependency>
    <!-- 基本的MySQL连接驱动 -->
    <dependency>
        <groupId>mysql</groupId>
        <artifactId>mysql-connector-java</artifactId>
        <scope>runtime</scope>
    </dependency>
    <!-- Lombok提供Getter/Setter方法即类日志注解@Slf4j -->
    <dependency>
        <groupId>org.projectlombok</groupId>
        <artifactId>lombok</artifactId>
        <optional>true</optional>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-test</artifactId>
        <scope>test</scope>
    </dependency>
</dependencies>
  • SpringBoot配置文件application.yml如下:

    1. 配置了tomcat默认启动的端口号;
    2. 配置了数据库连接参数;
    3. 配置了MyBatis的参数。
server:
  port: 8888
spring:
  datasource:
    druid:
      # 数据库连接配置
      db-type: com.alibaba.druid.pool.DruidDataSource
      driver-class-name: com.mysql.cj.jdbc.Driver
      url: jdbc:mysql://localhost:3306/transaction?serverTimezone=GMT%2B8&useAffectedRows=true
      username: root
      password: root
      # 基本连接池数据
      initial-size: 5
      min-idle: 10
      max-active: 20
      max-wait: 5000
mybatis:
  mapper-locations: classpath:mapper/*xml
  type-aliases-package: cn.dylanphang.mysql.pojo
  # 此条让数据库sql语句在控制台中输出
  configuration:
    log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
  • 所用的数据库表为:
CREATE TABLE `user` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `username` varchar(30) NOT NULL,
  `password` varchar(30) NOT NULL,
  `age` int(11) NOT NULL,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=3 DEFAULT CHARSET=utf8;
  • 数据库表基本数据:
INSERT INTO `user`(username, password, age)
VALUES
('dylan', '123456', 18),
('dora', '123456', 16);
  • Mapper类及配置:
package cn.dylanphang.mysql.mapper;

import cn.dylanphang.mysql.pojo.User;
import org.apache.ibatis.annotations.Mapper;
import org.springframework.stereotype.Repository;

/**
 * @author dylan
 * @date 2020/12/18
 */
@Mapper
@Repository
public interface UserMapper {
    /**
     * 用于查询表的语句,测试事务。
     *
     * @param username username
     * @return user
     */
    User find(String username);

    /**
     * 用于修改表的语句,测试事务。
     *
     * @param username username
     * @param age      age
     */
    void update(String username, Integer age);
}
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper
        PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
        "http://mybatis.org/dtd/mybatis-3-mapper.dtd">

<mapper namespace="cn.dylanphang.mysql.mapper.UserMapper">
    <select id="find" parameterType="string" resultType="user">
        SELECT *
        FROM user
        WHERE username = #{username};
    </select>

    <update id="update">
        UPDATE user
        SET age = #{param2}
        WHERE username = #{param1}
    </update>

</mapper>
  • Service层中的休眠设置:
package cn.dylanphang.mysql.service.impl;

import cn.dylanphang.mysql.mapper.UserMapper;
import cn.dylanphang.mysql.pojo.User;
import cn.dylanphang.mysql.service.UserService;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import javax.annotation.Resource;

/**
 * @author dylan
 * @date 2020/12/18
 */
@Service("userService")
@Transactional(rollbackFor = Exception.class)
public class UserServiceImpl implements UserService {

    @Resource
    private UserMapper userMapper;

    @Override
    public User find(String username) throws InterruptedException {
        Thread.sleep(3000);
        return this.userMapper.find(username);
    }

    @Override
    public void update(String username, Integer age) throws InterruptedException {
        Thread.sleep(3000);
        this.userMapper.update(username, age);
    }
}
  • 线程池工具类:
package cn.dylanphang.mysql.util;

import com.google.common.util.concurrent.ThreadFactoryBuilder;

import java.util.concurrent.*;

/**
 * @author dylan
 */
public class ThreadUtils {
    public static void create(Runnable runnable) {

        ThreadFactory namedThreadFactory = new ThreadFactoryBuilder()
                .setNameFormat("demo-pool-%d").build();
        ExecutorService singleThreadPool = new ThreadPoolExecutor(2000, 4000,
                0L, TimeUnit.MILLISECONDS,
                new LinkedBlockingQueue<>(1024), namedThreadFactory, new ThreadPoolExecutor.AbortPolicy());

        singleThreadPool.execute(runnable);
    }
}
  • 实验开始前需要了解4个参数:

    1. initial-size:数据库连接池初始化时创建的连接数,该值不能大于max-active,但可以大于min-idle
    2. min-idle:数据库连接池中所能存在的最小空闲连接数量,该值不能待遇max-active
    3. max-active:数据库连接池中所能存在的最大连接数量,该值需要大于initial-sizemin-idle
    4. max-wait:连接的最大等待时长,单位是毫秒。

测试一

  • 测试max-activemax-wait在查询的情况下,其作用和影响。

  • 此时更改数据库连接池的配置为:

initial-size: 1
min-idle: 2
max-active: 2
max-wait: 1000
  • 测试类将创建3个线程同时请求:
package cn.dylanphang.mysql;

import cn.dylanphang.mysql.pojo.User;
import cn.dylanphang.mysql.service.UserService;
import cn.dylanphang.mysql.util.ThreadUtils;
import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;

import javax.annotation.Resource;
import java.util.concurrent.CountDownLatch;

@SpringBootTest
@Slf4j
class MysqlApplicationTests {

    @Resource
    private UserService userService;

    @Test
    void testA() throws InterruptedException {
        int maxThread = 3;
        final CountDownLatch cdl = new CountDownLatch(maxThread);
        for (int i = 0; i < maxThread; i++) {
            ThreadUtils.create(() -> {
                try {
                    cdl.await();
                    final User user = this.userService.find("dylan");
                    log.info("User is: {}", user.toString());
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            });
            cdl.countDown();
        }
        Thread.sleep(30000);
    }

}
  • 测试结果:
  • 请求过程:

    1. 数据库连接池中的最大连接数为2,也就是在同一时间有且仅会允许2个线程获取连接;
    2. 数据库连接池中设置的最大等待时间是1秒,当3个线程的请求同时发起,有且仅有2个线程获取到了数据库连接对象,剩余1个线程进入了等待;
    3. 测试类中线程处理时间为最少3秒,而连接池中的最大等待时间为1秒,当该线程达到指定的等待时间但仍旧未获取到数据库连接对象时,将抛出GetConnectionTimeoutException
    4. 成功获取数据库连接对象的2个线程在3秒后将成功输出查询到的数据。
  • 从实验中可以知道max-activemax-wait的作用是什么:

    • max-active将决定数据库连接池中所能承受的线程上限,而max-wait将决定当超过max-active个线程发出请求时,进入等待流程的线程的最长等待时长是多少;
    • 设想场景,但200个并发发起了查询请求,而此时数据库连接池中配置为max-active: 100max-wait: 1000,而每个查询请求需要处理的时长为2000ms,此时必定只有100查询请求可以完成,但剩余的100个查询请求必定失败;
    • 过大的max-active会增加数据库的负担,而过大的max-wait会降低用户的体验。

测试二

  • 测试二将测试2条并发的情况下,事务是否能够顺利完成。

  • SpringBoot中事务管理可以通过直接添加@Transactional(rollbackFor = Exception.class)完成。

  • UserServiceImpl中添加一个方法,查询用户年龄后,根据年龄进行减一操作后等到新的年龄,再将新的年龄更新为该用户当前的年龄,查询操作后将休眠3秒。

package cn.dylanphang.mysql.service.impl;

import cn.dylanphang.mysql.mapper.UserMapper;
import cn.dylanphang.mysql.pojo.User;
import cn.dylanphang.mysql.service.UserService;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import javax.annotation.Resource;

/**
 * @author dylan
 * @date 2020/12/18
 */
@Service("userService")
@Transactional(rollbackFor = Exception.class)
public class UserServiceImpl implements UserService {

    @Resource
    private UserMapper userMapper;

    @Override
    public User find(String username) throws InterruptedException {
        Thread.sleep(3000);
        return this.userMapper.find(username);
    }

    @Override
    public void update(String username, Integer age) throws InterruptedException {
        Thread.sleep(3000);
        this.userMapper.update(username, age);
    }

    @Override
    public void findThenUpdate(String username) throws InterruptedException {
        final Integer newAge = this.find(username).getAge() - 1;
        Thread.sleep(3000);
        this.update(username, newAge);
    }
}
  • 测试类:

    1. 测试方法只使用了2个线程对数据库发出查询并修改数据的请求,其计算过程是在findThenUpdate中完成的;
    2. 测试前后将输出用户年龄前后的值。
package cn.dylanphang.mysql;

import cn.dylanphang.mysql.pojo.User;
import cn.dylanphang.mysql.service.UserService;
import cn.dylanphang.mysql.util.ThreadUtils;
import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;

import javax.annotation.Resource;
import java.util.concurrent.CountDownLatch;

@SpringBootTest
@Slf4j
class MysqlApplicationTests {

    @Resource
    private UserService userService;

    @BeforeEach
    void init() throws InterruptedException {
        final Integer age = this.userService.find("dylan").getAge();
        log.info("Now the age is: {}", age);
    }

    @Test
    void testB() throws InterruptedException {
        int maxThread = 2;
        final CountDownLatch cdl = new CountDownLatch(maxThread);
        for (int i = 0; i < maxThread; i++) {
            ThreadUtils.create(() -> {
                try {
                    cdl.await();
                    this.userService.findThenUpdate("dylan");
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            });
            cdl.countDown();
        }
        Thread.sleep(30000);
    }

    @AfterEach
    void destroy() throws InterruptedException {
        final Integer age = this.userService.find("dylan").getAge();
        log.info("After the age is: {}", age);
    }

}
  • @BeforeEach输出:
  • @Test输出:
  • @AfterEach输出:
  • 请求过程:

    1. 测试类中的2个请求同时发出后,都会先进行数据库查询的操作,无疑2个线程都会获得同一个年龄值;
    2. 线程完成Service中的线程倒计时后,此时2个线程都会同时对数据库发起update的操作;
    3. 由于更新操作完全一致,导致最终年龄值不是原来年龄值减二后的结果,而仅仅是减一后的结果。
  • 此时可以类比多线程请求购买商品时,商品扣库的情况,即使在有事务控制的情况下,多线程请求同一个接口,对同一个表的同一条数据进行同样的增删改操作,可能会造成数据的不准确,解决方法:
    • 将查询和更新语句写在同一条语句中。

测试三

  • 为了解决测试二中出现的并发问题,测试三将把查询和更新语句写在同一条sql语句中,观察输出结果:

    • UPDATE user SET age = age - 1 WHERE username = #{username};
  • UserMapper.java中添加一个新的方法,同时在UserMapper.xml中添加相应的Sql语句:

package cn.dylanphang.mysql.mapper;

import cn.dylanphang.mysql.pojo.User;
import org.apache.ibatis.annotations.Mapper;
import org.springframework.stereotype.Repository;

/**
 * @author dylan
 * @date 2020/12/18
 */
@Mapper
@Repository
public interface UserMapper {

    /**
     * 使用一条语句进行年龄更新的操作。
     *
     * @param username username
     */
    void findThenUpdateInOneSql(String username);
}
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper
        PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
        "http://mybatis.org/dtd/mybatis-3-mapper.dtd">

<mapper namespace="cn.dylanphang.mysql.mapper.UserMapper">
    <update id="findThenUpdateInOneSql">
        UPDATE user
        SET age = age - 1
        WHERE username = #{username};
    </update>
</mapper>
  • 相应地,在service层中对线程进行3秒延时处理的模仿:
package cn.dylanphang.mysql.service.impl;

import cn.dylanphang.mysql.mapper.UserMapper;
import cn.dylanphang.mysql.pojo.User;
import cn.dylanphang.mysql.service.UserService;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import javax.annotation.Resource;

/**
 * @author dylan
 * @date 2020/12/18
 */
@Service("userService")
@Transactional(rollbackFor = Exception.class)
public class UserServiceImpl implements UserService {

    @Resource
    private UserMapper userMapper;

    @Override
    public void findThenUpdateInOneSql(String username) throws InterruptedException {
        Thread.sleep(3000);
        this.userMapper.findThenUpdateInOneSql(username);
    }
}
  • 其中UPDATE user SET age = age - 1 WHERE username = #{username}语句为将年龄减一,再次运行测试:
  • @BeforeEach输出:
  • @Test输出:
  • @AfterEach输出:
  • 此时没有出现类似于测试二中的错误结果。

测试四

  • 经过测试三优化后,我们在同一条sql中完成了更新年龄的操作。

  • 如果此时需要同时处理20000条并发呢?每个线程发送的请求都会获取一个数据库连接对象,而该对象明显是从数据库连接池中取出来的,如果连接池的初始化数量或最小空闲连接数量不足20000,在突然需要处理高并发时,会出现异常。

  • 此时修改连接池的配置为:

initial-size: 10
min-idle: 20
max-active: 40
max-wait: 5000
  • 需要注意的是,关于initial-size初始化连接池数量,该数量不是越大越好的,创建连接对象是十分消耗资源的,该数值配置的越大,启动服务器则会越慢。
  • 所以说,当预计服务器的并发量为20000条时,不可能将initial-size设置为20000,这是不合理的。
  • 同样的,max-active参数也不能设置过大,服务器的资源总是有限的,如果资源大部分用作维护20000个数据库连接对象,无疑也是不合理的,可能会造成系统资源的浪费。
  • 同样使用测试三中的UserMapper.javaUserMapper.xml
package cn.dylanphang.mysql.mapper;

import cn.dylanphang.mysql.pojo.User;
import org.apache.ibatis.annotations.Mapper;
import org.springframework.stereotype.Repository;

/**
 * @author dylan
 * @date 2020/12/18
 */
@Mapper
@Repository
public interface UserMapper {

    /**
     * 使用一条语句进行年龄更新的操作。
     *
     * @param username username
     */
    void findThenUpdateInOneSql(String username);
}
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper
        PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
        "http://mybatis.org/dtd/mybatis-3-mapper.dtd">

<mapper namespace="cn.dylanphang.mysql.mapper.UserMapper">
    <update id="findThenUpdateInOneSql">
        UPDATE user
        SET age = age - 1
        WHERE username = #{username};
    </update>
</mapper>
  • 同时service层中一样延时3秒:
package cn.dylanphang.mysql.service.impl;

import cn.dylanphang.mysql.mapper.UserMapper;
import cn.dylanphang.mysql.pojo.User;
import cn.dylanphang.mysql.service.UserService;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import javax.annotation.Resource;

/**
 * @author dylan
 * @date 2020/12/18
 */
@Service("userService")
@Transactional(rollbackFor = Exception.class)
public class UserServiceImpl implements UserService {

    @Resource
    private UserMapper userMapper;

    @Override
    public void findThenUpdateInOneSql(String username) throws InterruptedException {
        Thread.sleep(3000);
        this.userMapper.findThenUpdateInOneSql(username);
    }
}
  • 运行测试,本次过程中出现了GetConnectionTimeoutExceptionCannotCreateTransactionException
  • 同时数据库中相关用户的年龄原本为20018,经过20000高并发测试后,结果却为19842
  • 并发扣减年龄失败的原因,无疑是因为异常导致的。
  • 关于异常的说明:
    • GetConnectionTimeoutException:由于在并发的情况下,不能获取到数据库连接的线程,将会进入等待状态,当等待时间超过max-wait中的设定5000ms后,仍未获取到连接,则会抛出此异常;
    • CannotCreateTransactionException:本异常的出现,是GetConnectionTimeoutException的一层外壳,其中调用获取数据库连接的方法失败后,捕获到了GetConnectionTimeoutException异常,该方法则抛出此异常:
Exception in thread "demo-pool-0" org.springframework.transaction.CannotCreateTransactionException:
Could not open JDBC Connection for transaction; nested exception is com.alibaba.druid.pool.GetConnectionTimeoutException: wait millis 9308, active 40, maxActive 40, creating 0
  • 高并发情况下发生异常,基本上都是由于无法获取到数据库连接而导致的。

总结

  1. 从测试一中,可以看到决定数据库连接池中连接数量的关键参数是max-active
  2. 从测试二中,可以知道每个线程中的事务,都是独立的:
    • 同一个线程的情况,前后两条代码同时使用service中的方法去更新数据库中的同一条数据,后一条代码必定是在前一条代码执行完全结束之后才会执行,而实际开发中,需要将两条代码置于service层中,保证其在一个事务里而不是两个;
    • 两个线程的情况下,其中的事务是各自单独执行的,每个线程都回去请求数据库连接池中的连接,每个连接都会开启一个独立的事务,一个事务的异常不会影响另一个事务的执行;
    • 多线程下如果需要进行更新库操作,最好的方式是使用同一条sql语句完成操作,先查后计算再更新,有可能导致两个线程中的查询结果一致,直接造成计算结果与更新结果一致的情况。
  3. 从测试三中,可以得出将操作置于同一条sql语句,是可以保证事务的一致性;
  4. 从测试四中,可以明白在保证事务一致性的前提下,并发量巨大时,系统的短板则会出现在数据库连接池的配置上,同时此配置也无法设置得太大,会影响系统性能。
(0)

相关推荐

  • Spring实现声明式事务

    Spring实现声明式事务

  • 5 ResultMap

    ResultMap 要解决的问题,属性名和数据库表格字段名不一致问题 在项目中,新建一个模块mybatis-02,内容和mybatis-01一样. 1.查看数据库user表的字段名 2.修改Java中 ...

  • 运用RabbitMQ编写秒杀逻辑

    简介 阅读本篇,需要具备RabbitMQ的知识,以及其在SpringBoot中的应用. 本篇将使用RabbitMQ制作一个秒杀系统的雏形,其主要充当的作用是流量削峰. 系统架构图 秒杀逻辑分为两部分: ...

  • 掌握Mybatis动态映射,我可是下了功夫的

    动态 SQL 是 MyBatis 的强大特性之一.如果你使用过 JDBC 或其它类似的框架,你应该能理解根据不同条件拼接 SQL 语句有多痛苦,例如拼接时要确保不能忘记添加必要的空格,还要注意去掉列表 ...

  • MyBatis-Plus学习笔记(八) 代码自动生成器

    AutoGenerator 是 MyBatis-Plus 的代码生成器 AutoGenerator 可以快速生成 Entity.Mapper.Mapper XML.Service.Controller ...

  • 给你的 MyBatis-Plus 装上批量插入的翅膀

    大家有用过MyBatis-Plus(简称MP)的都知道它是一个MyBatis的增强工具,旨在MyBatis的基础上只做增强不做改变,为简化开发.提高效率而生. 特点 无侵入:只做增强不做改变,引入它不 ...

  • 高并发压力测试,你真的会做吗?

    一.Introduction 对于小微企业网站在自主推出某些活动时,可能导致网站产生高并发访问的情况.针对这种情况采用临时租用云服务器是有性价比的应对措施,比如,使用弹性云.这种租用服务有的按照访问流 ...

  • Java高并发21-AQS在共享,独占场景下的源码介绍

    一.AQS--锁的底层支持 1.AQS是什么 AQS是AbstractQueuedSychronizer的简称,即抽象同步队列的简称,这是实现同步器的重要组件,是一个抽象类,虽然在实际工作中很烧用到它 ...

  • 高并发场景下锁的使用技巧

    来源:张飞洪 https://www.cnblogs.com/jackyfei/p/12142840.html 如何确保一个方法,或者一块代码在高并发情况下,同一时间只能被一个线程执行,单体应用可以使 ...

  • 专业的在线考试答题系统,快考题,高并发人数使用流畅

    在线考试的普及,让越来越多的学校,企业,教育机构纷纷加入.在线考试系统的开发也打破了以往传统的考试模式,不受时间限制,不受地域限制.那么一个完善的在线考试系统除了以上两大优势,还有哪些"过人 ...

  • 高并发,我把握不住啊

    慎入,作者高并发搞得少(没搞过),这里面水太深,什么高并发,大流量的东西都是虚拟的,作者还太年轻,没有那个经历,把握不住.系统只有几QPS,开心快乐就行,不PK,文明PK. 我关注的大佬更新了,在干货 ...

  • 高并发场景下,到底先更新缓存还是先更新数据库?

    在大型系统中,为了减少数据库压力通常会引入缓存机制,一旦引入缓存又很容易造成缓存和数据库数据不一致,导致用户看到的是旧数据. 为了减少数据不一致的情况,更新缓存和数据库的机制显得尤为重要,接下来带领大 ...

  • Java高并发24-使用自定义锁生成一个消费模型

    一.使用自定义锁实现生成--消费模型 下面我们使用上节自定义的锁实现一个简单的生产--消费模型,代码如下: package com.ruigege.LockSourceAnalysis6; impor ...

  • jmeter压测学习47-发soap请求测试webservice接口

    前言 jmeter3 的版本可以新建一个SOAP/XML-RPC Request 的请求,直接测试webservice的接口. jmeter5.1.1 版本已经去掉了自带的SOAP/XML-RPC R ...

  • Java高并发16-LongAdder类源码解析(下)

    一.复习 上次连载简单的介绍了其他函数的作用以及功能 二.完整的LongAdder类源码 package com.ruigege.AtomicOperationClass4;import java.u ...