Jdbc流式读取及后续流式处理的正确方式及示例

本文讨论了在PostgreSQL中使用JDBC流式读取避免一次性加载大量数据引发OOM的问题,介绍了如何正确处理流式查询的结果,包括流式写入文件和流式返回给前端的JSON对象,以及如何在Spring和非Spring环境下实现这一过程。
摘要由CSDN通过智能技术生成


本文数据库使用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中给出。

  • 18
    点赞
  • 21
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值