🚀 30+款热门AI模型一站整合,DeepSeek/GLM/Qwen 随心用,限时 5 折。 👉 点击领海量免费额度
最近在排查一个线上服务的内存溢出问题时,发现一个典型的“性能陷阱”:一个看似简单的全量数据导出功能,在数据量增长到百万级别后,服务器内存直接被“打满”,导致服务频繁重启。问题的根源,正是开发者在处理大数据量查询时,习惯性地使用了List<T>来接收数据库结果集。本文将深入剖析这种做法的风险,并手把手教你使用 MyBatis 的流式查询(Streaming Query)来优雅地解决这个问题,让你告别 OOM(OutOfMemoryError)的困扰。
本文适合所有使用 MyBatis 进行数据持久层开发的 Java 开发者,无论你是正在处理报表导出、数据同步,还是任何需要处理大量数据的场景,掌握流式查询都将是你工具箱中的重要利器。
1. 为什么“一行代码”就能挤爆内存?
在深入解决方案之前,我们首先要理解问题是如何产生的。很多开发者会写出类似下面的代码:
// 这是一个典型的风险代码示例 public List<User> exportAllUsers() { // 假设 userMapper.selectList() 会查询出数据库中的所有用户记录 return userMapper.selectList(new QueryWrapper<>()); }这行return userMapper.selectList(...)代码,在数据量小的时候运行良好。但是,当数据量达到十万、百万甚至千万级别时,它就成了一个“内存炸弹”。
1.1 内存消耗的“隐形杀手”
- 数据库驱动层:JDBC 驱动默认会将查询结果完整地加载到客户端内存中的
ResultSet里。 - MyBatis 映射层:MyBatis 会遍历这个
ResultSet,为每一行数据创建一个 Java 对象(如User对象)。 - 集合容器:所有这些对象会被添加到一个
ArrayList中。 - 返回与持有:这个巨大的
List被返回,并在调用栈的某个地方被持有,直到整个处理流程结束。
假设一个User对象占用 1KB 内存(这已经很保守了,实际包含字符串等属性可能更大),那么:
- 10万条数据 ≈ 100 MB
- 100万条数据 ≈ 1 GB
- 1000万条数据 ≈ 10 GB
你的应用堆内存可能只有 2GB 或 4GB。当一次性加载的数据量超过堆内存容量时,JVM 就会抛出java.lang.OutOfMemoryError: Java heap space异常,导致服务崩溃。
1.2 传统分页查询的局限性
你可能会想到使用分页查询:
public List<User> getUsersByPage(int pageNum, int pageSize) { Page<User> page = new Page<>(pageNum, pageSize); return userMapper.selectPage(page, null).getRecords(); }分页对于前端展示是完美的,但对于需要全量数据处理的场景(如导出、计算、同步),它存在明显缺点:
- 性能低下:深度分页(
offset很大)时,数据库需要扫描并跳过大量数据,性能极差。 - 逻辑复杂:需要在业务代码中循环调用分页查询,并处理每页的数据,代码不够简洁。
- 数据一致性:如果在分页查询过程中源数据发生了增删,可能导致数据重复或丢失。
2. 流式查询:化整为零的解决之道
流式查询的核心思想是“边读边处理”。它不像传统查询那样,等所有数据都从数据库传输到应用内存后再统一处理,而是像打开一个水龙头,数据一条一条地“流”过来,处理一条,释放一条,始终保持很低的内存占用。
2.1 核心原理
MyBatis 的流式查询本质上是利用了 JDBC 的ResultSet的TYPE_FORWARD_ONLY和CONCUR_READ_ONLY特性,并结合fetchSize参数的巧妙设置。
TYPE_FORWARD_ONLY:结果集只能向前滚动,这是支持流式读取的基础。CONCUR_READ_ONLY:结果集不可更新。fetchSize:这是关键!它指示 JDBC 驱动每次从数据库网络连接中获取多少行数据到客户端缓冲区。设置为Integer.MIN_VALUE(或某些驱动特定的值)时,会暗示驱动进行流式获取。
当启用流式查询时,MyBatis 会返回一个特殊的“游标”(Cursor)对象。遍历这个游标,就相当于通过一个迭代器,逐条从数据库连接中拉取数据。
2.2 与普通查询的对比
| 特性 | 普通查询 (List<T>) | 流式查询 (Cursor<T>) |
|---|---|---|
| 内存占用 | 高,一次性加载所有结果到内存 | 极低,只保持少量数据在内存中 |
| 适用数据量 | 小到中型数据集(千、万级别) | 中到超大型数据集(十万、百万、千万级别) |
| 响应时间 | 等待所有数据加载完毕后才返回 | 几乎立即返回游标对象,可立即开始处理 |
| 数据库连接占用 | 结果集全部传输完毕后释放 | 在整个遍历处理期间,连接必须保持打开状态 |
| 编程模型 | 简单,直接操作 List | 需使用 try-with-resources 或确保游标关闭 |
| 典型场景 | 业务逻辑处理、前端展示 | 数据导出、批量计算、ETL、数据同步 |
重要警告:使用流式查询时,必须确保在处理完数据后及时关闭游标,以释放数据库连接。否则会导致连接池耗尽,引发更严重的问题。
3. 环境准备与项目搭建
在开始编码前,我们先明确环境。本文示例基于以下主流技术栈:
- JDK: 1.8 或更高版本(推荐 11+)
- Spring Boot: 2.7.x (本文示例)
- MyBatis-Plus: 3.5.x (它是对 MyBatis 的增强,流式查询用法与原生 MyBatis 基本一致)
- 数据库: MySQL 5.7 / 8.0
- 构建工具: Maven
3.1 创建 Spring Boot 项目
你可以通过 Spring Initializr 快速生成项目,选择以下依赖:
- Spring Web
- MyBatis Framework
- MySQL Driver
- Lombok (可选,简化代码)
3.2 项目依赖 (pom.xml)
确保你的pom.xml中包含必要的依赖。关键依赖是mybatis-spring-boot-starter和数据库驱动。
<?xml version="1.0" encoding="UTF-8"?> <project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd"> <modelVersion>4.0.0</modelVersion> <parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>2.7.18</version> <!-- 使用一个稳定的版本 --> <relativePath/> </parent> <groupId>com.example</groupId> <artifactId>mybatis-streaming-demo</artifactId> <version>0.0.1-SNAPSHOT</version> <name>mybatis-streaming-demo</name> <description>Demo project for MyBatis Streaming Query</description> <properties> <java.version>1.8</java.version> <mybatis-plus.version>3.5.5</mybatis-plus.version> </properties> <dependencies> <!-- Spring Boot Web --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <!-- MyBatis Plus Starter (包含了MyBatis) --> <dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-boot-starter</artifactId> <version>${mybatis-plus.version}</version> </dependency> <!-- MySQL Driver --> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <scope>runtime</scope> </dependency> <!-- Lombok --> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <optional>true</optional> </dependency> <!-- Test --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-test</artifactId> <scope>test</scope> </dependency> </dependencies> <build> <plugins> <plugin> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-maven-plugin</artifactId> <configuration> <excludes> <exclude> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> </exclude> </excludes> </configuration> </plugin> </plugins> </build> </project>3.3 数据库与表结构
我们创建一个简单的用户表用于演示。
-- 创建数据库 CREATE DATABASE IF NOT EXISTS `demo_db` DEFAULT CHARACTER SET utf8mb4; USE `demo_db`; -- 创建用户表 CREATE TABLE `t_user` ( `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '主键ID', `name` varchar(100) NOT NULL COMMENT '姓名', `age` int(11) DEFAULT NULL COMMENT '年龄', `email` varchar(100) DEFAULT NULL COMMENT '邮箱', `create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', PRIMARY KEY (`id`), KEY `idx_create_time` (`create_time`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户表'; -- 可以插入一些测试数据,这里我们稍后用程序批量插入百万级数据3.4 应用配置 (application.yml)
配置数据库连接和 MyBatis。
spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/demo_db?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true username: root password: your_password # 请替换为你的数据库密码 # MyBatis-Plus 配置 mybatis-plus: configuration: # 开启下划线转驼峰(默认就是true,显式写出) map-underscore-to-camel-case: true # 控制台打印完整SQL(带参数),方便调试 log-impl: org.apache.ibatis.logging.stdout.StdOutImpl global-config: db-config: id-type: auto # 主键自增 # mapper.xml 文件位置,如果使用注解则不需要 # mapper-locations: classpath*:/mapper/**/*.xml # 日志级别,方便观察SQL执行 logging: level: com.example.demo.mapper: debug4. MyBatis 流式查询核心用法详解
准备工作就绪,现在进入核心环节。我们将通过一个完整的User实体和 Mapper 来演示流式查询。
4.1 定义实体类和 Mapper 接口
首先,创建对应的实体类和 Mapper。
// 文件路径:src/main/java/com/example/demo/entity/User.java package com.example.demo.entity; import com.baomidou.mybatisplus.annotation.IdType; import com.baomidou.mybatisplus.annotation.TableId; import com.baomidou.mybatisplus.annotation.TableName; import lombok.Data; import java.time.LocalDateTime; @Data @TableName("t_user") // 指定表名 public class User { @TableId(type = IdType.AUTO) private Long id; private String name; private Integer age; private String email; private LocalDateTime createTime; }// 文件路径:src/main/java/com/example/demo/mapper/UserMapper.java package com.example.demo.mapper; import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.example.demo.entity.User; import org.apache.ibatis.annotations.Mapper; import org.apache.ibatis.annotations.Select; import org.apache.ibatis.cursor.Cursor; @Mapper public interface UserMapper extends BaseMapper<User> { /** * 传统查询:返回List,有OOM风险 */ // 继承自 BaseMapper 的 selectList 方法即可,这里无需再写 /** * 流式查询:返回 Cursor * 关键:需要在查询方法上使用 @Select 注解,并在方法中返回 Cursor<T> 类型 */ @Select("SELECT * FROM t_user") Cursor<User> selectAllByStream(); }关键点:
- Mapper 接口需要被
@Mapper注解标记,或被@MapperScan扫描。 - 流式查询方法返回类型必须是
org.apache.ibatis.cursor.Cursor<T>。 - 查询语句使用
@Select注解定义。使用 MyBatis-Plus 的 Wrapper 条件构造器进行流式查询稍复杂,通常直接写 SQL 或使用@SelectProvider更清晰。
4.2 实现流式查询服务
接下来,我们创建一个 Service 来使用这个流式 Mapper 方法。这是最容易出错的地方,请特别注意资源管理。
// 文件路径:src/main/java/com/example/demo/service/UserService.java package com.example.demo.service; import com.example.demo.entity.User; import com.example.demo.mapper.UserMapper; import lombok.extern.slf4j.Slf4j; import org.apache.ibatis.cursor.Cursor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import javax.annotation.Resource; @Service @Slf4j public class UserService { @Resource private UserMapper userMapper; /** * 危险的传统查询方法 - 可能导致OOM */ public void processAllUsersTraditional() { long start = System.currentTimeMillis(); // 一次性加载所有用户到内存 List<User> allUsers = userMapper.selectList(null); log.info("共查询到 {} 条用户数据", allUsers.size()); int count = 0; for (User user : allUsers) { // 模拟处理每条数据,比如写入文件、计算等 processSingleUser(user); count++; } long end = System.currentTimeMillis(); log.info("传统方式处理完成,共处理 {} 条数据,耗时 {} ms", count, (end - start)); // 注意:巨大的 allUsers 列表在此方法结束后才会被GC回收,期间一直占用内存 } /** * 安全的流式查询方法 * 关键:必须添加 @Transactional 注解,以保证数据库连接在整个遍历过程中是同一个。 * 如果不在事务内,Cursor 在每次调用 next() 时可能会尝试获取新的连接,导致行为异常或连接泄露。 */ @Transactional(readOnly = true) // 只读事务,对于查询操作是合适的 public void processAllUsersByStream() { long start = System.currentTimeMillis(); // 关键:使用 try-with-resources 确保 Cursor 被关闭 try (Cursor<User> userCursor = userMapper.selectAllByStream()) { int count = 0; for (User user : userCursor) { // 遍历 Cursor // 模拟处理每条数据 processSingleUser(user); count++; // 每处理1000条打印一次日志,观察进度 if (count % 1000 == 0) { log.info("流式处理中,已处理 {} 条记录", count); } } long end = System.currentTimeMillis(); log.info("流式查询处理完成,共处理 {} 条数据,耗时 {} ms", count, (end - start)); } catch (Exception e) { log.error("流式查询处理失败", e); throw new RuntimeException("处理失败", e); } // try-with-resources 会自动调用 cursor.close(),释放数据库连接。 } /** * 模拟处理单条数据的业务逻辑 */ private void processSingleUser(User user) { // 这里可以是任何处理,例如: // 1. 写入本地文件 (FileWriter, BufferedWriter) // 2. 写入消息队列 (Kafka, RabbitMQ) // 3. 调用外部API // 4. 进行数据转换或计算 // 为了演示,我们只做一个简单的耗时操作 try { Thread.sleep(1); // 模拟1ms的处理耗时 } catch (InterruptedException e) { Thread.currentThread().interrupt(); } } }代码解读与核心注意事项:
@Transactional(readOnly = true):这是流式查询能正常工作的关键!它确保整个Cursor的遍历过程在一个数据库事务(连接)中完成。MyBatis 的Cursor实现依赖于一个活跃的 JDBCResultSet,而这个ResultSet又绑定在当前的数据库连接上。如果没有事务,Spring 可能会在每次 Mapper 方法调用后关闭连接,导致遍历失败。- Try-With-Resources:
Cursor实现了Closeable接口。必须使用try-with-resources语法(Java 7+)或在finally块中手动关闭。这是释放数据库连接资源的生命线,忘记关闭会导致连接池迅速耗尽。 - 遍历方式:
Cursor实现了Iterable接口,可以直接用 for-each 循环遍历,也可以使用cursor.iterator()。 - 只读事务:由于是查询操作,使用
readOnly = true可以给数据库一个优化提示,并且在一些配置下可以避免不必要的日志记录。
4.3 添加测试数据并验证
为了看到明显效果,我们需要向数据库插入大量数据。可以写一个简单的初始化方法。
// 文件路径:src/main/java/com/example/demo/runner/DataInitRunner.java package com.example.demo.runner; import com.example.demo.entity.User; import com.example.demo.mapper.UserMapper; import lombok.extern.slf4j.Slf4j; import org.springframework.boot.CommandLineRunner; import org.springframework.stereotype.Component; import javax.annotation.Resource; import java.time.LocalDateTime; import java.util.ArrayList; import java.util.List; import java.util.Random; @Component @Slf4j public class DataInitRunner implements CommandLineRunner { @Resource private UserMapper userMapper; private final Random random = new Random(); private static final int BATCH_SIZE = 5000; // 每批插入数量 private static final int TOTAL_COUNT = 100000; // 目标总数据量,10万条 @Override public void run(String... args) throws Exception { long count = userMapper.selectCount(null); if (count >= TOTAL_COUNT) { log.info("数据库中已有足够数据 ({} 条),跳过初始化。", count); return; } log.info("开始批量插入测试数据,目标 {} 条...", TOTAL_COUNT); long start = System.currentTimeMillis(); int inserted = 0; while (inserted < TOTAL_COUNT) { int batchToInsert = Math.min(BATCH_SIZE, TOTAL_COUNT - inserted); List<User> userList = new ArrayList<>(batchToInsert); for (int i = 0; i < batchToInsert; i++) { User user = new User(); user.setName("User_" + (inserted + i)); user.setAge(20 + random.nextInt(50)); // 20-70岁 user.setEmail("user" + (inserted + i) + "@example.com"); user.setCreateTime(LocalDateTime.now().minusDays(random.nextInt(365))); userList.add(user); } // MyBatis-Plus 的批量插入(非真正的批量SQL,但比循环单条快) for (User user : userList) { userMapper.insert(user); } // 或者使用自定义的批量插入Mapper方法(效率更高) // userMapper.insertBatchSomeColumn(userList); inserted += batchToInsert; log.info("已插入 {} / {} 条数据", inserted, TOTAL_COUNT); } long end = System.currentTimeMillis(); log.info("数据初始化完成,共插入 {} 条数据,耗时 {} ms", inserted, (end - start)); } }最后,创建一个 Controller 来触发我们的处理逻辑。
// 文件路径:src/main/java/com/example/demo/controller/DemoController.java package com.example.demo.controller; import com.example.demo.service.UserService; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; import javax.annotation.Resource; @RestController @RequestMapping("/demo") public class DemoController { @Resource private UserService userService; @GetMapping("/stream") public String processByStream() { userService.processAllUsersByStream(); return "流式查询处理开始,请查看后台日志。"; } @GetMapping("/traditional") public String processByTraditional() { // 警告:数据量大时,此接口很可能导致OOM,请谨慎调用! userService.processAllUsersTraditional(); return "传统查询处理开始,请查看后台日志。"; } }4.4 运行与观察
- 启动你的 Spring Boot 应用。
DataInitRunner会自动运行,插入10万条测试数据。 - 访问
http://localhost:8080/demo/stream。 - 观察控制台日志。你会看到类似下面的输出,数据是分批打印的,说明正在一条条处理:
... (启动日志) 开始批量插入测试数据,目标 100000 条... ... 数据初始化完成,共插入 100000 条数据,耗时 xxxx ms ... 流式处理中,已处理 1000 条记录 流式处理中,已处理 2000 条记录 ... 流式查询处理完成,共处理 100000 条数据,耗时 xxxxx ms同时,你可以使用 JConsole、VisualVM 或 Arthas 等工具监控应用堆内存,会发现内存使用曲线非常平稳,没有出现尖峰。
重要对比: 现在,尝试将TOTAL_COUNT改为一个更大的数,比如 100 万。然后先调用/demo/stream,内存依然平稳。接着,千万不要在同一个 JVM 实例中调用/demo/traditional,因为这几乎必然导致 OOM。你应该在新的 JVM 实例中,用较小的数据量(如 5万条)测试传统方式,观察内存的急剧上升。
5. 深入原理与高级配置
5.1fetchSize的奥秘
流式查询的幕后英雄是 JDBC 的fetchSize参数。它告诉数据库驱动每次从网络连接中获取多少行数据到客户端的缓冲区。
- 默认值:通常为 0,意味着由驱动决定,很多驱动会一次性拉取所有数据。
- 流式设置:设置为
Integer.MIN_VALUE(对于 MySQL 驱动)会启用“逐行流式获取”。其他数据库(如 Oracle、PostgreSQL)可能有不同的特殊值。 - 自定义大小:你也可以设置为一个正数(如 1000),这意味着驱动会每次批量获取1000行。这在网络传输和内存之间做了一个平衡。
在 MyBatis 中,你可以在@Select注解中通过ResultSetType和fetchSize属性来设置,但更常见的做法是在配置文件中全局设置,或者为特定语句配置。
在application.yml中为特定数据源配置默认 fetchSize:
spring: datasource: hikari: >// 在 @Select 注解中设置 @Select(value = "SELECT * FROM t_user", resultSetType = ResultSetType.FORWARD_ONLY, // 结果集类型:仅向前 fetchSize = Integer.MIN_VALUE) // 获取大小 Cursor<User> selectAllByStream();5.2 结合 Wrapper 进行条件流式查询
上面的例子是查询全表。实际业务中,我们往往需要带条件的查询。MyBatis-Plus 的 Wrapper 本身不直接返回Cursor,但我们可以通过@SelectProvider或 XML 动态 SQL 来实现。
方法一:使用@SelectProvider(推荐)
// 1. 创建一个 Provider 类 package com.example.demo.provider; import com.baomidou.mybatisplus.core.conditions.Wrapper; import org.apache.ibatis.annotations.Param; import org.apache.ibatis.jdbc.SQL; public class UserSqlProvider { public String selectByWrapperStream(@Param("ew") Wrapper<User> wrapper) { // 使用 SQL 构建器,避免 SQL 注入 return new SQL(){{ SELECT("*"); FROM("t_user"); // 注意:这里直接将 wrapper 的 getCustomSqlSegment 拼接到 WHERE 条件 // 在简单场景下可以,复杂场景需注意 wrapper 可能包含 entity 条件,需要更复杂的处理 // 更安全的做法是使用 Wrapper 的 getSqlSegment 和 getSqlSelect WHERE(wrapper.getSqlSegment()); }}.toString(); } } // 2. 在 Mapper 接口中使用 import org.apache.ibatis.annotations.SelectProvider; // ... 其他导入 @Mapper public interface UserMapper extends BaseMapper<User> { @SelectProvider(type = UserSqlProvider.class, method = "selectByWrapperStream") Cursor<User> selectByStream(@Param("ew") Wrapper<User> wrapper); } // 3. 在 Service 中使用 @Transactional(readOnly = true) public void processUsersByCondition() { LambdaQueryWrapper<User> wrapper = new LambdaQueryWrapper<>(); wrapper.gt(User::getAge, 18) // 年龄大于18 .likeRight(User::getName, "张"); // 姓张的用户 try (Cursor<User> cursor = userMapper.selectByStream(wrapper)) { for (User user : cursor) { processSingleUser(user); } } }方法二:使用 XML Mapper 文件
<!-- UserMapper.xml --> <?xml version="1.0" encoding="UTF-8"?> <!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd"> <mapper namespace="com.example.demo.mapper.UserMapper"> <!-- 流式查询 --> <select id="selectByConditionStream" resultType="com.example.demo.entity.User" fetchSize="-2147483648" resultSetType="FORWARD_ONLY"> SELECT * FROM t_user <where> <!-- 使用 MyBatis-Plus 的 wrapper 变量 --> ${ew.customSqlSegment} </where> </select> </mapper>// 在 Mapper 接口中声明 Cursor<User> selectByConditionStream(@Param("ew") Wrapper<User> wrapper);5.3 处理过程中的异常与中断
流式查询处理过程可能很长,需要考虑异常处理和中断。
- 异常处理:务必在
try-catch块中捕获异常,并在finally块或 try-with-resources 中确保Cursor被关闭。 - 业务中断:如果处理到一半需要中断(例如用户取消导出),直接
break出循环即可,Cursor会在close()时清理剩余未读取的数据。 - 事务回滚:如果在一个更大的
@Transactional方法中执行流式查询,并且后续步骤失败,整个事务会回滚。但流式查询本身是只读的,通常不会产生需要回滚的写操作。
6. 常见问题与排查思路
在实际使用流式查询时,你可能会遇到以下问题:
| 问题现象 | 可能原因 | 排查与解决思路 |
|---|---|---|
遍历Cursor时抛出Connection is closed异常 | 1. 方法未添加@Transactional。2. 事务传播行为设置不当,导致连接提前关闭。 3. 手动管理连接时未保持连接打开。 | 1.确保流式查询方法有@Transactional注解。2. 检查事务传播属性,确保在整个遍历过程中是同一个事务( PROPAGATION_REQUIRED默认即可)。3. 避免在遍历过程中调用其他会触发事务提交或回滚的方法。 |
| 应用运行一段时间后,数据库连接池耗尽 | Cursor未被正确关闭。 | 1.强制使用 try-with-resources。 2. 检查代码所有分支(异常、return)是否都确保了 cursor.close()被调用。3. 监控连接池使用情况,使用 Druid 等连接池的监控功能。 |
| 流式查询性能比普通查询还慢 | 1. 网络往返次数过多(fetchSize太小)。2. 数据库服务器压力大,保持长连接和游标消耗资源。 3. 单条处理逻辑 ( processSingleUser) 太慢,成为瓶颈。 | 1. 适当调大fetchSize(如 1000),减少网络交互。2. 评估是否真的需要全量流式处理,能否用分页或条件过滤减少数据量。 3. 优化单条数据处理逻辑,或考虑引入批量处理(每积累 N 条处理一次)。 |
| MySQL 下流式查询不生效,内存依然飙升 | MySQL JDBC 驱动默认配置可能未启用真正的流式读取。 | 1. 在 JDBC URL 中添加参数:&useCursorFetch=true。2. 在查询语句或配置中明确设置 fetchSize=Integer.MIN_VALUE。3. 升级到较新的 MySQL Connector/J 驱动版本。 |
| 在 Web 请求中如何使用流式查询导出文件? | 需要将Cursor的遍历和 HTTP 响应输出流结合起来。 | 1. 在 Controller 方法上同样需要@Transactional。2. 获取 HttpServletResponse的OutputStream或Writer。3. 在遍历 Cursor的同时,将每条数据转换为字符串(如 CSV 行)并写入输出流。4.注意:这会将数据库事务和 HTTP 请求绑定,长时间请求会占用连接,需设置合理的超时时间。 |
一个经典的 Web 导出示例片段:
@GetMapping("/export/csv") @Transactional(readOnly = true) // 关键! public void exportUsersToCsv(HttpServletResponse response) throws IOException { response.setContentType("text/csv; charset=utf-8"); response.setHeader("Content-Disposition", "attachment; filename=users.csv"); try (Cursor<User> cursor = userMapper.selectAllByStream(); PrintWriter writer = response.getWriter()) { // 写入 CSV 头 writer.println("ID,Name,Age,Email,CreateTime"); int count = 0; for (User user : cursor) { String line = String.format("%d,%s,%d,%s,%s", user.getId(), escapeCsv(user.getName()), user.getAge(), escapeCsv(user.getEmail()), user.getCreateTime()); writer.println(line); count++; // 可选:定期 flush 缓冲区,让客户端能陆续收到数据 if (count % 1000 == 0) { writer.flush(); } } writer.flush(); log.info("CSV 导出完成,共 {} 条记录", count); } catch (Exception e) { log.error("导出CSV失败", e); // 注意:此处异常可能导致响应已部分写入,需要更精细的错误处理 response.reset(); // 尝试重置响应 response.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, "导出失败"); } } private String escapeCsv(String field) { if (field == null) return ""; // 简单的 CSV 转义:如果包含逗号、双引号或换行,则用双引号包裹,并且内部双引号转义为两个 if (field.contains(",") || field.contains("\"") || field.contains("\n")) { return "\"" + field.replace("\"", "\"\"") + "\""; } return field; }7. 最佳实践与工程建议
- 明确使用场景:流式查询不是银弹。它适用于数据量大、需要全量处理、且处理逻辑可以逐条或分批进行的场景。对于需要随机访问、频繁过滤或聚合计算的数据,传统方式或分页可能更合适。
- 事务边界管理:牢记流式查询必须在一个数据库事务内。设计 Service 方法时,确保
@Transactional注解覆盖整个遍历过程。避免在遍历中调用其他会提交事务的 Service 方法。 - 资源关闭是铁律:使用
try-with-resources是关闭Cursor最安全、最简洁的方式。绝对不要依赖调用者来关闭资源。 - 超时与中断:对于可能运行很长时间的流式处理任务(如导出千万数据),考虑:
- 设置 Statement 或事务的超时时间 (
@Transactional(timeout = 3600))。 - 提供取消机制(例如,将任务放入线程池,支持 Future.cancel)。
- 对于 Web 导出,注意 HTTP 连接超时设置,并确保客户端(浏览器)能接收流式响应。
- 设置 Statement 或事务的超时时间 (
- 性能监控与调优:
- 监控应用内存和数据库连接池使用情况。
- 调整
fetchSize参数以在内存和网络往返之间取得平衡。 - 确保数据库表有合适的索引,避免流式查询时进行全表扫描(虽然流式查询不一次性加载数据,但数据库端的查询效率依然重要)。
- 与连接池的配合:主流的连接池(HikariCP, Druid)都支持配置连接的最大存活时间、空闲超时等。确保这些配置不会干扰长时间运行的流式查询事务。可能需要适当调大
maxLifetime或设置特定的连接属性。 - 测试策略:
- 单元测试:可以使用内存数据库(H2)测试小数据量下的流式查询逻辑是否正确。
- 集成测试:必须在真实或类生产环境的数据库中,用足够大的数据量(至少超过 JVM 堆内存能一次性容纳的量)来验证内存是否真的保持平稳。
- 压力测试:模拟并发流式查询,检查连接池和数据库负载。
流式查询是 MyBatis 提供的一个强大而隐蔽的特性,它能将你的应用从大数据量查询的 OOM 悬崖边拉回来。其核心在于理解“连接、事务、游标”三者生命周期的绑定关系,并严格遵守资源关闭的纪律。通过本文的讲解和示例,希望你能在自己的项目中安全、高效地应用此技术。
在下一篇(下)中,我们将探讨更深入的话题:如何与 Spring 的异步任务@Async结合实现非阻塞处理?如何在分布式环境下安全地使用流式查询?以及如何对超大规模的流式处理进行性能分析和优化。
🚀 30+款热门AI模型一站整合,DeepSeek/GLM/Qwen 随心用,限时 5 折。 👉 点击领海量免费额度