SQL分组查询通常用于统计数据,比如按地区统计订单数量、按用户分组计算消费总额,这类查询在业务数据量较小时可以直接执行后导出结果。但当数据量达到百万甚至千万级别时,分组后的结果集可能依然很大,直接全量导出会占用大量内存,甚至导致应用崩溃,需要采用合适的处理方式优化。

大结果集导出的常见问题
分组后的结果集如果过大,直接导出会触发两类核心问题:
- 内存溢出:全量结果集加载到应用内存中,超过JVM堆内存上限会直接抛出OutOfMemoryError
- 查询超时:数据库执行分组查询本身耗时较长,加上传输大量数据的时间,很容易超过数据库或应用的超时阈值
方案一:流式查询处理大结果集
流式查询是指数据库逐行返回结果,应用边接收边处理,不需要把全部结果加载到内存中,适合结果集持续输出、不需要随机访问的场景。
流式查询的实现方式
以MySQL和Java的JDBC为例,开启流式查询需要设置两个参数:
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
public class StreamQueryExport {
public static void exportGroupResult() {
String url = "jdbc:mysql://127.0.0.1:3306/test_db?useCursorFetch=true";
String sql = "SELECT region, COUNT(*) as order_count, SUM(amount) as total_amount FROM orders GROUP BY region";
try (Connection conn = DriverManager.getConnection(url, "root", "password");
PreparedStatement ps = conn.prepareStatement(sql)) {
// 设置fetchSize为Integer.MIN_VALUE开启流式查询
ps.setFetchSize(Integer.MIN_VALUE);
ResultSet rs = ps.executeQuery();
// 逐行处理结果,边读边写导出文件
while (rs.next()) {
String region = rs.getString("region");
int orderCount = rs.getInt("order_count");
double totalAmount = rs.getDouble("total_amount");
// 此处省略写入导出文件的逻辑
System.out.println(region + "," + orderCount + "," + totalAmount);
}
} catch (SQLException e) {
e.printStackTrace();
}
}
}
需要注意的是,流式查询过程中数据库连接会一直被占用,直到结果全部处理完成,期间不能执行其他操作。
流式查询的适用场景
- 结果集需要按顺序逐行处理,不需要跳页访问
- 导出文件是顺序写入,不需要先汇总所有结果再做处理
- 数据量极大,内存完全无法容纳全量结果
方案二:分页处理大结果集
分页处理是把分组后的结果集分成多页,每次只查询一页的数据,处理完后再查询下一页,适合需要支持跳页、或者导出过程可以中断重试的场景。
分页查询的实现方式
MySQL中可以使用LIMIT offset, pageSize实现分页,但是分组后的分页需要注意offset过大会导致查询变慢,建议结合排序字段优化。
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
public class PageQueryExport {
public static void exportGroupResultByPage() {
String url = "jdbc:mysql://127.0.0.1:3306/test_db";
int pageSize = 1000;
int currentPage = 0;
boolean hasMore = true;
try (Connection conn = DriverManager.getConnection(url, "root", "password")) {
while (hasMore) {
int offset = currentPage * pageSize;
String sql = "SELECT region, COUNT(*) as order_count, SUM(amount) as total_amount " +
"FROM orders GROUP BY region ORDER BY region LIMIT ? OFFSET ?";
try (PreparedStatement ps = conn.prepareStatement(sql)) {
ps.setInt(1, pageSize);
ps.setInt(2, offset);
ResultSet rs = ps.executeQuery();
int count = 0;
while (rs.next()) {
String region = rs.getString("region");
int orderCount = rs.getInt("order_count");
double totalAmount = rs.getDouble("total_amount");
// 此处省略写入导出文件的逻辑
System.out.println(region + "," + orderCount + "," + totalAmount);
count++;
}
// 如果当前页返回的数据少于pageSize,说明已经是最后一页
if (count < pageSize) {
hasMore = false;
} else {
currentPage++;
}
}
}
} catch (SQLException e) {
e.printStackTrace();
}
}
}
分页查询的适用场景
- 需要支持跳页查看分组结果,或者导出过程中可以暂停后从指定页继续
- 分组查询本身耗时较短,分页查询的额外开销可以接受
- 导出逻辑需要分段处理,每页处理完成后可以做校验或落库
两种方案的对比与选择
可以通过以下维度选择合适的技术方案:
| 对比维度 | 流式查询 | 分页处理 |
|---|---|---|
| 内存占用 | 极低,仅需缓存单行数据 | 中等,缓存一页数据 |
| 数据库连接占用 | 全程占用,直到结果处理完成 | 每页查询时占用,页处理完可释放 |
| 查询性能 | 单次查询,性能稳定 | offset过大时性能下降 |
| 灵活性 | 仅支持顺序处理 | 支持跳页、分段重试 |
如果分组后的结果集需要顺序导出,且导出过程不需要中断,优先选择流式查询;如果需要支持跳页、或者导出过程可能中断需要重试,优先选择分页处理。另外如果数据库支持游标查询,也可以结合游标实现类似流式的效果,同时避免长时间占用连接。