高並發請求測試


前言

  • 本篇是關於高並發請求的測試。
  • 高並發請求的情況下,會發生什么事情呢?本文將在測試中為大家解開這個謎題。

環境配置

  • 項目為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. 從測試四中,可以明白在保證事務一致性的前提下,並發量巨大時,系統的短板則會出現在數據庫連接池的配置上,同時此配置也無法設置得太大,會影響系統性能。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM