Druid ResultSet 内存泄露分析

现象

在使用 ShardingSphere-JDBC 配置 Druid 连接池,通过 Jmeter 开启预编译缓存进行性能压测时,遇到压测性波动较大问题。

定位流程

问题分析

压测过程中通过 jstat 观测 GC 情况,存在频繁 GC。
通过 async profiler 抓取 cpu 火焰图观察,发现大量 CPU 消耗在 GC 线程上,真正执行业务代码的 CPU 占比较少。

压测过程中通过 jmap 执行堆 Dump,通过 Eclipse MAT 分析堆 Dump。
查看支配树,压测使用 50 个线程时,存在 50 个 DruidPooledPreparedStatement 实例,但是 DruidPooledPreparedStatement 深堆大小共 1G。
每个 DruidPooledPreparedStatement#resultSetTrace 里持有大量 result set 实例。
至此,确定 DruidPooledPreparedStatement#resultSetTrace 内存泄漏。

问题复现

使用 druid jdbc demo 复现问题

使用 Druid 原生最小 Demo 复现问题。
测试: 同一个 PreparedStatement 在执行 executeQuery 查询后,多次通过 getResultSet 获取结果集,最后关闭。
现象:只要连续拿结果集,再 close 就会泄露,因为 Druid 代码里每次就只判断最后一个是不是close。
多次执行后 DruidPooledPreparedStatement#resultSetTrace 缓存没有清理,发生内存泄漏。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
import com.alibaba.druid.pool.DruidDataSource;
import com.alibaba.druid.pool.DruidPooledPreparedStatement;
import org.junit.Test;

import java.lang.reflect.Field;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.List;

/**
* 前置: 通过 mysql 执行如下 sql,然后运行测试.
* create database test;
* create table test_druid(id int, name varchar(25));
* insert into test.test_druid values(1,1),(2,2),(3,3),(4,4),(5,5);
*/
public class DruidMySQLDemo {

private static final String URL = "jdbc:mysql://localhost:3306/test";
private static final String USER = "root";
private static final String PASSWORD = "root";

/**
* 内存泄露异常Case: 测试结果集对象 resultSetTrace 的泄漏.
* 同一个 PreparedStatement 在执行 executeQuery 查询后,多次通过 getResultSet 获取结果集,最后关闭.多次执行后 DruidPooledPreparedStatement#resultSetTrace 缓存没有清理
*
* @throws Exception
*/
@Test
public void testResultSetTraceLeak() throws Exception {
Connection connection = getConnection();
String sql = "SELECT * FROM test_druid WHERE id = ?";
PreparedStatement psmt = connection.prepareStatement(sql);
for (int i = 0; i < 100; i++) {
psmt.setInt(1, 1);
ResultSet resultSet = psmt.executeQuery();
ResultSet rs1 = psmt.getResultSet();
ResultSet rs2 = psmt.getResultSet();
ResultSet rs3 = psmt.getResultSet();
ResultSet rs4 = psmt.getResultSet();
ResultSet rs5 = psmt.getResultSet();
rs1.close();
rs2.close();
rs3.close();
rs4.close();
rs5.close();
resultSet.close();
}
// 501 缓存没有清理
printResultSetTraceFieldSize(psmt);
psmt.close();
// 0
printResultSetTraceFieldSize(psmt);
}

/**
* 正常测试case.
*
* @throws Exception
*/
@Test
public void testOK() throws Exception {
Connection connection = getConnection();
String sql = "SELECT * FROM test_druid WHERE id = ?";
PreparedStatement psmt = connection.prepareStatement(sql);
for (int i = 0; i < 100; i++) {
psmt.setInt(1, 1);
ResultSet resultSet = psmt.executeQuery();
resultSet.close();
}
// 1
printResultSetTraceFieldSize(psmt);
psmt.close();
// 0
printResultSetTraceFieldSize(psmt);
}

private static Connection getConnection() throws ClassNotFoundException, SQLException {
Class.forName("com.alibaba.druid.pool.DruidDataSource");
DruidDataSource dataSource = new DruidDataSource();
dataSource.setUrl(URL);
dataSource.setUsername(USER);
dataSource.setPassword(PASSWORD);
return dataSource.getConnection();
}

private static void printResultSetTraceFieldSize(final PreparedStatement psmt) throws IllegalAccessException {
Class<?> currentClass = DruidPooledPreparedStatement.class;
Field field = null;
while (currentClass != null) {
try {
field = currentClass.getDeclaredField("resultSetTrace");
if (field != null) {
break;
}
} catch (NoSuchFieldException e) {
currentClass = currentClass.getSuperclass();
}
}
field.setAccessible(true);
List<String> resultSetTrace = (List<String>) field.get(psmt);
System.out.println("resultSetTrace size: " + resultSetTrace.size());
}
}

Druid 相关代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// com.alibaba.druid.pool.DruidPooledStatement#addResultSetTrace
protected void addResultSetTrace(ResultSet resultSet) {
if (resultSetTrace == null) {
resultSetTrace = new ArrayList<ResultSet>(1);
} else if (resultSetTrace.size() > 0) {
int lastIndex = resultSetTrace.size() - 1;
// 1.每次只会判断缓存中的最后一个结果集
ResultSet lastResultSet = resultSetTrace.get(lastIndex);
try {
// 1.1.只有当最后一个结果集是 closed, 才会更新 resultSetTrace 缓存
if (lastResultSet.isClosed()) {
resultSetTrace.set(lastIndex, resultSet);
return;
}
} catch (SQLException ex) {
// skip
}
}
// 1.2.否则,就会将结果集加入 resultSetTrace 缓存
resultSetTrace.add(resultSet);
}

代码可见,只要有一个 preparedStatement 下的连续获取多次 result set 并且没有关闭,就会产生 resultSetTrace 泄漏。

给 Druid 提了 issue,反馈该问题:
https://github.com/alibaba/druid/issues/6457

规避问题

PreparedStatement 通过 executeQuery 获取结果集后,不要再连续调用 Driud 的 getResultSet 方法,否则就会发生内存泄漏。所以在使用上避免这种场景即可。