JDBC FetchSize不生效,批量变全量致OOM问题分析
背景
一个简单的基于 JDBC 采集数据库表的功能,当采集 Postgre SQL 某表,其数据量达到 500万左右的时候,程序一启动就将 JVM 堆内存「6G」干满了。
问题是程序中使用了游标的只前进配置,且设置了 fetchSize
属性:
queryStat = connection.prepareStatement(executeSql, ResultSet.TYPE_FORWARD_ONLY, ResultSet.CONCUR_READ_ONLY);
queryStat.setFetchSize(batchSize);
为什么这个批量拉取数据的配置不生效呢?本文记录这个问题的排查过程及优化方法。
导出堆内存
程序一启动,jmap -heap
查看堆内存,老年代直接干到 99.98 % ,这时的程序直接 Stop all the world ,僵了。
JVM 启动最大堆内存已经调整到 6G 了,还是撑不住。感觉 SQL 查询的时候一下子将表的全部结果都加载到内存了,前面配置的批量拉取设置根本没生效。导出堆内存文件,进行分析。
nohup jmap -F -dump:live,format=b,file=/home/dump-result.hprof 23055 &
堆内存太大了,只能走后台进程的方式导出,接近一个小时才导出了 dump 文件,5.8G,确实跟 JVM 最大内存一样了。
堆内存分析
使用 mat 打开这个文件,直接内存溢出了。然后修改 mat 的 JVM 参数到8G后,得到分析结果不对,才几十M,明显不符合。
有很多 unreachable object,重新修改 mat 配置,勾选 “keep unreachable objects”,同时修改展示单位为 MB:
删除上次分析的结果文件后,重新导入 dump 文件分析,得到分析结果:
点开 Leak Suspects 查看内存泄漏的地方,发现最大的对象4.5G ,是一个列表,列表元素类型是 org.postgresql.core.Tuple
,盲猜这个类就是 JDBC 封装的查询结果
而这个类的对象总数跟表记录总数基本一致:
少掉的那些,应该是 GC 努力回收过的,但是剩余量还是很大。
这基本验证了前面的猜测,批量查询实际上成了全量查询了。为了再次确认,调整代码,造一张同结构、但是数据总量6万左右的表,然后在 while(result.next())
遍历的循环里面加上 sleep 10 分钟
后启动程序,导出堆内存。
这次程序老年代内存没有撑满,导出内存分析,Tuple 这个查询结果类对象的个数,跟数据库表总记录数「58000」多了21,基本可以确定这个批量size 没有生效。
问题分析
为什么批量加载不生效呢?是数据库的问题?驱动的问题?
尝试的方法:
- ❌升级数据库驱动为最新版本,无效。
- ❌在
while(result.next())
遍历过程中,直接打印一个字符串后continue
,休眠5秒,手动调用 GC。不做任何操作,且手动触发 GC,JVM 内存还是满了。 - ❌怀疑数据库有问题,确定测试环境版本和出问题的现场环境一致。
- ❌目标数据库是基于 OpenGauss 自研的数据库,难道不支持游标的批量获取数据?
搜到一篇文章 《Postgres查询结果集的获取方法及其优缺点》 ,里面提到了 PostgreSQL 数据库的批量获取游标结果集生效的四个条件:
- 连到数据库服务的连接必须是基于V3协议的,V3协议是7.4及更新版本PG才能支持的,并且是他们的默认协议;
- Connection必须是非自动提交模式.后端会在事务的结束的时候关闭游标,所以,在自动提交模式里,还没从游标里获取任何东西的时候,后端就已经把游标关闭了。「冷知识:Connection 默认是自动提交的。」
- Statement必须以ResultSet.TYPE_FORWARD_ONLY的类型来创建,该结果集类型是默认的,所以可以直接使用stmt = conn.createStatement()来创建(或者stmt = conn.createStatement(ResultSet.TYPE_FORWARD_ONLY)).因此基于游标的结果集是只能向前获取,不能向后或者跳跃获取的。「PS:PostgreSQL默认就是这个类型,所以这个不是关键。」
- 查询sql语句必须是一个单一的语句,不能是由分好分隔的多个语句。这个在本应用中不存在。
之前没仔细注意第2点,找了三天实在没办法了。又打开这篇文章,仔细看了一下,发现了这个点。
检查代码确实没有设置自动提交参数,加上它,还原 JVM 参数为2G,然后测试500万条数据顺利采集完成,老年代堆只占2%。
复测验证:再去掉这行代码,回到原点,还是一启动就堆满了,确定这行代码就是关键。排查了三天的问题,就这么简单的一行代码就解决了吗?赶在周末之前干掉问题,真是太幸运了。
优化结果
继续优化,循环遍历数据总量到达一个值后,手动触发 GC并休眠1秒:
// 手动触发GC,且休眠等待
if (count == maxFetchSize) {
logger.info("Reach max batch size {}, sleep 1s to gc", maxFetchSize);
count = 0;
// 手动触发 GC
Runtime.getRuntime().gc();
// 等待GC完成
Thread.sleep(1000);
}
将优化后的结果,加上 sleep 10分钟后,导出堆栈分析,发现这次 Tuple 类的个数就是 setFetchSize=2000,还多了21个。
跟上面那个一样,数据总量+21,说明额外还有 21 个对象,为查询操作提供了不为人知的功能。总归来说,只有加上这句话 connection.setAutoCommit(false);
才生效,才是真正的批量查询数据。
启示录
一开始就检索到了 《Postgres查询结果集的获取方法及其优缺点》 这篇文章,里面提到了 PostgreSQL 数据库的批量获取游标结果集生效的方法,但是忽略了重要的那个条件。
循环处理数据时,达到一个值后,手动触发 GC 还是有效的,可以让整个采集过程中老年代内存占用情况稳定在 2% 左右;如果去掉 GC 的话,内存会缓慢升至 10% 左右,但是已经不会再僵死了。
这个 JDBC 的批量查询不生效问题,前年冬天采集 Doris 的时候也发现了,只是后来没有细究。这次又碰到了,不知掉 Doris 能不能用这个配置解决呢?或者说 Doris 数据库支不支持批量查询呢?