4 分钟
一次 SpringBoot2 性能问题的定位和解决
现象
对一个开源项目 https://github.com/eclipse/openvsx 进行一定的改造后,进行了私有化部署。该项目是一个典型的 Java 项目,技术栈如下:
- Java 11
- Spring Boot 2
- Spring Security
- Spring Session
- JPA
- Spring Data Elasticsearch
运行几个月后,出现如下问题:
后端有多台实例,流量大的实例所有接口均非常缓慢,均大于 5 秒,流量小的实例,接口延迟正常。
另外,数据库为多个项目公用,负载本身就很高。
定位过程
尝试复现
- 使用 IDE 启动 Debug,并连接到生产环境的数据库。
curl
或 Postman 请求Debug,观察延迟。结果:接口延迟正常。使用压测软件
ab
,进行压测。# 安装 sudo apt update sudo apt install -y apache2-utils # 压测 QPS 比较高接口 ab -n 10000 -c 100 'http://localhost:xxx/xxx1' # 纯数据库查询 接口 ab -n 10000 -c 100 'http://localhost:xxx/xxx2' # 包含 ES 查询 接口 # 同时在执行压测的时候,另起一个 terminal,curl 其他接口,观察延迟 curl http://localhost:xxx/xxx2
以上压测结果显示,当压测接口并发度比较高的时候,延迟将上升,且其他包含数据库请求的接口都变得缓慢。
基本可以确认,是数据库的问题。推测是数据库连接池耗尽。
问题确认
手动打日志
在压测 QPS 高的接口时,在其他接口添加如下逻辑,获取拿到第一个数据库连接的耗时日志。
long start = 0, end = 0;
var hds = (HikariDataSource) this.datasource;
try {
start = System.currentTimeMillis();
var c = hds.getConnection();
end = System.currentTimeMillis();
logger.info("test get connection spend time: " + (end-start));
} catch (SQLException e) {
e.printStackTrace();
}
经测试,发现在并发 100 的场景下,获取一个数据库连接耗时月 4 秒。
打开 Hikari 连接池日志
# src/main/resources/application.yml
logging.level.com.zaxxer.hikari.pool.HikariPool: trace
同样执行压测,发现,waiting 状态的申请数据库连接的数目高达 70。
经过以上定位。可以确定,是数据库连接池耗尽导致的性能问题。
问题分析
由于该项目使用的是 JPA,即 Hibernate,且存在大量的级联操作。同时 QPS 高得那个请求,每个请求,均需查询大量的数据。另外随着时间推移,QPS 不断上升,数据库连接池时刻处于耗尽状态,导致,所有请求均需排队等待数据库连接,导致整体接口响应缓慢。
解决方案
以下三种方案,能支持的 QPS 依次增加。
方案 1:扩大数据库连接池
该方案,最简单,但是上限就是数据库的最大连接数。如果数据库负载已经很高了,该方案就无法使用了。
# src/main/resources/application.yml
spring:
datasource:
type: com.zaxxer.hikari.HikariDataSource
hikari:
maximum-pool-size: 100
方案 2:数据库读写分离
该方案,需数据库实现了读写分离,且 QPS 上限线比单纯数据库连接池方案高很多,且可以通过添加从库的方案进行水平扩容。另外,对业务代码没有侵入较小。是一个比较好方案。
需要特别注意的是,可能存在读写库同步延迟,可能导致不一致。
实现参考:
思路说明(Spring Boot 2):
- 原本,单数据源,只有一个 DataSource 对象。支持多数据源后,将需要初始化三个 DataSource
- masterDataSource(可以不放到 Spring 容器中)
- slaveDataSource (可以不放到 Spring 容器中)
- dynamicDataSource (注意该数据源不能有名字,必须是
@Bean
的方式声明)
- 切换 DataSource,有两种方式
- 方式 1:根据
@Transactional
是否是 ReadOnly 自动切换(没有走通) - 方式 2:通过
ThreadLocal
实现一个切换Context
,来记录切换情况,下文将以本方式为例,注意注意 ThreadLocal 读过一次后就立即设置为默认值,防止在一个线程中的请求,前面的影响后面的。
- 方式 1:根据
- 其他依赖 DataSource 的 Spring 组件,防止出现无法写入问题,需手动配置为
masterDataSource
数据源。比如 Spring Session,可以通过@SpringSessionDataSource
方式来配置
配置文件规划
# src/main/resources/application.yml
spring:
datasource:
master:
url: xxx
slave:
url: xxx
Java Config
@Configuration
public class MyConfig {
@Autowired
@Qualifier(value = "dataSourceMasterProperties")
Properties masterProperties;
@Bean(name = "dataSourceMasterProperties")
@ConfigurationProperties(prefix = "spring.datasource.master")
public Properties masterProperties() {
return new Properties();
}
@Autowired
@Qualifier(value = "dataSourceSlaveProperties")
Properties slaveProperties;
@Bean(name = "dataSourceSlaveProperties")
@ConfigurationProperties(prefix = "spring.datasource.slave")
public Properties slaveProperties() {
return new Properties();
}
DataSource masterDataSource;
@Bean("masterDataSource")
@Qualifier("masterDataSource")
@SpringSessionDataSource
public DataSource masterDataSource() {
if (masterDataSource != null) {
return masterDataSource;
}
HikariDataSource dataSource = new HikariDataSource();
dataSource.setDataSourceProperties(masterProperties);
// 其他配置
masterDataSource = dataSource;
return dataSource;
}
public DataSource slaveDataSource() {
HikariDataSource dataSource = new HikariDataSource();
dataSource.setDataSourceProperties(slaveProperties);
// 其他配置
return dataSource;
}
@Bean
@Primary
public DataSource dynamicDataSource() throws IOException {
Map<Object, Object> targetDataSources = new HashMap<>();
targetDataSources.put(DynamicRoutingDataSourceContext.MASTER, masterDataSource());
targetDataSources.put(DynamicRoutingDataSourceContext.SLAVE, slaveDataSource());
AbstractRoutingDataSource dynamicDataSource = new AbstractRoutingDataSource() {
@Override
protected Object determineCurrentLookupKey() {
try {
return DynamicRoutingDataSourceContext.getDataSourceKey();
} finally {
// 一定要 reset
DynamicRoutingDataSourceContext.reset();
}
}
};
dynamicDataSource.setDefaultTargetDataSource(targetDataSources.get(DynamicRoutingDataSourceContext.MASTER));
dynamicDataSource.setTargetDataSources(targetDataSources);
return dynamicDataSource;
}
}
切换数据源 Context
public class DynamicRoutingDataSourceContext {
public static final String MASTER = "master";
public static final String SLAVE = "slave";
private static final ThreadLocal<String> threadLocalDataSourceKey = new ThreadLocal<>();
private static void setRoutingDataSourceKey(String dataSource) {
threadLocalDataSourceKey.set(dataSource);
}
public static void setMaster() {
setRoutingDataSourceKey(MASTER);
}
public static void setSlave() {
setRoutingDataSourceKey(SLAVE);
}
public static String getDataSourceKey() {
var k = threadLocalDataSourceKey.get();
if (k == null) {
return MASTER;
}
return k;
}
public static void reset() {
threadLocalDataSourceKey.remove();
}
}
只读的 Service 方法设置为读库
public XxxDTO getXxx() {
DynamicRoutingDataSourceContext.setSlave();
// ...
}
方案 3:添加缓存
- 通过 Nginx 添加缓存,参考:博客。
- 业务侧手动添加缓存。
经验教训
- 对性能有一定要求的场景,尽量不要使用 JPA 这种不可控的 ORM。