前言
- 本篇是關於高並發請求的測試。
- 高並發請求的情況下,會發生什么事情呢?本文將在測試中為大家解開這個謎題。
環境配置
- 項目為
SpringBoot
項目,使用MyBatis
作為持久層框架,依賴如下:- 提供線程池的工具類需要依賴
guava
包,由Google
提供的; - 注意區分數據庫連接池和線程池,這倆是不一樣的東西。
- 提供線程池的工具類需要依賴
<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
如下:- 配置了
tomcat
默認啟動的端口號; - 配置了數據庫連接參數;
- 配置了
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
個參數:initial-size
:數據庫連接池初始化時創建的連接數,該值不能大於max-active
,但可以大於min-idle
;min-idle
:數據庫連接池中所能存在的最小空閑連接數量,該值不能待遇max-active
;max-active
:數據庫連接池中所能存在的最大連接數量,該值需要大於initial-size
和min-idle
;max-wait
:連接的最大等待時長,單位是毫秒。
測試一
-
測試
max-active
和max-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);
}
}
- 測試結果:
-
請求過程:
- 數據庫連接池中的最大連接數為
2
,也就是在同一時間有且僅會允許2
個線程獲取連接; - 數據庫連接池中設置的最大等待時間是
1
秒,當3
個線程的請求同時發起,有且僅有2
個線程獲取到了數據庫連接對象,剩余1
個線程進入了等待; - 測試類中線程處理時間為最少
3
秒,而連接池中的最大等待時間為1
秒,當該線程達到指定的等待時間但仍舊未獲取到數據庫連接對象時,將拋出GetConnectionTimeoutException
; - 成功獲取數據庫連接對象的
2
個線程在3
秒后將成功輸出查詢到的數據。
- 數據庫連接池中的最大連接數為
-
從實驗中可以知道
max-active
和max-wait
的作用是什么:max-active
將決定數據庫連接池中所能承受的線程上限,而max-wait
將決定當超過max-active
個線程發出請求時,進入等待流程的線程的最長等待時長是多少;- 設想場景,但
200
個並發發起了查詢請求,而此時數據庫連接池中配置為max-active: 100
、max-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);
}
}
- 測試類:
- 測試方法只使用了
2
個線程對數據庫發出查詢並修改數據的請求,其計算過程是在findThenUpdate
中完成的; - 測試前后將輸出用戶年齡前后的值。
- 測試方法只使用了
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
輸出:
- 請求過程:
- 測試類中的
2
個請求同時發出后,都會先進行數據庫查詢的操作,無疑2
個線程都會獲得同一個年齡值; - 線程完成
Service
中的線程倒計時后,此時2
個線程都會同時對數據庫發起update
的操作; - 由於更新操作完全一致,導致最終年齡值不是原來年齡值減二后的結果,而僅僅是減一后的結果。
- 測試類中的
- 此時可以類比多線程請求購買商品時,商品扣庫的情況,即使在有事務控制的情況下,多線程請求同一個接口,對同一個表的同一條數據進行同樣的增刪改操作,可能會造成數據的不准確,解決方法:
- 將查詢和更新語句寫在同一條語句中。
測試三
-
為了解決測試二中出現的並發問題,測試三將把查詢和更新語句寫在同一條
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.java
和UserMapper.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);
}
}
- 運行測試,本次過程中出現了
GetConnectionTimeoutException
和CannotCreateTransactionException
:
- 同時數據庫中相關用戶的年齡原本為
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
- 高並發情況下發生異常,基本上都是由於無法獲取到數據庫連接而導致的。
總結
- 從測試一中,可以看到決定數據庫連接池中連接數量的關鍵參數是
max-active
; - 從測試二中,可以知道每個線程中的事務,都是獨立的:
- 同一個線程的情況,前后兩條代碼同時使用
service
中的方法去更新數據庫中的同一條數據,后一條代碼必定是在前一條代碼執行完全結束之后才會執行,而實際開發中,需要將兩條代碼置於service
層中,保證其在一個事務里而不是兩個; - 兩個線程的情況下,其中的事務是各自單獨執行的,每個線程都回去請求數據庫連接池中的連接,每個連接都會開啟一個獨立的事務,一個事務的異常不會影響另一個事務的執行;
- 多線程下如果需要進行更新庫操作,最好的方式是使用同一條
sql
語句完成操作,先查后計算再更新,有可能導致兩個線程中的查詢結果一致,直接造成計算結果與更新結果一致的情況。
- 同一個線程的情況,前后兩條代碼同時使用
- 從測試三中,可以得出將操作置於同一條
sql
語句,是可以保證事務的一致性; - 從測試四中,可以明白在保證事務一致性的前提下,並發量巨大時,系統的短板則會出現在數據庫連接池的配置上,同時此配置也無法設置得太大,會影響系統性能。