ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Java后端大数据量导出OOM?流式查询原理与MyBatis/JDBC实践

Java后端大数据量导出OOM?流式查询原理与MyBatis/JDBC实践 做后端开发的朋友八成都在导出功能上栽过跟头。数据量一大导出接口就抖写着写着内存飙到顶Full GC 咔咔响紧接着就是一行刺眼的OutOfMemoryError: Java heap space。我最早遇到这个场景是做订单导出库里有几百万条数据一条select *查出来后端拿List接着然后各种转换、拼装、写文件最后 JVM 直接判了死刑。后来我把方案换成流式查询同样的数据量内存几乎没怎么动导出照样跑。这篇文章就是想把流式查询这个方案从头到尾讲透为什么会 OOM、流式查询的原理到底是什么、MyBatis 和 JDBC 分别怎么落地、有哪些坑要躲、线上出了问题要怎么排查。正在做报表导出、数据迁移、数据同步的 Java 开发同学可以重点看被线上 OOM 搞到头大的朋友拿来当排查手册也行。1. 从 OOM 说起大数据量导出的内存困境1.1 一次性查询为什么必炸很多业务导出的实现逻辑长得很像Controller 收到请求Service 调 Mapper 查出一批数据返回ListOrder然后循环getXxx()取值拼 CSV 或者写 Excel最后输出给前端。这套逻辑在小数据量的时候完全没问题但数据量一旦上去内存就成了第一块短板。先算一笔账。假设一张订单表 500 万行每行有订单号、用户 ID、金额、状态、创建时间等 20 个字段。MySQL 驱动把结果集一行行往 Java 对象里塞一个简单的Order对象光对象头就占 16 字节加上 20 个字段的引用、String 的 char[] 开销单条对象轻松超过 500 字节。500 万条就是 2.5GB 起步。如果导出时还要做字段拼接、格式转换、Excel 行对象缓存那内存占用还得再翻倍。常见的-Xmx2g、-Xmx4g配置在这种量面前根本不够看多来几个并发导出请求OOM 只是时间问题。更麻烦的是 JVM 的 GC 行为。内存里塞了几 GB 的存活对象后Minor GC回收不掉对象晋升到老年代接着触发Full GC。Full GC 频繁执行时CPU 飙高、接口响应变慢、整个应用出现“世界暂停”最终 CMS 或 G1 也扛不住抛出OutOfMemoryError。很多同学以为加个-Xmx8g就万事大吉其实只是把爆炸点延后了8GB 也扛不住几千万行数据同时驻留在堆里。1.2 常见的“伪解决”方案与代价为了解决导出 OOM市面上有不少看似合理的方案但每个都有隐藏成本。分页查询是最多人第一个想到的。把大查询改成LIMIT 0, 10000循环拉每页 1 万条处理完再拉下一页。这个方案在小表上没问题但到了大表就露馅深分页时OFFSET越来越大数据库要把前 N 页数据全部扫描一遍再丢弃越往后越慢。而且如果导出期间表里有数据插入或删除会出现记录重复或者漏导。分页查询能解内存问题但解决不了性能和时间问题还会引入一致性问题。ResultHandler 回调在 MyBatis 里也常被推荐。MyBatis的ResultHandler可以在每查出一条记录时立刻回调处理不需要把所有结果对象攒在一个List里。这个思路比一次性List好一些但很多同学忽略了一个前提如果 JDBC 驱动默认把整个结果集都拉到客户端内存里ResultHandler只能保证应用层不堆积对象却不能阻止驱动层把整个结果集塞进内存。换句话说如果底层ResultSet默认是一次性加载ResultHandler并不能从根源上解决 OOM。临时表 批量导出也有团队在用。先把要导出的数据写入临时表然后分批从临时表取、分批删。这个方案的工程复杂度偏高要在库里建表、写清理任务、处理临时表和源表的数据一致性问题。而且如果导出的数据本身来自复杂的多表 JOIN落到临时表也有额外的写入开销。这些方案不是完全不能用而是“代价换内存”的思路。真正的解法是让数据不要一次性进内存而是“随取随用”这就是流式查询的用武之地。2. 流式查询的核心原理让数据库成为你的外置内存2.1 流式查询到底改变了什么流式查询的本质是调整数据在“数据库”——“JDBC 驱动”——“应用进程”这条链路上的流动节奏。普通查询是“全量传输”数据库把匹配结果全部返回给驱动驱动全部解析成 Java 对象应用层全部拿到后统一处理。流式查询则把这个过程改成了“按需拉取”应用层处理完当前一批数据再向数据库要下一批。以 MySQL 为例JDBC 驱动开启useCursorFetchtrue之后服务端会为这条查询创建一个游标客户端每次主动fetch一批数据处理完再fetch下一批。整个结果集不是一次性进入 JVM而是以“批”为单位流过内存。对于导出这类场景数据库本身变成了一个“外部存储”JVM 只保留当前正在处理的那一小撮数据。这个改变带来的收益很直观内存占用从“和结果集总量成正比”变成“和每次抓取的批量大小成正比”。批量抓 1000 条时哪怕底层表有 5000 万行JVM 里同时存活的导出对象也就几百 MB稳定性完全不一样。2.2 为什么同样一条 SQL有的驱动不听话经常有同学反馈我也设置了fetchSize怎么内存还是涨这里有个隐藏知识点fetchSize能不能生效取决于数据库驱动和连接参数是否配合。MySQL 的Connector/J默认情况下并不会因为你设置了fetchSize就开启游标。必须在 JDBC URL 上额外加上useCursorFetchtruefetchSize才会变成“每次从服务端抓取的行数”。如果只在PreparedStatement上设置setFetchSize(500)而 URL 里没有开启useCursorFetch驱动会忽略这个设置仍然一次性把全部结果集拉回客户端。这是 MySQL 流式查询最常见的“假生效”。PostgreSQL 的驱动行为又不一样。它的 JDBC 驱动会在autoCommitfalse时配合setFetchSize启用游标式读取所以用 PostgreSQL 要注意关闭自动提交否则同样的代码也不会触发流式效果。Oracle 则是在ResultSet层面天然支持分段读取默认就比 MySQL 友好。这里多说一句老版本 MySQL 驱动的一个土办法把fetchSize设置为Integer.MIN_VALUE-2147483648会触发驱动内部把查询转换成逐行流式读取。这个技巧在老项目中很常见但新版本驱动更推荐用useCursorFetchtrue的正规方案兼容性和可维护性都更好。2.3 实现方案的选型对比流式查询在工程落地时有几条路线简单整理一下各自的定位方案使用场景优点缺点MyBatisCursorT项目已用 MyBatisMapper 返回游标代码侵入小能复用既有 SQL需处理 SqlSession 生命周期Spring 环境下有坑原生 JDBCResultSet对连接、事务有精确控制需求最透明没有任何 ORM 层包装代码量多需手动管理连接和资源MyBatisResultHandler小批量、结果集不大使用简单驱动不配合时仍会全量加载治标不治本分页循环查询无驱动支持、数据规模可控实现门槛低深分页性能差、数据一致性难保证我个人的建议是如果项目里已经用了 MyBatis优先用CursorT方案如果团队对 SQL 和连接管理有洁癖直接用原生 JDBC。总之要让“数据真正以流的形式进入应用”而不是做表面功夫。3. MyBatis 流式游标完整实操3.1 基础用法Mapper 返回 CursorMyBatis 从 3.4.0 开始提供了org.apache.ibatis.cursor.Cursor接口它本质上是ResultSet的游标封装。Mapper 方法的返回值类型直接声明成CursorTMyBatis 就会以流式方式创建结果集而不会一次性把全部对象塞进List。先看最基础的 Mapper 写法Mapper public interface OrderMapper { Select(select * from t_order where create_time #{startTime} order by create_time) CursorOrder scanOrders(Param(startTime) LocalDateTime startTime); }对应的 XML 写法其实也一样关键是返回值类型是Cursorselect idscanOrders resultTypecom.example.Order select * from t_order where create_time gt; #{startTime} order by create_time /select然后 Service 层这样消费游标public void exportOrders(LocalDateTime startTime, Path output) { try (CursorOrder cursor orderMapper.scanOrders(startTime)) { IteratorOrder it cursor.iterator(); while (it.hasNext()) { Order order it.next(); // 逐条处理写入 CSV / Excel / 消息队列 } } }try-with-resources会在遍历结束后自动关闭游标这一点很重要。如果忘记关闭Cursor底层的ResultSet和Statement会一直挂着数据库连接也一直占着不放。3.2 Spring 环境中避免“Cursor 已关闭”的关键配置这里有一个 MyBatis 流式查询最容易踩的坑。在 Spring MyBatis 的组合里Mapper 方法默认在一个短生命周期的SqlSession中执行。方法执行完SqlSession就关闭了数据库连接归还连接池。问题在于Cursor需要依赖这个SqlSession保持打开才能继续从数据库取数。如果你在 Service 方法里写成这样CursorOrder cursor orderMapper.scanOrders(startTime); // 等这个 Service 方法返回后SqlSession 已经关了 // 再在外部遍历 cursor 就会报 “ResultSet closed” 或 “Cursor is closed”解决办法有几种。最推荐的是在同一个事务性方法内完成“查询 遍历 导出”的全流程。Spring 的SqlSessionTemplate会把SqlSession绑定到当前事务上事务方法结束前SqlSession不会被关闭。也就是说只要给 Service 方法加上Transactional游标在整个方法内都是可用的Transactional public void exportOrders(LocalDateTime startTime, Path output) { try (CursorOrder cursor orderMapper.scanOrders(startTime)) { IteratorOrder it cursor.iterator(); while (it.hasNext()) { // 处理逻辑 } } }如果不方便开事务也可以用SqlSessionTemplate手动获取一个独立的SqlSession在try-with-resources里完成全部操作Autowired private SqlSessionTemplate sqlSessionTemplate; public void exportOrders(LocalDateTime startTime, Path output) { try (SqlSession session sqlSessionTemplate.getSqlSessionFactory().openSession()) { CursorOrder cursor session.selectCursor(com.example.mapper.OrderMapper.scanOrders, startTime); IteratorOrder it cursor.iterator(); while (it.hasNext()) { // 处理逻辑 } } }这里需要注意使用openSession()手动开启会话实际上是在独占管理一个连接。在这个SqlSession关闭之前连接池里那一个连接一直被占用所以遍历要尽快处理完不要让游标长时间挂在那。3.3 导出 CSV 的完整示例代码下面是完整的导出订单 CSV 的示例加上缓冲写入性能更好。注意 CSV 的流式写入要用BufferedWriter而不是把所有行内容先拼成一个StringBuilder再一次性输出否则内存又从另一个地方涨上去了。Service public class OrderExportService { private final OrderMapper orderMapper; public OrderExportService(OrderMapper orderMapper) { this.orderMapper orderMapper; } Transactional public void exportOrders(LocalDateTime startTime, Path csvPath) throws IOException { try (CursorOrder cursor orderMapper.scanOrders(startTime); BufferedWriter writer Files.newBufferedWriter(csvPath, StandardCharsets.UTF_8)) { writer.write(orderId,userId,amount,status,createTime); writer.newLine(); IteratorOrder it cursor.iterator(); while (it.hasNext()) { Order order it.next(); String line String.format(%s,%s,%s,%s,%s, order.getOrderId(), order.getUserId(), order.getAmount(), order.getStatus(), order.getCreateTime()); writer.write(line); writer.newLine(); } } } }关于 CSV 导出有一个容易忽略的点字符串拼接用String.format在性能上并不占优如果单条记录字段特别多可以考虑直接用字符串拼接或者StringBuilder但要注意别把每一行都驻留成不可回收的对象。这里贴的是可读性优先版本的写法生产环境可以按需微调。4. JDBC 原生流式查询与参数调优4.1 MySQL 使用 useCursorFetch 的正确姿势如果不想走 MyBatis直接在 JDBC 层写流式查询也完全可以而且更透明。先看 MySQL 下的标准写法// 1. 在 JDBC URL 中启用游标抓取 // jdbc:mysql://localhost:3306/test?useCursorFetchtruedefaultFetchSize500 try (Connection conn dataSource.getConnection(); PreparedStatement ps conn.prepareStatement( select * from t_order where create_time ?, ResultSet.TYPE_FORWARD_ONLY, ResultSet.CONCUR_READ_ONLY)) { ps.setFetchSize(500); ps.setFetchDirection(ResultSet.FETCH_FORWARD); ps.setObject(1, startTime); try (ResultSet rs ps.executeQuery()) { while (rs.next()) { String orderId rs.getString(order_id); long userId rs.getLong(user_id); BigDecimal amount rs.getBigDecimal(amount); // 逐行处理 } } }代码里有几个要点。ResultSet.TYPE_FORWARD_ONLY和CONCUR_READ_ONLY必须设置游标语义下只能向前单向读取不能回滚行指针。fetchSize建议显式设置哪怕 URL 里配了defaultFetchSize代码里再写一遍更稳妥。FETCH_FORWARD是给驱动一个优化提示告诉它“我只会往前读”可以让服务端用更高效的滑动窗口。MySQL 使用游标查询还有一个必然的代价在整个游标生命周期内这条数据库连接不能再去执行别的 SQL也不能被归还连接池。所以遍历期间的所有数据处理逻辑都要在同一个方法里完成不能把ResultSet传出去给别的线程异步消费。4.2 PostgreSQL、OceanBase 等数据库的 fetchSize 差异很多团队的数据库不只是 MySQLPostgreSQL 和国内常见的分布式数据库也有一些差异。PostgreSQL 的 JDBC 驱动对fetchSize的支持比较宽松。基本结构如下Connection conn dataSource.getConnection(); conn.setAutoCommit(false); // PostgreSQL 需要关闭自动提交才能启动游标 PreparedStatement ps conn.prepareStatement(select * from t_order where create_time ?); ps.setFetchSize(500); ResultSet rs ps.executeQuery(); while (rs.next()) { // 处理 } rs.close(); ps.close(); conn.commit(); // 将资源释放交给驱动 conn.close();注意autoCommit必须关闭并且最后要调用commit()或rollback()否则连接上的事务状态会悬空连接归还连接池后可能影响下一次复用。国内不少团队用的是 OceanBase、TiDB 这类兼容 MySQL 协议的分布式数据库它们在useCursorFetch行为和 MySQL 大体一致但不同版本对游标内存的处理有差异。建议上线前先做一个几百万元素的性能验证把fetchSize分别调成100/500/1000/5000跑一遍观察内存曲线、单次查询耗时和带宽占用再做决定。不同数据库对“服务端临时存储”的实现不同有的会在存储层开辟临时文件数据量过大时磁盘开销会比较明显。4.3 fetchSize 应该设置为多少实测与调参思路fetchSize不是一个越大越好的参数也不是越小越好它取决于你处理每条记录的耗时和网络带宽。如果fetchSize太小比如100驱动会频繁和数据库交互网络往返次数大幅上升总耗时变长。如果fetchSize太大比如10000单次加载到应用层的数据量太大内存压力又会回来。我个人的实践参考单条记录在 1KB 以内时fetchSize 设为 500~1000 比较均衡单条记录是包含大文本或 JSON 的大对象时fetchSize 降到 200~500。核心逻辑是让“单批数据的总字节数”控制在几 MB 以内这样 JVM 吃得下网络传输也顺。从实验角度看可以这样测先跑一个固定查询统计 JVM 堆内存的used曲线然后分别固定fetchSize200/500/1000跑三轮记录耗时和内存峰值。一般情况下能看到内存峰值和fetchSize近似线性关系耗时则是先随fetchSize增大而下降到某个点之后趋于平稳。找到那个“性能拐点”对应的fetchSize就是当前环境的最优值。5. 常见问题与线上排查实录5.1 问题一连接长时间占用拖垮线程池之前我遇到过这样一个线上事故监控大屏上看到mrds63实例老是被 Full GC 告警轰炸隔三差五报Java heap space后来mrds65也出现同样情况。把堆转储捞下来一看里面躺着几十个ListOrder每个 List 里都是几十万条订单记录。这就是典型的“一次查询全量加载”导致的 OOM。切到流式查询之后又引发了一个新问题点导出的人一多连接池的连接被游标占满了。流式查询天然就是“长事务、长连接”操作一行行读、一行行写可能持续几分钟甚至十几分钟这个连接在此期间不能给其他请求用。如果连接池上限是 20同时有 20 个导出任务在跑其他正常业务就全部拿不到连接。解决办法有两个方向。第一导出任务不要直接挂在用户的 HTTP 请求线程上而是做成异步任务通过线程池或消息队列提交任务执行时独占一个连接等任务结束再放回连接池。第二如果必须实时导出把连接池的maxActive适当加大同时给导出接口做限流超过并发上限直接返回“系统繁忙请稍后下载”。我的经验是异步化 限流双管齐下比较靠谱。5.2 问题二流式查询中再执行 SQL 报 CommunicationsException这是一个非常隐蔽的坑。MySQL 游标模式下结果集还没遍历完时同一个连接上再去执行另一条 SQL会直接报错常见的是CommunicationsException或者Cannot call statement when connection is in streaming mode。原因很好理解服务端协议规定游标占用期间连接不能复用驱动也限制了同一连接只能有一个活跃结果集。遇到这个问题的同学多半是在遍历游标时又调用了某个 MyBatis Mapper 方法去查关联数据。比如导出订单要带出用户姓名于是在while (it.hasNext())里又调userMapper.selectById(...)。这就是典型的N1查询本身就有性能问题在流式模式下还会直接踩中协议限制。正确的姿势是提前把关联数据全部查好放到一个Map里。或者用 JOIN 单条 SQL 直接查出所有需要的字段游标只负责读取。再不行就把关联查询放到批处理环节攒够 1000 条再统一查一次避开“流中嵌套 SQL”的冲突。5.3 问题三事务快照与 Undo 膨胀流式查询往往配合事务使用MySQL 默认的REPEATABLE READ隔离级别下事务开始后就固定了一个快照。好处是整个导出过程看到的是同一份数据不会中途“越导越乱”。坏处是如果导出时间特别长而同时有其他事务在修改这些数据数据库需要保留大量的Undo日志来支撑快照Undo 膨胀会拖垮磁盘和性能。最典型的表现是导出任务跑了一个小时还没结束业务侧正常的更新操作全部变慢数据库的undo tablespace疯长。这时候要反思导出数据量实在太大是不是应该拆分成小批次任务每一批单独开事务导完一批就提交一批。或者评估数据实时性的要求导出功能使用的数据副本是不是可以走只读从库降低对主库事务的压力。以我的经验超过千万级的导出任务不适合用一个长事务跑完。要么按时间区间切分成多个子任务每个子任务一个事务、一条游标要么在业务上设定“导出的是截至某一时刻的快照”提前把数据落到一个独立的导出表再从这个表做流式读取。这样既能满足数据一致性也能避免把数据库拖垮。5.4 问题四导出中途失败如何保证任务可重试流式查询本身不提供断点续传能力。如果导出到第 300 万行时服务重启或者网络闪断前面的成果就全没了从头再来代价很大。这个问题虽然不是流式查询直接造成的但在流式方案里特别突出因为单次任务的数据量往往很大重新执行的成本高。我常用的做法是给导出任务增加“批次标记”。比如导出一批订单时按create_time或自增主键 ID 分段记录每个任务处理的区间范围启动前先检查该区间是否已处理过。如果采用 CSV 导出可以边写边同步更新“已导出的最大 ID”任务重启后从那个 ID 继续。另一种比较省事的做法是导出过程中把已处理记录的 ID 写入 Redis 的Set但数据量大时 Redis 内存也扛不住反而还是数据库记录更可靠。具体到流式查询我推荐的模式是外层用任务表记录导出批次状态内层按主键区间循环开游标。例如先把订单表按 ID 切成[0, 1000万)、[1000万, 2000万)若干段每一段跑一个独立的流式查询任务负责更新这一段的状态。无论哪一段失败只需要重跑这一段而不用全量重来。最后再分享一点个人经验流式查询不是一个花哨的技巧它本质上是一种“内存策略”的选择让内存只保留正在处理的数据而不是把所有数据都堆在 JVM 里。做导出功能这么多年我最大的体会是不要等到 OOM 了再去加内存、调 GC而是从一开始就想清楚数据规模的上限然后选择相配套的读取方式。很多同学第一次用流式查询时会觉得“性能是不是变差了”因为感觉上是一条一条在处理。实际上只要fetchSize调得合理整体耗时和全量加载相差不大而且内存曲线平稳得多。真正的性能瓶颈往往出现在下游比如 Excel 写入慢、文件服务器带宽不足、数据转换 CPU 占用高这些和查询方式无关。定位问题的时候先分清瓶颈在“数据读取”还是“数据消费”再动手优化方向就对了。如果这个功能后续还要扩展可以考虑把流式查询和异步导出、文件分片结合起来比如先流式读取数据生成多个临时分片文件最后再合并且异步推送下载链接。这样一来导出接口的响应时间会大幅下降用户体验也会好很多。
返回列表