大数据量查询优化
传统查询会将全部结果加载到内存,百万级数据导出时导致 OOM。MyBatis 提供流式查询和游标机制,实现逐行处理,内存占用恒定。
传统查询的内存瓶颈
Java
// 危险:100 万条数据全部加载到内存
List<Order> orders = orderMapper.selectAll();
for (Order order : orders) {
process(order);
}
| 数据量 | 内存占用 | 表现 |
|---|---|---|
| 1,000 条 | ~1 MB | 无影响 |
| 100,000 条 | ~100 MB | 可接受 |
| 1,000,000 条 | ~1 GB | GC 频繁,响应慢 |
| 10,000,000 条 | ~10 GB | OOM 崩溃 |
传统
List<T>查询的内存占用与数据量成正比,数据量超过百万时极易触发 OOM。
流式查询 (ResultHandler)
基本用法
Java
public interface OrderMapper {
// 使用 ResultHandler 流式处理结果
void selectAllWithHandler(ResultHandler<Order> handler);
}
XML
<select id="selectAllWithHandler" resultType="Order"
resultSetType="FORWARD_ONLY" fetchSize="1000">
SELECT * FROM order
</select>
Java
public void exportOrders() throws IOException {
mapper.selectAllWithHandler(context -> {
Order order = (Order) context.getResultObject();
writeToFile(order);
});
}
| 参数 | 说明 |
|---|---|
resultSetType="FORWARD_ONLY" | 只进游标,不支持回滚 |
fetchSize="1000" | 每次从数据库获取 1000 条,控制网络往返次数 |
ResultHandler | 逐行回调处理函数,不缓存结果到内存 |
流式导出 CSV
Java
public void exportToCsv(String outputPath) throws IOException {
try (BufferedWriter writer = new BufferedWriter(
new OutputStreamWriter(new FileOutputStream(outputPath), "UTF-8"))) {
// 写入表头
writer.write("id,order_no,amount,create_time\n");
mapper.selectAllWithHandler(context -> {
Order order = (Order) context.getResultObject();
try {
writer.write(String.format("%d,%s,%.2f,%s\n",
order.getId(), order.getOrderNo(), order.getAmount(), order.getCreateTime()));
} catch (IOException e) {
throw new RuntimeException(e);
}
});
writer.flush();
}
}
流式查询内存占用恒定(仅保存 fetchSize 指定的批次数据),适合导出、统计等全表扫描场景。
Cursor 游标查询
基本用法
Java
public interface OrderMapper {
// 返回 Cursor,支持 try-with-resources
Cursor<Order> selectAllAsCursor();
}
XML
<select id="selectAllAsCursor" resultType="Order"
resultSetType="FORWARD_ONLY" fetchSize="1000">
SELECT * FROM order
</select>
Java
public void processOrders() {
try (Cursor<Order> cursor = mapper.selectAllAsCursor()) {
for (Order order : cursor) {
process(order);
}
} catch (IOException e) {
log.error("Cursor processing error", e);
}
}
Cursor vs ResultHandler
| 维度 | Cursor | ResultHandler |
|---|---|---|
| 返回值 | Cursor<T> 迭代器 | void,回调处理 |
| 遍历方式 | for-each / iterator | 回调函数 |
| 异常处理 | try-with-resources | 回调内部处理 |
| 流控制 | 外部控制,可 break/continue | 内部驱动,全部处理 |
| 适用场景 | 需要中途退出或跳过 | 全量处理,不可中断 |
| 事务管理 | 需保持在同一事务 | 自动在查询事务内 |
分页查询优化
传统 offset 分页的问题
XML
<!-- 深度分页:LIMIT 1000000, 20 需要扫描前 100 万行 -->
<select id="selectByPage" resultType="Order">
SELECT * FROM order LIMIT #{offset}, #{limit}
</select>
offset 越大,数据库需要扫描的行数越多,查询时间线性增长。
游标分页(基于索引)
XML
<!-- 基于自增 ID 的游标分页 -->
<select id="selectByCursor" resultType="Order">
SELECT * FROM order
WHERE id > #{lastId}
ORDER BY id ASC
LIMIT #{limit}
</select>
Java
public void processAllOrders() {
Long lastId = 0L;
int limit = 5000;
List<Order> batch;
do {
batch = mapper.selectByCursor(lastId, limit);
for (Order order : batch) {
process(order);
lastId = order.getId();
}
} while (batch.size() == limit);
}
| 分页方式 | 第 1 页耗时 | 第 10000 页耗时 | 扫描行数 |
|---|---|---|---|
| offset 分页 | 10 ms | 5000 ms | 100 万 + |
| 游标分页 | 10 ms | 10 ms | 5000 |
游标分页基于索引查找,无论数据深度多少页,扫描行数恒等于 LIMIT 值。
批量处理策略
ResultHandler + 分批提交
Java
public void batchProcessOrders() {
final int batchSize = 1000;
final List<Order> buffer = new ArrayList<>(batchSize);
mapper.selectAllWithHandler(context -> {
buffer.add((Order) context.getResultObject());
if (buffer.size() >= batchSize) {
processBatch(buffer);
buffer.clear();
}
});
// 处理剩余数据
if (!buffer.isEmpty()) {
processBatch(buffer);
}
}
Cursor + 并行处理
Java
public void parallelProcessOrders() throws Exception {
ForkJoinPool pool = new ForkJoinPool(4);
try (Cursor<Order> cursor = mapper.selectAllAsCursor()) {
Iterator<Order> iterator = cursor.iterator();
// 分批读取 + 并行处理
List<Order> batch = new ArrayList<>(1000);
while (iterator.hasNext()) {
batch.add(iterator.next());
if (batch.size() >= 1000) {
pool.submit(() -> processBatch(new ArrayList<>(batch)));
batch.clear();
}
}
if (!batch.isEmpty()) {
pool.submit(() -> processBatch(batch));
}
}
pool.shutdown();
pool.awaitTermination(1, TimeUnit.HOURS);
}
大数据量查询配置对比
| 配置 | 传统查询 | 流式查询 | 游标查询 |
|---|---|---|---|
| 返回值 | List<T> | void + 回调 | Cursor<T> |
| 内存占用 | O(N) | O(fetchSize) | O(fetchSize) |
| 事务范围 | 整个查询 | 整个查询 | 整个查询 |
| 流控制 | 无 | 无(全部回调) | 可 break/continue |
| 适用场景 | 小数据量 | 全量导出/统计 | 逐行处理 |
| 百万数据内存 | ~1 GB | ~1 MB | ~1 MB |
关键配置说明
fetchSize 调优
| fetchSize | 网络往返次数 (100 万条) | 内存占用 | 建议场景 |
|---|---|---|---|
| 默认 (0) | 1 次 | 全部加载 | 小数据量 |
| 100 | 10,000 次 | ~1 MB | 低内存环境 |
| 1000 | 1,000 次 | ~10 MB | 通用场景 |
| 5000 | 200 次 | ~50 MB | 高性能要求 |
fetchSize 不是越大越好,过大导致单次网络传输延迟增加;过小导致网络往返频繁。
数据库连接超时处理
Java
public void longRunningQuery() {
SqlSession session = sqlSessionFactory.openSession();
try {
// 设置查询超时时间
session.getConfiguration()
.getDefaultStatementTimeout(); // 可在 settings 中配置
mapper.selectAllWithHandler(context -> {
// 长时间处理逻辑
process((Order) context.getResultObject());
});
session.commit();
} finally {
session.close();
}
}
要点总结
- 传统
List<T>查询全量加载到内存,百万级数据导致 OOM。 ResultHandler流式查询逐行回调处理,内存占用恒定为 O(fetchSize)。Cursor游标返回迭代器,支持 try-with-resources 和流控制(break/continue)。- 深度分页问题使用游标分页(WHERE id > #{lastId})替代 offset 分页。
- fetchSize 控制每次从数据库拉取的行数,1000 是通用场景的平衡值。
- 批量处理场景结合 ResultHandler + 缓冲队列实现分批提交。
- 大数据量导出必须使用流式查询,避免一次性加载全部结果到内存。
📝 发现内容有误?点击此处直接编辑