JDBC游标解决大数据导出OOM:从原理到实战的完整方案

发布时间:2026/9/9 4:32:45
JDBC游标解决大数据导出OOM:从原理到实战的完整方案 做政务类大数据平台开发的人十有八九都撞上过这个场景全量数据导出任务一开始JVM堆内存像坐了火箭一样往上蹿几分钟后一条OOM直接砸下来整个导出链路当场瘫痪。我之前在处理某个政务数据平台的导出功能时就翻过这个车——几百万行记录用MyBatis一次性查出来再写文件内存直接被打满任务重启了好几次才意识到问题不在并发而在最基础的查询方式上。后来绕开MyBatis改用JDBC游标逐行读取、逐行写文件才把内存占用从几个GB压到了几十MB。这篇文章就把这次OOM的根因、排查过程、方案取舍以及完整的落地代码都整理出来。搞过大数据导出、报表下载、数据交换这类场景的同学可以直接抄作业刚接触JDBC和MyBatis的也可以借此把“查询结果集到底是怎么进内存的”这件事彻底弄明白。1. 先把问题看明白MyBatis查大数据为什么会把内存打满1.1 事故现场一次全量导出引发的 OOM事情是这样的。当时平台有一个数据导出功能业务方要求把某个主题库里的全量明细数据导出成文件提供给下游部门做数据核对。测试环境数据量只有几十万行跑得好好的从来没出过问题。结果一上生产业务表里攒了将近八百万行数据导出任务启动后不到半分钟报错就来了java.lang.OutOfMemoryError: Java heap space一开始我以为是并发任务太多把内存抢光了赶紧去看线程栈和堆内存快照结果发现罪魁祸首就是导出任务本身。代码逻辑其实很普通Service层调Mapper接口查出全量数据返回一个List然后遍历这个List拼接成文件内容。就是这么一段“看起来没毛病”的代码在数据量面前露出了真面目。政务类数据表的特点是字段多、单行数据大八百万行、每行上百个字段一次性全部加载到JVM堆内存里就是好几个GB。不管你的堆设置成4G还是8G只要数据量再涨迟早会爆。这不是调优能解决的问题而是查询方式在根上就有缺陷。1.2 MyBatis的默认行为全量结果集一股脑装进List要理解为什么OOM得先弄清楚MyBatis执行一次普通查询时内部到底发生了什么。大多数业务代码用的是这种写法ListDataRecord list dataMapper.selectAll();这类方法的底层逻辑是MyBatis通过JDBC执行SQL拿到ResultSet之后在DefaultResultSetHandler里逐行处理结果每处理一行就通过反射创建一个对应的Java对象最终把这些对象全部塞进一个ArrayList返回。换句话说MyBatis默认的selectList模式把数据库返回的所有行都物化成了Java对象全部放进堆里。一百万行就是一百万个对象一千万行就是一千万个对象。单行对象看着不大但对象头、字段引用、集合结构这些开销叠加起来量级是非常恐怖的。我当时查了下堆dump光是这个List里的数据对象就占了将近3GB内存。数据还没开始写文件内存就已经快被“查出来”这件事吃光了。这也是很多大数据量导出场景OOM的通用根因数据根本不是处理不过来而是查询阶段就把内存打满了。1.3 为什么不建议在MyBatis里硬解这个问题有人可能会说MyBatis不是提供了ResultHandler吗可以自己实现一个处理器让它逐行回调不就能避免List堆积了吗这个说法理论上没错但在实际项目中我不太推荐把它当成第一选择。原因主要有三点。第一MyBatis的ResultHandler尽管能做到逐行处理但并没有改变“查询一张超大表时数据库驱动和MyBatis内部缓存结果集”的底层机制。如果配置不当或者对MyBatis的流式查询原理理解不够深该OOM还是OOM。第二政务老项目里的MyBatis版本普遍比较旧很多流式处理特性并不完善临时升级版本会带来一堆兼容性问题。第三也是最重要的一点绕开MyBatis直接用JDBC代码路径更短、依赖更少、行为更透明出了问题也好排查。用一个类比来说明MyBatis默认查询像是用一个大卡车把货全装回来再慢慢分拣而JDBC游标相当于一条传送带货从数据库那边一点点送过来这边一个个处理完就送走。对于“全量导出”这种不依赖ORM映射的场景传送带方式显然更合适。2. 方案对比分页、临时表、游标为什么最后选了JDBC游标2.1 分页导出看起来简单深翻页是大坑在想到游标之前我第一个尝试的方案是分页查询。思路很直接一次查一万条写入文件再查下一万条循环往复。这个方案在数据量几百万时勉强能用但生产环境跑起来立刻暴露了两个问题。第一个是深翻页性能问题。常见的分页写法是LIMIT offset, sizeMySQL在计算偏移量时需要扫描并丢弃前面所有行。offset到了几百万时一条分页SQL可能要执行好几秒甚至更久整个导出任务变成了一个巨型慢查询数据库CPU直接被打满。第二个是数据一致性问题。导出过程中如果有新数据写入分页查询的结果集会发生变化可能出现重复或漏数据。比如你在导出期间业务表新增了一条记录正好落在某个分页区间里就可能被重复导出。对政务数据交换这种场景来说数据一致性是硬要求这个隐患不能忍。2.2 临时表快照稳定但成本太高既然分页有一致性问题我又想到了临时表方案先把要导出的数据INSERT INTO一个临时表然后对临时表做分页读取。因为临时表是导出一开始的数据快照后续业务写入不影响它一致性自然就保证了。但这个方案有个明显的弊端等于把全量数据从主表复制了一份既占存储空间又占数据库性能。八百万行数据的拷贝可不是闹着玩的光写入临时表这一步就要占用大量数据库资源。如果导出的表不止一张临时仓库甚至可能把磁盘空间撑爆。运维的同事看到临时表占了几十GB空间直接找到了我。2.3 游标的原理让数据库按批次“递数据”最终选的方案是JDBC游标本质上它就是充分利用数据库驱动提供的流式读取能力。完整的流程是这样的先通过JDBC拿到数据库连接创建PreparedStatement执行查询此时SQL已经发送到数据库端但结果集并不会一次性全部传回客户端而是由数据库端维护一个游标。客户端通过fetchSize告诉驱动“我一次只要这么多行”然后循环调用rs.next()时驱动才从数据库一批一批准拉取数据。这个过程中JVM堆里最多只保存一批数据。比如fetchSize设置为500那么内存中最多同时存在500行记录的Java对象。就算表里有五千万行数据堆内存占用也始终维持在一个很小的水平。所以整个方案的取舍很清楚第一次全量炸了堆是查询方式不对分页解决了内存但引入深翻页和一致性问题临时表解决了问题但代价太高。JDBC游标直接在最底层控制了结果集的传输节奏内存可控、代码可控、行为可预期这就是我最终选它的理由。3. 实操JDBC游标逐行读写的完整实现3.1 前期准备确认驱动版本和连接参数动手写代码前第一步是确认数据库驱动版本和连接参数支持不支持流式读取。不同数据库的游标实现方式差别很大这里我把常见的情况列一下。数据库游标开启方式注意事项MySQLJDBC URL加useCursorFetchtrueStatement设置fetchSize需要MySQL 5.0.2以上驱动老版本驱动不支持Oracle直接设置fetchSizeOracle JDBC驱动默认批量拉取fetchSize控制批次大小PostgreSQL直接设置fetchSize需在事务内执行否则游标不生效SQL Server直接设置fetchSize或使用select配合serverCursor依赖驱动类型和目标库配置我们当时用的是MySQL所以URL上必须要加一个关键参数jdbc:mysql://数据库地址:3306/数据库名?useCursorFetchtruerewriteBatchedStatementstrueuseUnicodetruecharacterEncodingutf8这里useCursorFetchtrue是让MySQL驱动以流式方式读取结果集的核心开关。如果没有这个参数就算你在代码里调了setFetchSize驱动也可能一次性把全部结果拉回客户端。另外提醒一下项目里如果用的是Druid或HikariCP连接池不需要特殊配置连接池只管连接的创建和复用不干预Statement级别的行为。3.2 核心代码游标读取ResultSet下面这段代码是当时简化后的核心逻辑注释写得很详细照着用基本不会出问题。import java.sql.Connection; import java.sql.PreparedStatement; import java.sql.ResultSet; import java.sql.Statement; public class CursorExportService { // 注入数据源这里用javax.sql.DataSource兼容各类连接池 private final DataSource dataSource; public CursorExportService(DataSource dataSource) { this.dataSource dataSource; } public void exportLargeData(String sql, RowHandler rowHandler) throws Exception { // 1. 获取数据库连接 // 注意整个导出过程中这个连接会被长时间占用连接池的超时时间要相应调大 try (Connection conn dataSource.getConnection()) { // 2. 关闭自动提交 // MySQL流式读取必须要事务环境自动提交模式下游标不生效 conn.setAutoCommit(false); // 3. 创建PreparedStatement // TYPE_FORWARD_ONLY CONCUR_READ_ONLY 是流式读取的基本要求 try (PreparedStatement ps conn.prepareStatement( sql, ResultSet.TYPE_FORWARD_ONLY, ResultSet.CONCUR_READ_ONLY)) { // 4. 设置fetchSize一次从数据库拉取500行 // 这个值不是越大越好要根据单行大小和JVM堆内存来调整 ps.setFetchSize(500); // 5. 执行查询 try (ResultSet rs ps.executeQuery()) { // 6. 逐行读取结果集 while (rs.next()) { // 每拿到一行就交给外部处理函数 // 比如拼CSV、写文件、做数据转换等 rowHandler.handle(rs); } } } finally { // 7. 提交或回滚 // 流式读完后提交事务释放数据库端游标资源 // 实际开发中可以去掉自动回滚这种写法但事务一定需要边界 conn.commit(); } } } /** * 行处理器用于定义每行数据怎么输出 */ FunctionalInterface public interface RowHandler { void handle(ResultSet rs) throws Exception; } }这段代码里有几个要点必须强调。conn.setAutoCommit(false)这行非常关键。MySQL驱动在自动提交模式下不会开启真正的流式读取游标不生效。但要注意关闭自动提交意味着事务会长期持有导出期间有可能会对线上数据库产生额外压力所以导出最好放在业务低峰期执行。ps.setFetchSize(500)这个数字需要根据实际情况调。如果每行数据有上百个字段、单行好几个KBfetchSize可以设小一点比如200如果单行很小比如只有ID和时间两个字段fetchSize可以设到1000甚至更大。目标是确保一批数据在堆内存里占用的空间不超过几十MB。3.3 边读边写文件输出与编码处理光有游标读取还不够写入文件的逻辑也要配合好。如果读一行就往磁盘写一行IO开销会非常大。正确做法是用带缓冲的Writer攒一批再flush一次。我当时导出的格式是CSV这里完整写一下文件输出部分的实现import java.io.BufferedWriter; import java.io.FileOutputStream; import java.io.OutputStreamWriter; import java.nio.charset.StandardCharsets; public class CsvFileExporter { public void exportToCsv(String sql, String outputFilePath, int fetchSize) throws Exception { CursorExportService exportService new CursorExportService(dataSource); // 计数用 long[] rowCount {0}; // 使用try-with-resources自动关闭文件流 try (BufferedWriter writer new BufferedWriter(new OutputStreamWriter( new FileOutputStream(outputFilePath), StandardCharsets.UTF_8), 8192)) { // 如果文件是给Excel直接打开的建议写入UTF-8 BOM // 否则Excel默认以ANSI编码打开中文会乱码 writer.write(\uFEFF); // 从游标里逐行读取逐行写入 exportService.exportLargeData(sql, rs - { StringBuilder line new StringBuilder(256); int columnCount rs.getMetaData().getColumnCount(); for (int i 1; i columnCount; i) { if (i 1) { line.append(,); } String value rs.getString(i); // 字段里如果包含逗号、换行、双引号需要做转义 if (value ! null (value.contains(,) || value.contains(\) || value.contains(\n))) { line.append().append(value.replace(\, \\)).append(); } else { line.append(value); } } writer.write(line.toString()); writer.newLine(); // 每满5000行flush一次避免长时间不落盘导致数据丢失 rowCount[0]; if (rowCount[0] % 5000 0) { writer.flush(); } }); } System.out.println(导出完成共 rowCount[0] 行); } }这里有几个容易踩坑的细节。第一UTF-8 BOM。CSV文件如果直接用Excel打开Excel会以系统默认编码去解析中文极大概率乱码。在文件开头写入\uFEFF这个BOM标记Excel才能正确识别为UTF-8。但如果是给程序解析的数据文件BOM又可能变成多余字符所以要看下游系统怎么消费这个文件。第二CSV转义。字段内容里包含逗号、双引号或者换行符时如果不处理文件解析必然出错。上面的代码用了最通用的CSV转义规则字段包含特殊字符时用双引号包起来字段内部的双引号替换成两个双引号。第三flush的频率。如果每行都flush性能会退化到接近逐行磁盘IO如果一直不flush中途报错时会丢失大量已处理的数据。5000行一次是个比较平衡的数值也可以改成按时间定期flush。3.4 资源关闭与异常兜底细节用游标做大数据导出资源管理要比普通查询更细致因为连接被占用的时间很长一旦出现异常数据库端的游标可能一直不释放。建议的做法是把整个流程拆成如下几个检查点连接获取后检查isValid避免拿到连接池里的坏连接否则SQL一执行就直接抛IOException排查半天以为是代码问题。PreparedStatement和ResultSet用try-with-resources管理语法上保证自动关闭。事务边界放在finally里处理。正常读完就commit异常时至少要把事务回滚避免连接归还给连接池时带着未完成的事务状态。写文件过程中如果出现异常要记得把半成品文件清理掉或者重命名加个.tmp后缀防止下游系统误读到不完整的文件。还有一点我一开始就吃过亏的导出任务跑起来后如果用户在前端页面点了取消HTTP请求中断了但后端线程不会立刻停止。最好在导出前设置一个超时控制或者支持任务记录状态让用户能主动终止正在执行的导出任务并释放对应资源。4. 常见问题与排查技巧实录4.1 游标不生效内存还是飙高这是最容易碰到的问题。代码看起来没问题fetchSize也设置了但跑起来内存还是暴涨。问题通常出在两个地方。第一个是MySQL没加useCursorFetchtrue。这个参数只对MySQL驱动生效漏了它setFetchSize就是摆设。第二个是自动提交没有关闭。MySQL驱动要求在非自动提交模式下才能启用流式读取如果setAutoCommit(true)状态下直接执行查询驱动会忽略fetchSize一次性把全部结果拉到客户端。排查时可以打一条日志在executeQuery之后用rs.getFetchSize()确认当前ResultSet的fetchSize值。如果返回的还是初始值或者不符合预期那就是配置层面出问题了。4.2 导出过程中连接被中断流式读取需要长时间占用数据库连接如果连接池的maxLifetime、connectionTimeout配置得比较短导出可能跑着跑着连接就被连接池或数据库端断开了。典型报错是Communications link failure或者Connection is not available, request timed out。我当时的处理方式是把连接级别的空闲超时调大并确保连接池最大生命周期大于导出任务的最长预计耗时。另外如果在网络环境比较复杂的机房部署建议在连接URL上加上socketTimeout0让JDBC驱动不主动断开socket连接。但这里要注意socketTimeout设为0意味着没有读超时如果数据库端卡死了客户端会一直等所以这个参数要结合运维监控来权衡。4.3 数据导出后文件乱码或CSV格式错乱文件乱码优先检查文件开头有没有BOM以及输出流指定的字符集和实际写入的字符集是否一致。我当时用的是OutputStreamWriter包BufferedWriter指定UTF-8后依然乱码排查了半天最后发现是测试时用Excel直接打开文件Excel用ANSI去解析了加上BOM就正常了。CSV格式错乱基本就是转义没做全。字段里有逗号、双引号、换行符是最常见的三类问题。尤其是地址、备注这类文本字段里面带什么字符都可能。建议写完转义逻辑后用包含特殊字符的样例数据自测一遍别等到下游同学反馈“文件打不开”才去补。4.4 性能还是慢怎么继续优化游标方案解决了内存问题但如果导出速度还是达不到业务要求可以从这几个方向去优化。第一是fetchSize调优。fetchSize太小会导致频繁的网络往返比如一次拉500行拉一百万行就要两千次网络请求延迟自然会大。可以在内存允许的前提下把fetchSize调到1000或2000测试看效果。第二是SQL层优化。保证导出SQL用到合适的索引避免查询过程中大量回表。有时候导出慢不是因为游标方式有问题而是SQL本身执行就慢全表扫描几百万行再怎么优化客户端代码也没用。第三是写文件优化。除了BufferedWriter导出数据量特别大时可以考虑边写边压缩比如直接输出为gzip格式或者写入后打成zip包。文件变小了磁盘IO和网络传输都会快不少。下面是当时整理的常见问题速查表方便排查时对照现象可能原因解决方案内存仍然暴涨缺少useCursorFetchtrue或未关闭自动提交检查JDBC URL参数和setAutoCommit(false)导出中途连接断开连接池生命周期设置过短调大maxLifetime确认socketTimeout文件内容中文乱码编码不一致或缺少BOM统一使用UTF-8按需写入BOMCSV打开列错乱字段含逗号、引号、换行未转义补充CSV转义逻辑速度很慢fetchSize太小或SQL未走索引调大fetchSize分析执行计划下游读取文件报错半成品文件未清理先写临时文件写完再重命名5. 站在项目角度的几点总结性思考5.1 技术选型要多问一句“数据量上限在哪”这次OOM的教训让我意识到一个问题很多系统在开发阶段用测试数据验证功能几十万行数据完全没有压力但没人问过生产环境的数据规模上限是多少。等到用户量增长、数据累积原本没问题的代码突然就变成了事故隐患。所以我建议在做任何涉及数据集成的功能设计时先明确一个量级概念。千行级别、万行级别、百万行级别、千万行级别对应的是完全不同的技术方案。MyBatis默认查询处理十几万行没问题但要做全量导出起步就要按百万行以上来考虑直接选择流式方案。5.2 不是所有场景都要绕开MyBatis最后解释一个容易被人误解的点。我在这里说绕开MyBatis并不是说MyBatis不好而是在特定场景下用MyBatis反而要“绕过三层映射去拿底层能力”得不偿失。如果导出时还需要做大量Java对象转换比如要把数据库字段映射成复杂嵌套结构MyBatis的ResultHandler仍然有它的价值。但政务数据导出的主流场景就是“把表结构原样倒出来”这种情况下JDBC直接操作ResultSet是最短路径。连接复用交给连接池事务控制自己管理行数据处理自己写反而清爽。5.3 分享一个可以在下次直接用的经验我个人这两年做类似需求时已经形成了一套固定的默认模板无论是导出CSV还是Excel只要数据量可能超过百万行第一反应就是JDBC游标加BufferedWriter先保证内存不炸再谈性能优化。这套方案我用了很多次一次OOM都没再出现。如果后续业务方提出“要支持几千万行甚至上亿行导出”这类需求游标方案也还有扩展空间比如在读取端引入并行分片或者在写入端用批量二进制格式替代文本格式这些都是以JDBC游标为底座继续往上叠加的思路。先把最基础的流式读取跑通后面做什么都不慌。