当前位置: 首页 > news >正文

MyBatis流式查询实战:千万级数据导出与性能优化指南

1. 项目概述:当MyBatis遇上千万级数据

做后端开发,尤其是处理数据报表、数据导出或者大数据量分析的场景,你肯定遇到过这样的头疼时刻:一个查询需要返回几十万甚至上百万条记录。如果直接用传统的List<T>一次性加载到内存,轻则接口响应缓慢,内存飙升,重则直接OutOfMemoryError,服务挂掉。这时候,MyBatis的流式查询(Streaming Query)就成了你的救命稻草。它允许你像打开一个水龙头一样,从数据库里“流式”地、一条一条地获取数据,而不是把整桶水都先搬到内存里。今天,我就结合自己处理千万级数据导出的实战经验,来彻底拆解MyBatis流式查询的原理、实现、坑点以及最佳实践。无论你是想优化现有的大数据查询接口,还是为即将到来的海量数据处理做准备,这篇内容都能给你一套可直接落地的方案。

2. 流式查询的核心原理与为什么需要它

2.1 传统查询的瓶颈:全量加载之痛

在深入流式查询之前,我们必须先搞清楚传统方式为什么不行。当我们执行一个典型的MyBatis查询,例如:

<select id="selectLargeData" resultType="com.example.User"> SELECT id, name, email FROM user WHERE create_time > #{startTime} </select>

对应的Mapper接口方法返回一个List<User>。MyBatis(或者说底层的JDBC驱动)在执行这个查询时,其默认行为是:一次性将所有匹配的结果集从数据库服务器通过网络传输到应用服务器的内存中,并封装成完整的List对象

这个过程存在几个致命问题:

  1. 内存压力:假设一条User记录在内存中占用1KB,1000万条数据就是约10GB。JVM堆内存很可能无法容纳,直接导致OOM。
  2. 网络与数据库压力:数据库需要一次性准备并发送整个结果集,这期间会长时间占用数据库连接和网络带宽,可能导致数据库响应变慢,影响其他查询。
  3. 响应延迟:应用必须等待所有数据都传输、反序列化完成后,才能开始处理并返回给客户端。用户会经历漫长的等待,体验极差。

2.2 流式查询的工作机制:细水长流

流式查询改变了这个范式。它的核心思想是:保持数据库游标(Cursor)打开,然后让应用像迭代器(Iterator)一样,一次从游标中获取一条或一小批记录进行处理,处理完一条就丢弃一条(或批量处理),内存中始终只保持少量数据。

其背后的技术栈是:

  • JDBC层面:通过Statement.setFetchSize(Integer.MIN_VALUE)(MySQL驱动)或使用ResultSet.TYPE_FORWARD_ONLYCONCUR_READ_ONLY模式,并设置合适的fetchSize,来告诉驱动我们想要流式获取结果。
  • MyBatis层面:提供了Cursor<T>接口作为流式查询的返回类型。Cursor实现了Iterable<T>Iterator<T>,你可以像遍历普通集合一样遍历它,但每次next()调用,才会驱动JDBC从网络连接中获取下一条数据。
  • 数据库层面:以MySQL为例,当使用流式结果集时,数据库服务器会保持结果集(和相关的连接、资源)处于打开状态,等待客户端逐条请求数据。

关键区别类比

  • 传统查询:就像点外卖,餐厅(数据库)必须把所有菜(数据)都做好、打包好,骑手(网络)一次性全部送到你家(内存),你才能开始吃。
  • 流式查询:就像吃回转寿司,厨师(数据库)不断地把做好的寿司(数据)放在传送带(连接)上,你(应用)坐在旁边,看到想吃(需要处理)的就拿下来,吃完盘子就被收走(内存释放)。你永远不需要同时拥有所有的寿司。

2.3 哪些场景必须使用流式查询?

不是所有查询都需要流式。引入流式查询会带来额外的复杂性(如事务和连接管理)。判断标准很简单:

  1. 数据量极大,无法一次性装入内存:这是最直接的信号。当你预计查询结果在数万条以上,且每条记录字段较多、体积较大时,就应该考虑流式。
  2. 需要逐条或分批处理,且处理逻辑可独立:例如数据导出为CSV/Excel文件、数据清洗后写入另一个存储系统(如Elasticsearch、另一个数据库)、实时计算统计指标等。处理完的数据可以立即丢弃或转移。
  3. 需要提供实时或渐进式响应:比如一个大型报表生成,你可以边查询边生成文件,并即时提供下载链接,或者通过WebSocket分批推送数据到前端,提升用户体验。

注意:流式查询并非为了提升“查询速度”。实际上,由于需要保持连接和游标,整个处理过程的总耗时可能比一次性获取更长。它的核心价值在于用时间换空间,以及提供渐进式处理的能力,避免内存瓶颈。

3. MyBatis流式查询的三种实现方式与选型

MyBatis提供了不止一种方式来实现流式查询,每种方式各有优劣和适用场景。理解它们之间的区别,是正确选型的关键。

3.1 方式一:使用Cursor<T>接口(推荐)

这是MyBatis官方最直接、最现代的支持方式。你只需要将Mapper方法的返回值定义为Cursor<T>类型。

定义Mapper接口:

import org.apache.ibatis.cursor.Cursor; public interface UserMapper { Cursor<User> selectLargeDataStream(@Param("startTime") Date startTime); }

编写XML映射:

<select id="selectLargeDataStream" resultType="com.example.User"> SELECT id, name, email, create_time FROM user WHERE create_time > #{startTime} ORDER BY id <!-- 流式查询强烈建议排序,保证顺序和可重复性 --> </select>

服务层调用与遍历:

@Service @Transactional // 事务至关重要! public class DataExportService { @Autowired private UserMapper userMapper; public void exportLargeData(Date startTime, OutputStream outputStream) { try (Cursor<User> cursor = userMapper.selectLargeDataStream(startTime)) { CSVWriter writer = new CSVWriter(new OutputStreamWriter(outputStream)); // 写入表头 writer.writeNext(new String[]{"ID", "Name", "Email", "Create Time"}); for (User user : cursor) { // 这里开始逐条遍历,触发数据获取 // 处理每条数据,例如写入CSV writer.writeNext(new String[]{ String.valueOf(user.getId()), user.getName(), user.getEmail(), user.getCreateTime().toString() }); // 可选:每处理1000条刷新一次输出流,避免内存堆积 if (cursor.getCurrentIndex() % 1000 == 0) { writer.flush(); } } writer.flush(); } catch (IOException e) { throw new RuntimeException("导出失败", e); } // Cursor在try-with-resources中会自动关闭,确保资源释放 } }

为什么推荐这种方式?

  • 语义清晰Cursor<T>类型明确表达了“这是一个流式查询”。
  • 资源管理方便Cursor实现了AutoCloseable,配合try-with-resources语法,可以确保数据库游标和连接被正确关闭,避免资源泄漏。
  • 与Spring事务集成好:在@Transactional注解的方法内使用,可以确保在整个遍历过程中,数据库连接和事务保持一致。

3.2 方式二:使用ResultHandler(更底层控制)

ResultHandler是一个回调接口。MyBatis在从数据库获取到每一行结果时,都会调用这个接口的handleResult方法。这种方式将处理逻辑完全交给开发者,控制粒度最细。

定义ResultHandler:

import org.apache.ibatis.session.ResultHandler; public class UserExportResultHandler implements ResultHandler<User> { private final CSVWriter writer; private int count = 0; public UserExportResultHandler(CSVWriter writer) { this.writer = writer; writer.writeNext(new String[]{"ID", "Name", "Email"}); } @Override public void handleResult(ResultContext<? extends User> resultContext) { User user = resultContext.getResultObject(); // 处理单条记录 writer.writeNext(new String[]{ String.valueOf(user.getId()), user.getName(), user.getEmail() }); count++; if (count % 1000 == 0) { writer.flush(); } // 你甚至可以根据条件停止处理 // if (count > 10000) { // resultContext.stop(); // } } public int getCount() { return count; } }

Mapper接口和XML定义:Mapper接口方法返回值为void,并增加ResultHandler参数。

public interface UserMapper { void selectLargeDataWithHandler(@Param("startTime") Date startTime, ResultHandler<User> handler); }

XML映射文件不需要特殊改动,和普通查询一样。

服务层调用:

@Service @Transactional public class DataExportServiceV2 { @Autowired private UserMapper userMapper; public void exportLargeData(Date startTime, OutputStream outputStream) throws IOException { try (CSVWriter writer = new CSVWriter(new OutputStreamWriter(outputStream))) { UserExportResultHandler handler = new UserExportResultHandler(writer); // 执行查询,结果将通过handler处理 userMapper.selectLargeDataWithHandler(startTime, handler); writer.flush(); System.out.println("共处理数据: " + handler.getCount() + " 条"); } } }

适用场景与注意事项:

  • 优点:绝对的控制权,可以在处理每条数据时做任何事,甚至可以中途停止(resultContext.stop())。
  • 缺点:代码更复杂,需要自己创建和管理ResultHandler实例。资源关闭的逻辑也需要更小心(主要关闭SqlSession)。
  • 适用:当你需要对结果集进行非常复杂的、有状态的逐行处理时。

3.3 方式三:自定义ExecutorTypeREUSEBATCH?(误区澄清)

网上有些资料会提到在SqlSession上设置ExecutorTypeREUSEBATCH来实现“流式”或“批量”效果。这里必须澄清一个常见的误区

  • ExecutorType.SIMPLE:默认执行器。每次执行完语句就关闭Statement对象。
  • ExecutorType.REUSE:复用Statement对象。对于同一模式的SQL(例如多次插入不同参数),可以复用预编译的Statement,提升效率。但它不改变结果集的获取方式
  • ExecutorType.BATCH:批处理执行器。将多个更新操作(INSERT, UPDATE, DELETE)攒在一起,一次性发送给数据库,大幅提升批量写入性能。它只针对更新语句,对SELECT查询无效

结论ExecutorType主要用于优化写入性能,无法实现SELECT查询的流式读取。流式查询的核心在于对ResultSet的处理方式,而不是Statement的执行方式。实现流式查询,必须依靠CursorResultHandler,或者在JDBC层面直接设置fetchSize

3.4 选型决策指南

特性Cursor<T>方式ResultHandler方式
易用性。符合Java迭代器习惯,代码简洁。。需要实现回调接口,代码稍显分散。
控制粒度。可以逐条处理,也能获取当前索引。。可以访问ResultContext,能中途停止、跳过。
资源管理。支持try-with-resources自动关闭。需注意。需要在正确的作用域内确保SqlSession关闭。
与Spring集成。在@Transactional中工作良好。。同样需要事务上下文。
推荐场景绝大多数流式查询场景,如数据导出、批量转换。需要精细控制处理流程提前终止的场景。

对于90%的开发者,首选Cursor<T>方式。它平衡了易用性、安全性和功能性。

4. 流式查询的实战配置、陷阱与深度优化

知道怎么用只是第一步,用得好、不出错才是关键。这部分是真正的干货,来自大量实战踩坑后的总结。

4.1 强制要求:事务管理与连接持有

这是流式查询最核心、也最容易出错的地方。流式查询的本质是保持一个数据库游标打开。而游标是依附于数据库连接(Connection)和事务(Transaction)的。

错误示范:

// 没有事务注解! public void exportData() { Cursor<User> cursor = userMapper.selectLargeDataStream(...); // 遍历cursor... // 问题:方法执行过程中,MyBatis可能会在每次cursor.next()时从连接池获取新连接, // 导致游标所在的连接被关闭,抛出 `Connection is closed` 异常。 }

正确做法:必须确保整个遍历过程在一个数据库事务内,从而保证始终使用同一个物理连接。

@Service public class ExportService { @Transactional // 关键!确保方法在一个事务内执行 public void exportWithTransaction() { try (Cursor<User> cursor = mapper.selectLargeDataStream(...)) { for (User u : cursor) { // 处理数据 } } } }
  • 为什么?@Transactional会为这个方法创建一个事务上下文。Spring会为此上下文绑定一个独立的数据库连接。在整个方法执行期间,所有数据库操作(包括Cursor的遍历)都使用这个连接,游标得以保持。
  • 连接池注意事项:常用的连接池(如HikariCP、Druid)都有连接回收机制。如果没有事务保护,连接可能在Cursor未关闭时就被回收到池中,造成状态混乱。事务阻止了连接被提前归还。

4.2 数据库驱动与FetchSize的奥秘

流式查询的行为高度依赖于JDBC驱动的实现。不同数据库、不同驱动版本,配置可能不同。

1. MySQL (mysql-connector-java):

  • 经典方式Statement.setFetchSize(Integer.MIN_VALUE)。这是告诉MySQL驱动使用流式结果集的“魔法值”。在MyBatis中,可以通过在Mapper XML的<select>标签里配置fetchSize属性来实现。
    <select id="selectLargeDataStream" fetchSize="-2147483648" resultType="..."> SELECT ... </select>
  • 驱动版本的影响:在较新的驱动版本(如8.x)中,仅设置fetchSize为负值可能还不够。你可能还需要在JDBC连接字符串中显式指定使用流式读取:
    spring.datasource.url=jdbc:mysql://localhost:3306/db?useCursorFetch=true
    设置useCursorFetch=true后,fetchSize的正值表示每次从服务器获取的行数,实现了“客户端游标”式的分批流式获取,对服务器更友好。

2. PostgreSQL:PostgreSQL的驱动对流式支持很好。通常只需要设置一个合理的正数fetchSize即可。

<select id="selectLargeDataStream" fetchSize="1000" resultType="..."> SELECT ... </select>

这里fetchSize=1000意味着每次网络往返从服务器获取1000条记录。这是一个平衡内存和网络开销的常用值。

3. Oracle:Oracle JDBC驱动默认就是流式的(fetchSize默认是10)。对于海量数据,你可以根据情况调大fetchSize(比如5000)来减少网络通信次数,但要注意客户端内存。

实操心得fetchSize没有银弹。Integer.MIN_VALUE(MySQL流式)或一个较小的正数(如1000)是安全的起点。对于超大数据量,可以尝试调大fetchSize以减少网络延迟的影响,但务必在测试环境中监控客户端内存使用。一定要查阅你所使用数据库驱动的最新官方文档

4.3 SQL语句的编写禁忌

不是所有SQL都适合流式查询。

  • 必须排序(ORDER BY):流式处理通常意味着顺序处理。如果没有ORDER BY,数据库可能以任意顺序返回数据。在多批次处理或中断重试时,可能导致数据重复或丢失。强烈建议使用一个唯一或递增的字段(如主键ID、创建时间)进行排序
  • 避免大字段(BLOB, TEXT, CLOB):流式查询解决的是“行数多”的问题,而不是“单行数据大”的问题。如果单行记录包含一个几十MB的BLOB字段,即使只流式获取一行,也可能撑爆内存。对于包含大字段的表,考虑分两次查询,或者使用数据库特定的流式读取大对象API。
  • 使用覆盖索引:确保你的WHERE条件和ORDER BY字段能被索引覆盖。流式查询虽然减轻了客户端压力,但数据库服务器仍然需要执行完整的查询。一个全表扫描的流式查询对数据库同样是灾难。使用EXPLAIN分析你的SQL。

4.4 资源泄漏:你必须关闭Cursor!

Cursor背后是打开的数据库ResultSetStatement。如果不关闭,就会导致:

  1. 数据库游标泄漏,消耗服务器资源。
  2. 数据库连接无法及时释放回连接池,可能导致连接池耗尽。

关闭的最佳实践:

// 正确做法1: try-with-resources (Java 7+) try (Cursor<User> cursor = userMapper.selectLargeDataStream(...)) { for (User user : cursor) { // process } } // 无论是否异常,cursor都会自动关闭 // 正确做法2: 在finally块中手动关闭 Cursor<User> cursor = null; try { cursor = userMapper.selectLargeDataStream(...); // ... 遍历处理 } finally { if (cursor != null && !cursor.isClosed()) { cursor.close(); } }

绝对不要在遍历到一半时直接return而不关闭Cursor

4.5 超时与中断处理

流式查询可能运行很长时间。你需要考虑超时和用户中断。

  • 查询超时:可以在MyBatis的<select>标签中设置timeout属性(单位:秒),或者在数据源连接字符串中配置socketTimeout
    <select id="selectLargeDataStream" timeout="300" ...> <!-- 5分钟超时 -->
  • 事务超时:如果你使用了Spring的@Transactional,可以设置事务超时@Transactional(timeout = 300)。注意,这个超时是从事务开始算起,如果事务中还做了其他操作,需要留有余地。
  • 用户中断:在Web应用中,如果用户取消了导出请求,你需要有能力停止正在进行的流式查询。这通常需要:
    1. Cursor的遍历放在一个可中断的线程中。
    2. 提供一个取消接口,该接口设置一个中断标志。
    3. 在遍历循环中定期检查这个中断标志,如果被中断,则调用cursor.close()并退出。 这是一个相对高级的特性,需要结合具体的应用框架(如Spring MVC的DeferredResult)来实现。

5. 性能调优与监控:让千万级查询飞起来

处理千万级数据,光有流式查询还不够,需要一套组合拳。

5.1 分页 vs 流式查询:如何选择?

很多人面对大数据查询,第一反应是“分页”。但分页在处理超大数据量时存在严重问题:

  • 深度分页性能极差LIMIT 1000000, 100这种查询,数据库需要先扫描并跳过前100万条记录,成本极高。
  • 数据一致性风险:如果数据在分页过程中被增删,可能导致某一页数据重复或丢失。

决策指南:

  • 使用流式查询:当你的目的是处理全部数据(如导出、ETL、计算总和),且不需要将全部数据同时呈现给用户时。
  • 使用分页:当你的目的是在UI上展示数据,且用户只需要浏览其中一部分时。对于深度分页,应使用“游标分页”或“seek method”,即WHERE id > last_id LIMIT 100,利用索引避免偏移。

两者结合:有时可以先用流式查询处理数据,将处理结果(如聚合后的统计信息、生成的文件)存储起来,再通过分页提供给用户查看。这是非常成熟的架构模式。

5.2 应用层批处理:减少I/O开销

即使使用流式查询逐条获取,如果逐条写入文件或调用远程接口,I/O效率也会极低。

优化:在应用层做批处理。

try (Cursor<User> cursor = userMapper.selectLargeDataStream(...)) { List<User> buffer = new ArrayList<>(BATCH_SIZE); // 例如 BATCH_SIZE = 1000 for (User user : cursor) { buffer.add(user); if (buffer.size() >= BATCH_SIZE) { // 批量处理:写入文件、插入ES、发送消息等 batchWriteToCSV(buffer, writer); buffer.clear(); writer.flush(); // 定期刷新输出流 } } // 处理最后一批不满 BATCH_SIZE 的数据 if (!buffer.isEmpty()) { batchWriteToCSV(buffer, writer); } }

通过内存缓冲区积累一定数量的记录后再进行批量I/O操作,可以大幅减少系统调用或网络请求的次数,提升整体吞吐量。

5.3 JVM内存与GC优化

流式查询的目标是降低内存压力,但如果处理逻辑不当,仍然可能引起GC问题。

  • 避免在遍历中积累数据:最忌讳在遍历Cursor时,又将所有数据添加到一个新的ArrayList中,这就失去了流式的意义。
  • 及时释放对象引用:对于每一条处理完的记录,确保没有全局的或长时间存活的对象引用它。让垃圾回收器可以及时回收。
  • 调整JVM参数:虽然流式查询降低了堆内存需求,但频繁创建和丢弃大量短期对象(User对象)可能加剧Young GC。可以适当调整新生代大小(-Xmn),并考虑使用G1或ZGC这类低延迟垃圾收集器来应对这种“高分配速率”的场景。

5.4 数据库层面的配合优化

  • 只查询需要的字段SELECT *是万恶之源。明确列出需要的字段,减少网络传输和内存占用。
  • 使用只读事务:对于纯粹的导出查询,可以在Spring事务中设置只读属性@Transactional(readOnly = true)。这会给数据库一个提示,可能触发一些优化。
  • 从库查询:如果业务允许,将这类消耗资源的分析型、导出型查询路由到只读从库,避免影响主库的OLTP事务性能。

6. 常见问题排查与实战案例实录

这里记录了几个我在实际项目中遇到的典型问题及其解决方案。

6.1 问题一:遍历Cursor时抛出“Connection is closed”异常

现象:在for (User user : cursor)循环中,处理到一部分数据后,突然抛出异常,提示数据库连接已关闭。

根因分析

  1. 缺少事务:这是最常见的原因。没有@Transactional注解,MyBatis可能在使用完一次连接后(比如执行完Mapper方法)就将其归还给连接池。当遍历Cursor需要再次读取数据时,使用的可能已经是另一个连接。
  2. 事务传播行为不当:如果方法被另一个没有事务的方法调用,且事务传播行为是REQUIRED(默认),则不会开启新事务。需要检查调用链。
  3. 连接池超时:连接池(如Druid)设置了removeAbandonedTimeoutidleTimeout,长时间未归还的连接被强制回收。流式查询耗时过长触发了这个机制。

解决方案

  1. 确保流式查询的整个遍历过程在一个@Transactional方法内。
  2. 检查并调大连接池的超时参数,确保其大于流式查询处理的最大预估时间。
  3. 对于超长任务,考虑将连接池的testOnBorrowvalidationQuery属性打开,确保取出的连接是有效的。

6.2 问题二:流式查询速度比一次性查询还慢

现象:改用Cursor后,处理完所有数据的总时间反而变长了。

根因分析

  1. 网络往返(Round-Trip)开销:如果fetchSize设置过小(比如默认是1),每获取一条记录都需要一次网络通信,延迟成为主要瓶颈。
  2. 数据库端游标开销:保持游标打开本身对数据库有一定资源消耗,特别是当有大量并发流式查询时。
  3. 客户端处理逻辑过重:如果每处理一条记录都要进行复杂的计算或远程调用,那么I/O等待时间会掩盖流式获取的优势。

解决方案

  1. 调整fetchSize:根据网络状况调整。在局域网内,可以设置为1000甚至更大。使用useCursorFetch=true(MySQL)并设置一个合适的正数fetchSize
  2. 应用层批处理:如前所述,积累一定数量(如1000条)再批量处理,减少I/O次数。
  3. 优化SQL和索引:确保查询本身是高效的。流式解决的是内存问题,不解决慢查询问题。

6.3 问题三:内存使用仍然很高

现象:使用了Cursor,但通过监控发现JVM堆内存使用率依然在持续上升。

根因分析

  1. 内存泄漏:在遍历Cursor时,无意中将处理的对象添加到了某个全局集合(如MapList)中,导致所有对象都无法被GC回收。
  2. 大对象驻留:处理的单条记录中包含大字段(如长文本、Base64图片),即使只存在一条在内存中,也可能占用很大空间。
  3. 框架或驱动缓存:某些ORM框架或JDBC驱动可能有内部缓存机制。

排查与解决

  1. 使用jmap或VisualVM等工具做堆转储分析,查看内存中数量最多的对象是什么。
  2. 审查处理逻辑,确保处理完的对象引用被及时清除。
  3. 对于大字段,考虑在SQL中不查询它们,或者使用数据库特定的流式API来分段读取。

6.4 一个完整的千万级数据导出案例

需求:将过去一年超过2000万的用户订单数据导出为CSV文件。

技术栈:Spring Boot + MyBatis + MySQL + HikariCP

实现步骤:

  1. Mapper定义
    public interface OrderMapper { Cursor<OrderExportDTO> streamOrdersForExport(@Param("startDate") LocalDate startDate, @Param("endDate") LocalDate endDate); }
    <select id="streamOrdersForExport" resultType="OrderExportDTO" fetchSize="-2147483648"> SELECT order_id, user_id, amount, status, create_time FROM orders WHERE create_time BETWEEN #{startDate} AND #{endDate} ORDER BY order_id ASC <!-- 按主键排序,保证顺序且利于数据库扫描 --> </select>
  2. Service层
    @Service @Slf4j public class OrderExportService { private static final int BATCH_SIZE = 2000; @Transactional(readOnly = true, timeout = 7200) // 只读事务,2小时超时 public void exportOrdersToCsv(LocalDate startDate, LocalDate endDate, Path outputPath) throws IOException { long start = System.currentTimeMillis(); try (BufferedWriter writer = Files.newBufferedWriter(outputPath, StandardCharsets.UTF_8); CSVPrinter csvPrinter = new CSVPrinter(writer, CSVFormat.DEFAULT.withHeader(HEADERS)); Cursor<OrderExportDTO> cursor = orderMapper.streamOrdersForExport(startDate, endDate)) { List<OrderExportDTO> batch = new ArrayList<>(BATCH_SIZE); for (OrderExportDTO order : cursor) { batch.add(order); if (batch.size() >= BATCH_SIZE) { writeBatchToCsv(csvPrinter, batch); batch.clear(); csvPrinter.flush(); // 定期刷新缓冲区到磁盘 } } // 处理剩余数据 if (!batch.isEmpty()) { writeBatchToCsv(csvPrinter, batch); } csvPrinter.flush(); } long duration = (System.currentTimeMillis() - start) / 1000; log.info("订单导出完成,耗时: {} 秒", duration); } private void writeBatchToCsv(CSVPrinter printer, List<OrderExportDTO> batch) throws IOException { for (OrderExportDTO order : batch) { printer.printRecord( order.getOrderId(), order.getUserId(), order.getAmount(), order.getStatus(), order.getCreateTime() ); } } }
  3. 关键配置(application.yml)
    spring: datasource: hikari: maximum-pool-size: 20 connection-timeout: 30000 idle-timeout: 600000 # 10分钟,确保长事务连接不被回收 max-lifetime: 1800000 # 30分钟 url: jdbc:mysql://localhost:3306/order_db?useCursorFetch=true&serverTimezone=Asia/Shanghai mybatis: configuration: default-fetch-size: -2147483648 # 全局设置流式获取
  4. 监控与告警:在导出服务中集成Metrics,记录导出速率(行/秒)、内存使用情况,并设置耗时过长或内存异常的告警。

通过这套方案,我们成功将单次导出2000万条订单数据的内存占用从预期的数十GB(如果全量加载)降低到稳定的几百MB(批处理缓冲区),任务总耗时在可控范围内,且对数据库主库的影响降到了最低。

http://www.jsqmd.com/news/1340908/

相关文章:

  • 高管离任、业绩下滑!机构正在撤离山西汾酒
  • 福州豪宅装修公司高端家装服务商综合实力深度测评含专家点评 - 全域品牌推荐
  • 2026年河南PPR一体保温管厂家聚氨酯保温管靠谱**单 - 奔跑123
  • 3分钟学会Blender UV Squares插件:一键让复杂UV变规整网格的完整指南
  • 商用洗地机质保政策哪家强?4个评判标准帮你避坑 - 资讯综合
  • 网络安全渗透测试核心概念与实战工具详解
  • 夜班、外勤、高强度岗位员工心理风险怎么管?这10个行业尤其需要 - 衡识人才测评
  • 你的Agent真的安全吗?说说Prompt注入之外的四大威胁
  • VSCode中Vue3项目红色波浪线终极解决方案:从诊断到根治
  • Unity项目YooAsset缓存清理全攻略:提升开发效率与构建稳定性
  • 宣城婚纱照,3家底片免费送 - 商业信息快查
  • KMS_VL_ALL_AIO:3分钟完成Windows与Office智能激活的终极方案
  • 简单快速指南:如何用Python免费批量下载通达信财务数据
  • UE4/UE5摄像机系统避坑指南:PlayerCameraManager与CameraModifier核心原理与实战配置
  • 2026年7月长沙阳光壹佰靠谱语言迟缓机构最新推荐指南 - 奔跑123
  • 金刚石的量子世界是什么?北睿科技带你了解量子材料新应用
  • 船舶与海工增材制造市场迎来规范化新阶段!中国船级社新指南9月1日生效
  • 2026年京师秦皇岛律所盘点 秦皇岛刑事律所挑选攻略整理 - 小范同学a
  • 保界数值方法:确保物理模拟结果不越界的核心算法突破
  • 赏金女王进阶技巧提高触发善用自旋转减少手动节奏避免紊乱
  • 医师节最美/十佳/优秀医师评选投票活动制作方法,天天评选投票平台功能测评 - 微信投票制作工具
  • Unity VR物体吸附效果实现:从XRI速度追踪到手感调优
  • 如何高效下载加密m3u8视频流:3步完成专业级视频保存
  • 靠谱的高端整卫定制服务商
  • “瑞芯微 iCore-3588Q 核心板:6 TOPS NPU + 8K 编解码,66×50mm 国产 RK3588 计算模块,商规/工规/车规三选一“
  • 佛山家具家装GEO优化:如何让AI优先推荐你的品牌
  • 瑞数六思路
  • C++之list模拟实现
  • Python Pygame贪吃蛇游戏开发:从零实现经典游戏逻辑与图形绘制
  • SpringBoot3+Vue3+MySQL 电子设备保修售后管理系统源码 前后端分离实战