本文数据库使用PostgreSQL,如果是MySQ的话请移步 MySQL JDBC的官方文档
PostgreSQL中开启流式读取
为什么开启了流式读取后依然OOM?
大部分同学都知道在JDBC查询的时候,有的数据库驱动会把查询的数据全部返回给程序。这样就很容易造成OOM。下列内容来自PostgreSQL JDBC文档
By default the driver collects all the results for the query at once. This can be inconvenient for large data sets so the JDBC driver provides a means of basing a ResultSet on a database cursor and only fetching a small number of rows.
大部分同学也都知道,为了不一次性把数据加载到程序中,可以使用流式读取的方式,就是两行代码,关键是设置fetchSize
connection.setAutoCommit(false);
statement.setFetchSize(50);
但是流式读取的后面应该怎么返回/处理数据,很多人没说。我比较笨且思考较少,于是我在Service中写出了下列代码
public class JdbcStreamService {
public List<User> getUserList() {
List<User> result = new ArrayList();
String url = "jdbc:postgresql://localhost:5432/postgres?user=postgres&password=12345&?currentSchema=public";
Connection conn = DriverManager.getConnection(url);
// 关闭自动提交
conn.setAutoCommit(false);
Statement st = conn.createStatement();
// 开启流式读取
st.setFetchSize(50);
ResultSet rs = st.executeQuery("SELECT * FROM public.user");
try {
while (rs.next()) {
int userId = rs.getInt(1);
String password = rs.getString(2);
String roles = rs.getString(3);
String introduction = rs.getString(4);
User user = new User();
user.setUserId(userId);
user.setPassword(password);
user.setRoles(roles);
user.setIntroduction(introduction);
result.add(user);
}
} finally {
rs.close();
st.close();
conn.close();
}
return result;
}
}
很开心,我已经开启了流式读取,不用担心OOM了,我把所有结果放到List里,然后返回给Controller。但是很不幸,当启动程序之后,访问该接口,这种做法依旧会OOM。
为什么?
因为虽然我们是分批次读取的数据库结果,但是每批次生成的User对象一直没有被消费,一直放在List中,JVM不能把它GC掉。
自己想了一下,之所以写出上述代码,还是自己对流理解不够深刻。数据流就像水,有源头,有目的地,数据流必须消费,所谓的消费可以理解成被放到某个存储中,或者被删除。这个存储要么是内存、要么是磁盘上的文件、要么是通过网络传给另外一端。这样理解之后,就知道该怎么写出正确代码了。强烈建议阅读stackoverflow上对stream解释
我们每次从数据库拿出一批数据,这批数据在ResultSet中被转为对应的Entity,那么我们就要在ResultSet中消费掉这个Entity流,因为默认的Entity已经在JVM内存中了,为了不引起OOM,我们就要将这个Entity写入文件中或者通过网络返回给请求方,请求方大多数情况下是给浏览器。
流式读取并流式写入文件
这里的场景是假设要生成一个报表,该报表的数据来自数据库查询结果,查询结果非常多。这是个非常常见的需求。
下面代码数据库查询使用JPA方式,设置fetchSize,循环读取该批次数据时,每次循环拿到一个User对象后,就将这个对象写入文件中,然后将该对象从persistence context移除。
注意: 这里一定要detach,否则persistence context也会因为entity太多导致OOM。文档链接
if you are processing a huge number of objects and need to manage memory efficiently, the evict() method can be used to remove the object and its collections from the first-level cache
public boolean streamJdbcResultToFile(String fileName) {
EntityManager entityManager = JPAUtil.acquireEntityManager();
jakarta.persistence.Query query = entityManager.createQuery(JPQL, User.class);
// 将JPA的Query转换为Hibernate的Query
query.unwrap(Query.class);
query.setHint(AvailableHints.HINT_FETCH_SIZE, FETCH_SIZE);
try (FileWriter fileWriter = getFileWriter(fileName)) {
// 使用JPA的getResultStream
query.getResultStream().forEach(object -> {
User user = (User) object;
writeToFile(fileWriter, user);
// 必须detach,否则persistence context也会因为entity太多导致OOM
entityManager.detach(user);
});
}finally {
entityManager.close();
}
return true;
}
流式读取并流式返回给前端JSON对象
这里的场景是将大量JSON对象以数据流行式返回给前端。
返回给前端的JSO数据结构类似于这样
{
"users": [{
"userId": 1,
"password": "KRRLAZg0IzxPY232lJefu9l6Hts8HO1cTfLIF38jrqPWNjIT78nVYlNrCsOl",
"roles": "a5cdt8YMmXdxc5jkwFKxJBvkCZMR25ljGvVr79h33R0rB1SQKoIm6AllSGVL5Xk119phqMPKvYSPvxkXkc9W0PClhAybnPNGKm9jGey6P8IuisUNP5xvDZpKuPj00kyQ9lKSU6zr5qJN1i5U0dhOqAqPUOqpuluZNdwtDuVkaFI8sqFhCdYO6bUtSMCbiuyAOzFkn05t",
"introduction": "8m1IXJOmixm2joDScCW2LVZwJtsNdBuG3NUzlMCMjtlYnvMJ6SzEkxRATmbq4mcb7WQ1NPCWPDjKsGfAnZpewihL7Ih95IxpGzcN8vq58m9DRZjiQyIhS7DrH60chEUuEV2qaf80hxq7p3P8gmPRXTtidt9lVOT7fhj0hN0SMqJtnyXoaQ6WFOVXehGkQyZfjWAJJiyu"
}]
}
这里使用的是RestEasy的响应流,如果是纯Servlet的,可以换成ServletOutputStream
public StreamingOutput streamJdbcResultToResponse() {
StreamingOutput stream = output -> {
EntityManager entityManager = JPAUtil.acquireEntityManager();
Query<User> query = entityManager.createQuery(JPQL, User.class).unwrap(Query.class);
query.setFetchSize(FETCH_SIZE);
// 两个流都会被自动关闭
try(ScrollableResults<User> scrollableResults = query.scroll();
// 使用JsonGenerator
JsonGenerator jsonGenerator = OBJECT_MAPPER.getFactory().createGenerator(output);) {
// 开始构造JSON对象
jsonGenerator.writeStartObject();
// JSON对象的key
jsonGenerator.writeArrayFieldStart("users");
while (scrollableResults.next()) {
User user = scrollableResults.get();
jsonGenerator.writeObject(user);
entityManager.detach(user);
}
// 结束
jsonGenerator.writeEndArray();
jsonGenerator.writeEndObject();
}
};
return stream;
}
测试
测试的时候,可以将fetchSize相关代码放开或注释掉,并在IDEA中设置较小的JVM内存,例如
-Xms50m -Xmx50m
观察OOM的发生以及在较小内存下,流式读取+流式处理结果集,写文件、写响应给前端的不同之处。
其他有帮助文章和代码
本文示例代码没有使用Spring,关于在Spring中将结果流式返回给前端,可以参考下面这篇文章或去Stackoverflow上找一下
示例代码
源码中包括了一个完整的PostgreSQL的docker-compose文件,以及生成测试数据的配置。想要完整体验代码的同学,确保本地已经安装了Docker,JDK11, Tomcat10即可。如何运行、如何测试都已在README中给出。