想象一下你要从水库往家里运水。如果一次性把整个水库的水都装进卡车,不仅车会被压垮,路上也会一片狼藉。JDBC的默认查询方式就是这样——它会一次性把所有查询结果加载到内存里。当处理百万级数据时,这就像用茶杯去接消防水管的水,结果可想而知。
我在去年处理一个用户行为分析项目时就踩过这个坑。当时查询300万条记录,JVM直接OOM崩溃。后来发现PostgreSQL的JDBC驱动默认会缓存所有结果,相当于在内存里复制了整个表。这就是为什么我们需要setFetchSize(50)这样的设置,它告诉驱动:"每次只给我50条数据,就像用桶分批运水"。
但这里有个关键细节:setAutoCommit(false)必须配合使用。因为事务提交后数据库游标可能关闭,就像运水时如果中途关了阀门,后续就没水可接了。MySQL和PostgreSQL在这方面的表现略有不同,MySQL需要额外配置useCursorFetch=true参数。
我见过最典型的错误代码是这样的:
java复制List<User> users = new ArrayList<>();
while(rs.next()) {
users.add(new User(rs.getInt("id"), rs.getString("name")));
}
return users;
表面上看用了setFetchSize,实际上还是在内存中累积所有对象。就像把运来的水全部倒进浴缸,浴缸满了照样会溢出。这种做法的内存消耗曲线是条直线上升的斜线,直到触发OOM。
另一种情况是使用了Stream API但未及时消费:
java复制return Stream.generate(() -> {
if(rs.next()) return new User(rs.getInt("id"), rs.getString("name"));
return null;
}).takeWhile(Objects::nonNull);
这种写法看起来优雅,但如果调用方用collect(Collectors.toList()),还是回到了老问题。就像用管道运水却把出水口堵住,管道最终会爆裂。
当需要生成大型报表时,我推荐这种模式:
java复制try(FileWriter writer = new FileWriter("report.csv")) {
writer.write("id,name\n");
while(rs.next()) {
writer.write(rs.getInt("id") + "," +
escapeCsv(rs.getString("name")) + "\n");
if(rowsProcessed++ % 1000 == 0) {
writer.flush(); // 定期刷新缓冲区
}
}
}
关键点在于:
我在电商订单导出功能中应用此方案,处理100万订单时内存稳定在50MB以内。
对于API返回大量数据的情况,Spring Boot中可以这样实现:
java复制@GetMapping("/users")
public StreamingResponseBody getUsers() {
return output -> {
try(Connection conn = dataSource.getConnection();
Statement stmt = conn.createStatement()) {
stmt.setFetchSize(100);
ResultSet rs = stmt.executeQuery("SELECT * FROM users");
JsonGenerator generator = new ObjectMapper()
.getFactory()
.createGenerator(output);
generator.writeStartArray();
while(rs.next()) {
generator.writeStartObject();
generator.writeNumberField("id", rs.getInt("id"));
generator.writeStringField("name", rs.getString("name"));
generator.writeEndObject();
}
generator.writeEndArray();
}
};
}
这种方案的特点:
JVM层面:给ResultSet处理线程设置独立的较小内存池
java复制ExecutorService executor = Executors.newFixedThreadPool(1,
r -> new Thread(null, r, "db-stream", 256*1024)); // 256KB栈内存
数据库层面:优化查询只返回必要字段
sql复制-- 反例
SELECT * FROM large_table;
-- 正例
SELECT id, name FROM large_table WHERE create_time > ?
框架层面:Spring Data JPA中可配置:
properties复制spring.jpa.properties.hibernate.jdbc.batch_size=50
spring.jpa.properties.hibernate.jdbc.fetch_size=50
流式处理必须考虑中断场景:
java复制try {
while(rs.next()) {
if(Thread.currentThread().isInterrupted()) {
throw new RuntimeException("Processing interrupted");
}
// 处理逻辑
}
} finally {
// 确保关闭顺序从内到外
IOUtils.closeQuietly(rs);
IOUtils.closeQuietly(stmt);
IOUtils.closeQuietly(conn);
}
在我的基准测试中(100万记录,每个对象约1KB):
| 处理方式 | 内存峰值 | 耗时 | GC次数 |
|---|---|---|---|
| 传统List加载 | 2.1GB | 12.3s | 38 |
| 真流式(文件) | 52MB | 15.7s | 2 |
| 真流式(网络) | 48MB | 17.2s | 2 |
虽然流式处理稍慢,但内存表现是数量级的优势。当数据量达到千万级时,传统方式根本跑不起来,而流式方案依然稳定。
MySQL:
useCursorFetch=true参数statement.setFetchSize(Integer.MIN_VALUE)Oracle:
TYPE_FORWARD_ONLY和CONCUR_READ_ONLY组合OracleResultSet.CURSOR_READ_ONLYSQL Server:
responseBuffering=adaptiveselectMethod=cursor这些差异源于各数据库驱动对JDBC规范的不同实现。在我参与的跨数据库项目中,我们抽象了一个流式处理适配层来统一这些细节。
使用VisualVM观察内存变化时,注意:
在Linux环境下可以用:
bash复制watch -n 1 'ps -p <pid> -o rss,vsz'
实时监控进程内存变化
日志中应记录处理进度:
java复制if(rowsProcessed % 10000 == 0) {
log.info("Processed {} rows", rowsProcessed);
}
流式处理就像操作流水线,重点在于保持流动畅通。当看到内存曲线像心跳图一样规律波动时,说明你的流式处理系统已经健康运行了。