大数据量导出:流式查询避免 OOM
·
前言
“运营又要导出100万条订单数据,系统又崩溃了!”、“每次导出大量数据都会出现 OutOfMemoryError”、“分页导出太慢,有没有更好的方案?”
在后台管理系统中,大数据量导出是一个常见的需求,但也是最容易出现问题的地方。一次性查询百万级数据到内存,然后生成Excel,往往会导致OOM(Out Of Memory)崩溃。
本文将详细介绍如何使用流式查询 + 流式写入Excel的方式,优雅地解决大数据量导出问题,让你的系统能够稳定导出百万级数据。
一、问题分析
1.1 传统导出方式的问题
传统导出方式的问题:
- 内存占用过高:
- 一次性查询100万条数据到内存
- 每条数据占用内存,加上对象开销
- 假设每条数据1KB,100万条就是1GB
- 加上Excel生成时的临时对象,内存可能达到数GB
- 响应时间过长:
- 查询时间:数据库查询100万条数据需要时间
- 传输时间:数据从数据库传输到应用服务器
- 生成时间:生成Excel需要时间
- 总时间可能超过5分钟
- 系统资源耗尽:
- CPU占用高:处理大量数据
- 内存占用高:导致OOM
- 数据库连接占用高:长时间占用连接
- 用户体验差:
- 导出时间长,用户不知道进度
- 系统崩溃,用户无法获得数据
- 导出失败,需要重新操作
1.2 传统导出代码示例
/**
* 传统导出方式(不推荐)
* 问题:一次性查询所有数据到内存,导致OOM
*/
public class OrderExportService {
@Autowired
private OrderMapper orderMapper;
/**
* 导出所有订单(传统方式)
*/
public void exportAllOrders(HttpServletResponse response) throws IOException {
// 问题1:一次性查询所有订单
List<Order> orders = orderMapper.selectAll();
// 问题2:全部加载到内存
// 假设100万条数据,每条1KB,就是1GB内存
// 加上对象开销,可能达到2-3GB
// 问题3:生成Excel时需要更多内存
ExcelWriter excelWriter = EasyExcel.write(response.getOutputStream(), Order.class).build();
excelWriter.write(orders);
excelWriter.finish();
}
}
执行结果:
java.lang.OutOfMemoryError: Java heap space
at com.example.OrderExportService.exportAllOrders(OrderExportService.java:25)
二、解决方案:流式查询 + 流式写入
2.1 核心思路
核心思路:
- 流式查询:
- 使用数据库游标或分页查询
- 每次只查询少量数据(如1000条)
- 逐批处理,不一次性加载所有数据
- 流式写入:
- 使用EasyExcel的流式写入
- 逐条写入Excel,不占用大量内存
- 实时输出到HTTP响应流
- 分批处理:
- 控制每批数据的大小
- 处理完一批后释放内存
- 继续处理下一批
2.2 技术选型
技术选型:
- 数据库查询:
- MyBatis游标:使用Cursor方式流式查询
- MyBatis分页:使用PageHelper分页查询
- JPA流式查询:使用Stream方式查询
- Excel写入:
- EasyExcel:阿里巴巴开源,支持流式写入
- FastExcel:高性能Excel库,支持流式处理
- 响应输出:
- HttpServletResponse:直接写入响应流
- 实时输出:边查询边写入,减少内存占用
三、MyBatis游标查询
3.1 MyBatis游标配置
/**
* MyBatis游标查询配置
*/
public interface OrderMapper {
/**
* 游标查询所有订单
* 使用Cursor方式,流式查询
*/
@Select("SELECT * FROM orders ORDER BY id")
@Options(resultSetType = ResultSetType.FORWARD_ONLY, fetchSize = Integer.MIN_VALUE)
Cursor<Order> selectAllWithCursor();
/**
* 游标查询订单(带条件)
*/
@Select("SELECT * FROM orders WHERE status = #{status} ORDER BY id")
@Options(resultSetType = ResultSetType.FORWARD_ONLY, fetchSize = Integer.MIN_VALUE)
Cursor<Order> selectByStatusWithCursor(@Param("status") String status);
/**
* XML配置方式
*/
Cursor<Order> selectAllCursor();
}
Mapper 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.mapper.OrderMapper">
<!-- 游标查询 -->
<select id="selectAllCursor" resultType="com.example.entity.Order"
resultSetType="FORWARD_ONLY" fetchSize="1000">
SELECT * FROM orders ORDER BY id
</select>
</mapper>
3.2 游标查询示例
/**
* 游标查询示例
*/
@Service
public class OrderQueryService {
@Autowired
private OrderMapper orderMapper;
/**
* 游标查询订单
*/
public void queryWithCursor() {
try (Cursor<Order> cursor = orderMapper.selectAllWithCursor()) {
cursor.forEach(order -> {
// 逐条处理订单
processOrder(order);
});
}
}
/**
* 游标查询并导出
*/
public void exportWithCursor(HttpServletResponse response) throws IOException {
// 设置响应头
response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
response.setCharacterEncoding("utf-8");
String fileName = URLEncoder.encode("订单导出.xlsx", "UTF-8").replaceAll("\\+", "%20");
response.setHeader("Content-disposition", "attachment;filename*=utf-8''" + fileName);
// 创建ExcelWriter(流式写入)
ExcelWriter excelWriter = EasyExcel.write(response.getOutputStream(), Order.class).build();
try (Cursor<Order> cursor = orderMapper.selectAllWithCursor()) {
// 逐条写入Excel
cursor.forEach(order -> {
excelWriter.write(order);
});
}
excelWriter.finish();
}
/**
* 处理订单
*/
private void processOrder(Order order) {
// 业务逻辑
System.out.println("处理订单: " + order.getId());
}
}
四、MyBatis分页查询
4.1 分页查询配置
/**
* MyBatis分页查询配置
*/
public interface OrderMapper {
/**
* 分页查询订单
*/
@Select("SELECT * FROM orders ORDER BY id LIMIT #{offset}, #{pageSize}")
List<Order> selectByPage(@Param("offset") int offset, @Param("pageSize") int pageSize);
/**
* 统计订单总数
*/
@Select("SELECT COUNT(*) FROM orders")
long count();
}
4.2 分页查询示例
/**
* 分页查询导出示例
*/
@Service
public class OrderExportService {
@Autowired
private OrderMapper orderMapper;
private static final int BATCH_SIZE = 1000; // 每批1000条
/**
* 分页查询导出
*/
public void exportByPage(HttpServletResponse response) throws IOException {
// 设置响应头
response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
response.setCharacterEncoding("utf-8");
String fileName = URLEncoder.encode("订单导出.xlsx", "UTF-8").replaceAll("\\+", "%20");
response.setHeader("Content-disposition", "attachment;filename*=utf-8''" + fileName);
// 创建ExcelWriter(流式写入)
ExcelWriter excelWriter = EasyExcel.write(response.getOutputStream(), Order.class).build();
// 查询总数
long total = orderMapper.count();
int totalPages = (int) Math.ceil((double) total / BATCH_SIZE);
// 分页查询并写入
for (int pageNum = 0; pageNum < totalPages; pageNum++) {
int offset = pageNum * BATCH_SIZE;
List<Order> orders = orderMapper.selectByPage(offset, BATCH_SIZE);
// 写入Excel
excelWriter.write(orders);
// 释放内存
orders.clear();
}
excelWriter.finish();
}
}
五、完整实现方案
5.1 实体类
package com.example.entity;
import com.alibaba.excel.annotation.ExcelIgnore;
import com.alibaba.excel.annotation.ExcelProperty;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/**
* 订单实体
*/
@Data
public class Order {
@ExcelProperty("订单ID")
private Long id;
@ExcelProperty("订单号")
private String orderNo;
@ExcelProperty("用户ID")
private Long userId;
@ExcelProperty("用户名")
private String username;
@ExcelProperty("商品名称")
private String productName;
@ExcelProperty("商品数量")
private Integer quantity;
@ExcelProperty("订单金额")
private BigDecimal amount;
@ExcelProperty("订单状态")
private String status;
@ExcelProperty("创建时间")
private LocalDateTime createTime;
@ExcelProperty("支付时间")
private LocalDateTime payTime;
@ExcelIgnore // 忽略不需要导出的字段
private String remark;
}
5.2 Mapper接口
package com.example.mapper;
import com.example.entity.Order;
import org.apache.ibatis.annotations.*;
import org.apache.ibatis.cursor.Cursor;
import org.apache.ibatis.mapping.ResultSetType;
import java.util.List;
/**
* 订单Mapper
*/
@Mapper
public interface OrderMapper {
/**
* 游标查询所有订单
*/
@Select("SELECT * FROM orders ORDER BY id")
@Options(resultSetType = ResultSetType.FORWARD_ONLY, fetchSize = Integer.MIN_VALUE)
Cursor<Order> selectAllWithCursor();
/**
* 游标查询订单(带条件)
*/
@Select("SELECT * FROM orders WHERE status = #{status} ORDER BY id")
@Options(resultSetType = ResultSetType.FORWARD_ONLY, fetchSize = Integer.MIN_VALUE)
Cursor<Order> selectByStatusWithCursor(@Param("status") String status);
/**
* 分页查询订单
*/
@Select("SELECT * FROM orders ORDER BY id LIMIT #{offset}, #{pageSize}")
List<Order> selectByPage(@Param("offset") int offset, @Param("pageSize") int pageSize);
/**
* 统计订单总数
*/
@Select("SELECT COUNT(*) FROM orders")
long count();
/**
* 统计订单总数(带条件)
*/
@Select("SELECT COUNT(*) FROM orders WHERE status = #{status}")
long countByStatus(@Param("status") String status);
}
5.3 Service实现
package com.example.service;
import com.alibaba.excel.EasyExcel;
import com.alibaba.excel.ExcelWriter;
import com.example.entity.Order;
import com.example.mapper.OrderMapper;
import lombok.extern.slf4j.Slf4j;
import org.apache.ibatis.cursor.Cursor;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.web.bind.annotation.RequestParam;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.net.URLEncoder;
import java.util.List;
/**
* 订单导出服务
*/
@Slf4j
@Service
public class OrderExportService {
@Autowired
private OrderMapper orderMapper;
/**
* 导出所有订单(游标方式)
*/
public void exportAllOrders(HttpServletResponse response) throws IOException {
log.info("开始导出所有订单");
long startTime = System.currentTimeMillis();
// 设置响应头
setResponseHeaders(response, "订单导出.xlsx");
// 创建ExcelWriter(流式写入)
ExcelWriter excelWriter = EasyExcel.write(response.getOutputStream(), Order.class).build();
int count = 0;
try (Cursor<Order> cursor = orderMapper.selectAllWithCursor()) {
cursor.forEach(order -> {
excelWriter.write(order);
count++;
// 每10000条记录一次日志
if (count % 10000 == 0) {
log.info("已导出 {} 条订单", count);
}
});
}
excelWriter.finish();
long endTime = System.currentTimeMillis();
log.info("订单导出完成,共导出 {} 条,耗时 {} 秒",
count, (endTime - startTime) / 1000);
}
/**
* 导出订单(带条件)
*/
public void exportOrdersByStatus(HttpServletResponse response, String status) throws IOException {
log.info("开始导出订单,状态: {}", status);
long startTime = System.currentTimeMillis();
// 设置响应头
setResponseHeaders(response, "订单导出_" + status + ".xlsx");
// 创建ExcelWriter
ExcelWriter excelWriter = EasyExcel.write(response.getOutputStream(), Order.class).build();
int count = 0;
try (Cursor<Order> cursor = orderMapper.selectByStatusWithCursor(status)) {
cursor.forEach(order -> {
excelWriter.write(order);
count++;
if (count % 10000 == 0) {
log.info("已导出 {} 条订单", count);
}
});
}
excelWriter.finish();
long endTime = System.currentTimeMillis();
log.info("订单导出完成,共导出 {} 条,耗时 {} 秒",
count, (endTime - startTime) / 1000);
}
/**
* 分页导出订单
*/
public void exportOrdersByPage(HttpServletResponse response, int pageSize) throws IOException {
log.info("开始分页导出订单,每页大小: {}", pageSize);
long startTime = System.currentTimeMillis();
// 设置响应头
setResponseHeaders(response, "订单导出.xlsx");
// 创建ExcelWriter
ExcelWriter excelWriter = EasyExcel.write(response.getOutputStream(), Order.class).build();
// 查询总数
long total = orderMapper.count();
int totalPages = (int) Math.ceil((double) total / pageSize);
log.info("订单总数: {}, 总页数: {}", total, totalPages);
int count = 0;
// 分页查询并写入
for (int pageNum = 0; pageNum < totalPages; pageNum++) {
int offset = pageNum * pageSize;
List<Order> orders = orderMapper.selectByPage(offset, pageSize);
// 写入Excel
excelWriter.write(orders);
count += orders.size();
// 释放内存
orders.clear();
log.info("已处理第 {} 页,累计 {} 条", pageNum + 1, count);
}
excelWriter.finish();
long endTime = System.currentTimeMillis();
log.info("订单导出完成,共导出 {} 条,耗时 {} 秒",
count, (endTime - startTime) / 1000);
}
/**
* 设置响应头
*/
private void setResponseHeaders(HttpServletResponse response, String fileName) throws IOException {
response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
response.setCharacterEncoding("utf-8");
String encodedFileName = URLEncoder.encode(fileName, "UTF-8").replaceAll("\\+", "%20");
response.setHeader("Content-disposition", "attachment;filename*=utf-8''" + encodedFileName);
}
}
5.4 Controller实现
package com.example.controller;
import com.example.entity.Order;
import com.example.service.OrderExportService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.*;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
/**
* 订单导出控制器
*/
@Slf4j
@RestController
@RequestMapping("/api/orders")
@RequiredArgsConstructor
public class OrderExportController {
private final OrderExportService orderExportService;
/**
* 导出所有订单
*/
@GetMapping("/export")
public void exportAllOrders(HttpServletResponse response) throws IOException {
log.info("收到导出所有订单请求");
orderExportService.exportAllOrders(response);
}
/**
* 导出订单(带状态)
*/
@GetMapping("/export")
public void exportOrdersByStatus(
HttpServletResponse response,
@RequestParam String status) throws IOException {
log.info("收到导出订单请求,状态: {}", status);
orderExportService.exportOrdersByStatus(response, status);
}
/**
* 分页导出订单
*/
@GetMapping("/export")
public void exportOrdersByPage(
HttpServletResponse response,
@RequestParam(defaultValue = "10000") int pageSize) throws IOException {
log.info("收到分页导出订单请求,每页大小: {}", pageSize);
orderExportService.exportOrdersByPage(response, pageSize);
}
}
六、性能优化
6.1 优化策略
6.2 优化后的Mapper
/**
* 优化后的Mapper
*/
public interface OrderMapper {
/**
* 游标查询订单(只查询需要的字段)
*/
@Select("SELECT id, order_no, user_id, username, product_name, quantity, amount, status, create_time, pay_time " +
"FROM orders ORDER BY id")
@Options(resultSetType = ResultSetType.FORWARD_ONLY, fetchSize = Integer.MIN_VALUE)
Cursor<Order> selectAllWithCursorOptimized();
/**
* 分页查询订单(只查询需要的字段)
*/
@Select("SELECT id, order_no, user_id, username, product_name, quantity, amount, status, create_time, pay_time " +
"FROM orders ORDER BY id LIMIT #{offset}, #{pageSize}")
List<Order> selectByPageOptimized(@Param("offset") int offset, @Param("pageSize") int pageSize);
/**
* 统计订单总数
*/
@Select("SELECT COUNT(*) FROM orders")
long count();
}
6.3 优化后的Service
/**
* 优化后的导出服务
*/
@Slf4j
@Service
public class OrderExportServiceOptimized {
@Autowired
private OrderMapper orderMapper;
/**
* 批量大小
*/
private static final int BATCH_SIZE = 1000;
/**
* 优化后的分页导出
*/
public void exportOptimized(HttpServletResponse response) throws IOException {
log.info("开始优化导出订单");
long startTime = System.currentTimeMillis();
// 设置响应头
setResponseHeaders(response, "订单导出.xlsx");
// 创建ExcelWriter
ExcelWriter excelWriter = EasyExcel.write(response.getOutputStream(), Order.class).build();
// 查询总数
long total = orderMapper.count();
int totalPages = (int) Math.ceil((double) total / BATCH_SIZE);
log.info("订单总数: {}, 总页数: {}", total, totalPages);
int count = 0;
// 分页查询并写入
for (int pageNum = 0; pageNum < totalPages; pageNum++) {
int offset = pageNum * BATCH_SIZE;
// 查询当前页数据
List<Order> orders = orderMapper.selectByPageOptimized(offset, BATCH_SIZE);
// 写入Excel
excelWriter.write(orders);
count += orders.size();
// 及时释放内存
orders.clear();
orders = null;
// 每处理100000条记录一次日志
if (count % 100000 == 0) {
log.info("已导出 {} 条订单,进度: {}/{}", count, pageNum + 1, totalPages);
}
// 建议JVM进行GC
if (pageNum % 10 == 0) {
System.gc();
}
}
excelWriter.finish();
long endTime = System.currentTimeMillis();
log.info("订单导出完成,共导出 {} 条,耗时 {} 秒",
count, (endTime - startTime) / 1000);
}
}
6.4 批量写入优化
/**
* 批量写入优化
*/
@Slf4j
@Service
public class OrderExportBatchService {
@Autowired
private OrderMapper orderMapper;
private static final int BATCH_SIZE = 1000;
/**
* 批量写入导出
*/
public void exportBatch(HttpServletResponse response) throws IOException {
log.info("开始批量写入导出");
long startTime = System.currentTimeMillis();
// 设置响应头
setResponseHeaders(response, "订单导出.xlsx");
// 创建ExcelWriter
ExcelWriter excelWriter = EasyExcel.write(response.getOutputStream(), Order.class).build();
// 查询总数
long total = orderMapper.count();
int totalPages = (int) Math.ceil((double) total / BATCH_SIZE);
log.info("订单总数: {}, 总页数: {}", total, totalPages);
int count = 0;
// 分页查询并批量写入
for (int pageNum = 0; pageNum < totalPages; pageNum++) {
int offset = pageNum * BATCH_SIZE;
// 查询当前页数据
List<Order> orders = orderMapper.selectByPageOptimized(offset, BATCH_SIZE);
// 批量写入Excel
if (!orders.isEmpty()) {
excelWriter.write(orders, getWriteSheet());
}
count += orders.size();
// 释放内存
orders.clear();
orders = null;
log.info("已处理第 {} 页,累计 {} 条", pageNum + 1, count);
}
excelWriter.finish();
long endTime = System.currentTimeMillis();
log.info("订单导出完成,共导出 {} 条,耗时 {} 秒",
count, (endTime - startTime) / 1000);
}
/**
* 获取写入Sheet配置
*/
private WriteSheet getWriteSheet() {
return EasyExcel.writerSheet("订单").build();
}
}
七、异步导出
7.1 异步导出实现
/**
* 异步导出服务
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class AsyncOrderExportService {
private final OrderMapper orderMapper;
private final AsyncTaskExecutor asyncTaskExecutor;
/**
* 异步导出订单
*/
@Async("asyncTaskExecutor")
public void exportAsync(String exportId, String status) {
log.info("开始异步导出,导出ID: {}, 状态: {}", exportId, status);
long startTime = System.currentTimeMillis();
// 更新导出状态为"处理中"
updateExportStatus(exportId, "PROCESSING");
try {
// 创建临时文件
String fileName = "订单导出_" + exportId + ".xlsx";
String filePath = "/tmp/exports/" + fileName;
// 创建ExcelWriter
ExcelWriter excelWriter = EasyExcel.write(filePath, Order.class).build();
int count = 0;
try (Cursor<Order> cursor = orderMapper.selectByStatusWithCursor(status)) {
cursor.forEach(order -> {
excelWriter.write(order);
count++;
if (count % 10000 == 0) {
log.info("已导出 {} 条订单", count);
}
});
}
excelWriter.finish();
// 更新导出状态为"完成"
updateExportStatus(exportId, "COMPLETED", fileName);
long endTime = System.currentTimeMillis();
log.info("异步导出完成,导出ID: {}, 共导出 {} 条,耗时 {} 秒",
exportId, count, (endTime - startTime) / 1000);
} catch (Exception e) {
log.error("异步导出失败,导出ID: {}", exportId, e);
updateExportStatus(exportId, "FAILED");
}
}
/**
* 更新导出状态
*/
private void updateExportStatus(String exportId, String status) {
updateExportStatus(exportId, status, null);
}
/**
* 更新导出状态和文件名
*/
private void updateExportStatus(String exportId, String status, String fileName) {
// 更新数据库中的导出状态
// orderExportMapper.updateStatus(exportId, status, fileName);
}
}
7.2 异步导出配置
/**
* 异步配置
*/
@Configuration
@EnableAsync
public class AsyncConfig {
/**
* 异步任务执行器
*/
@Bean("asyncTaskExecutor")
public AsyncTaskExecutor asyncTaskExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
// 核心线程数
executor.setCorePoolSize(5);
// 最大线程数
executor.setMaxPoolSize(10);
// 队列容量
executor.setQueueCapacity(100);
// 线程名称前缀
executor.setThreadNamePrefix("async-export-");
// 拒绝策略
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
executor.initialize();
return executor;
}
}
7.3 异步导出Controller
/**
* 异步导出控制器
*/
@Slf4j
@RestController
@RequestMapping("/api/orders")
@RequiredArgsConstructor
public class AsyncOrderExportController {
private final AsyncOrderExportService asyncOrderExportService;
/**
* 创建异步导出任务
*/
@PostMapping("/export/async")
public Result<String> createAsyncExport(
@RequestParam(required = false) String status) {
log.info("收到异步导出请求,状态: {}", status);
// 生成导出ID
String exportId = UUID.randomUUID().toString();
// 创建导出记录
OrderExport export = new OrderExport();
export.setId(exportId);
export.setStatus("PENDING");
export.setCreateTime(LocalDateTime.now());
orderExportMapper.insert(export);
// 异步执行导出
asyncOrderExportService.exportAsync(exportId, status);
return Result.success(exportId);
}
/**
* 查询导出状态
*/
@GetMapping("/export/status/{exportId}")
public Result<OrderExport> getExportStatus(@PathVariable String exportId) {
OrderExport export = orderExportMapper.selectById(exportId);
return Result.success(export);
}
/**
* 下载导出文件
*/
@GetMapping("/export/download/{exportId}")
public void downloadExport(
HttpServletResponse response,
@PathVariable String exportId) throws IOException {
OrderExport export = orderExportMapper.selectById(exportId);
if (export == null || !"COMPLETED".equals(export.getStatus())) {
throw new BusinessException("导出未完成或不存在");
}
// 设置响应头
response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
response.setCharacterEncoding("utf-8");
String fileName = URLEncoder.encode(export.getFileName(), "UTF-8").replaceAll("\\+", "%20");
response.setHeader("Content-disposition", "attachment;filename*=utf-8''" + fileName);
// 读取文件并写入响应
String filePath = "/tmp/exports/" + export.getFileName();
Files.copy(Paths.get(filePath), response.getOutputStream());
}
}
八、进度反馈
8.1 进度跟踪实现
/**
* 导出进度服务
*/
@Slf4j
@Service
public class ExportProgressService {
@Autowired
private OrderMapper orderMapper;
/**
* 带进度的导出
*/
public void exportWithProgress(HttpServletResponse response) throws IOException {
log.info("开始带进度的导出");
long startTime = System.currentTimeMillis();
// 设置响应头
setResponseHeaders(response, "订单导出.xlsx");
// 创建ExcelWriter
ExcelWriter excelWriter = EasyExcel.write(response.getOutputStream(), Order.class).build();
// 查询总数
long total = orderMapper.count();
int count = 0;
try (Cursor<Order> cursor = orderMapper.selectAllWithCursor()) {
cursor.forEach(order -> {
excelWriter.write(order);
count++;
// 每处理10000条记录更新一次进度
if (count % 10000 == 0) {
int progress = (int) ((double) count / total * 100);
log.info("导出进度: {}/{} ({}%)", count, total, progress);
// 可以通过WebSocket等方式实时推送进度
// webSocketService.sendProgress(exportId, progress);
}
});
}
excelWriter.finish();
long endTime = System.currentTimeMillis();
log.info("订单导出完成,共导出 {} 条,耗时 {} 秒",
count, (endTime - startTime) / 1000);
}
}
8.2 WebSocket进度推送
/**
* WebSocket进度推送
*/
@ServerEndpoint("/export/progress/{exportId}")
@Component
@Slf4j
public class ExportProgressWebSocket {
private static Map<String, Session> sessions = new ConcurrentHashMap<>();
@OnOpen
public void onOpen(Session session, @PathParam String exportId) {
sessions.put(exportId, session);
log.info("WebSocket连接建立,导出ID: {}", exportId);
}
@OnClose
public void onClose(Session session, @PathParam String exportId) {
sessions.remove(exportId);
log.info("WebSocket连接关闭,导出ID: {}", exportId);
}
/**
* 发送进度
*/
public static void sendProgress(String exportId, int progress) {
Session session = sessions.get(exportId);
if (session != null && session.isOpen()) {
try {
session.getAsyncRemote().sendText("{\"progress\": " + progress + "}");
} catch (IOException e) {
log.error("发送进度失败,导出ID: {}", exportId, e);
}
}
}
}
九、前端实现
9.1 导出按钮
/**
* 导出按钮实现
*/
function exportOrders() {
// 显示加载状态
const btn = document.getElementById('exportBtn');
btn.disabled = true;
btn.textContent = '导出中...';
// 调用导出接口
window.location.href = '/api/orders/export';
// 5秒后恢复按钮状态
setTimeout(() => {
btn.disabled = false;
btn.textContent = '导出订单';
}, 5000);
}
9.2 异步导出实现
/**
* 异步导出实现
*/
async function exportOrdersAsync() {
try {
// 显示加载状态
const btn = document.getElementById('exportBtn');
btn.disabled = true;
btn.textContent = '创建导出任务中...';
// 创建导出任务
const response = await fetch('/api/orders/export/async?status=PAID', {
method: 'POST'
});
const result = await response.json();
if (result.code === 200) {
const exportId = result.data;
// 显示进度
showProgress(exportId);
// 轮询导出状态
pollExportStatus(exportId);
} else {
alert('创建导出任务失败:' + result.message);
btn.disabled = false;
btn.textContent = '导出订单';
}
} catch (error) {
console.error('导出失败', error);
alert('导出失败,请重试');
btn.disabled = false;
btn.textContent = '导出订单';
}
}
/**
* 显示进度条
*/
function showProgress(exportId) {
const progressDiv = document.createElement('div');
progressDiv.innerHTML = `
<div class="progress-container">
<div class="progress-bar" id="progressBar-${exportId}"></div>
<div class="progress-text" id="progressText-${exportId}">0%</div>
</div>
`;
document.body.appendChild(progressDiv);
}
/**
* 轮询导出状态
*/
async function pollExportStatus(exportId) {
const interval = setInterval(async () => {
const response = await fetch(`/api/orders/export/status/${exportId}`);
const result = await response.json();
if (result.code === 200) {
const export = result.data;
// 更新进度
const progressBar = document.getElementById(`progressBar-${exportId}`);
const progressText = document.getElementById(`progressText-${exportId}`);
progressBar.style.width = export.progress + '%';
progressText.textContent = export.progress + '%';
// 导出完成
if (export.status === 'COMPLETED') {
clearInterval(interval);
// 自动下载
window.location.href = `/api/orders/export/download/${exportId}`;
// 显示成功消息
alert('导出完成!');
// 恢复按钮
const btn = document.getElementById('exportBtn');
btn.disabled = false;
btn.textContent = '导出订单';
}
// 导出失败
if (export.status === 'FAILED') {
clearInterval(interval);
alert('导出失败,请重试');
const btn = document.getElementById('exportBtn');
btn.disabled = false;
btn.textContent = '导出订单';
}
}
}, 2000);
}
十、性能测试
10.1 性能对比
/**
* 性能测试
*/
@Slf4j
@Service
public class ExportPerformanceTest {
@Autowired
private OrderMapper orderMapper;
/**
* 测试传统方式
*/
public void testTraditionalExport() {
log.info("测试传统导出方式");
long startTime = System.currentTimeMillis();
try {
// 一次性查询所有数据
List<Order> orders = orderMapper.selectAll();
log.info("查询到 {} 条订单", orders.size());
// 模拟内存占用
log.info("内存占用: {} MB",
(Runtime.getRuntime().totalMemory() - Runtime.getRuntime().freeMemory()) / 1024 / 1024);
} catch (Exception e) {
log.error("传统导出方式失败", e);
}
long endTime = System.currentTimeMillis();
log.info("传统方式耗时: {} 秒", (endTime - startTime) / 1000);
}
/**
* 测试流式方式
*/
public void testStreamExport() {
log.info("测试流式导出方式");
long startTime = System.currentTimeMillis();
int count = 0;
try (Cursor<Order> cursor = orderMapper.selectAllWithCursor()) {
cursor.forEach(order -> {
count++;
if (count % 10000 == 0) {
log.info("已处理 {} 条订单,内存占用: {} MB",
count,
(Runtime.getRuntime().totalMemory() - Runtime.getRuntime().freeMemory()) / 1024 / 1024);
}
});
}
long endTime = System.currentTimeMillis();
log.info("流式方式耗时: {} 秒,共处理 {} 条", (endTime - startTime) / 1000, count);
}
}
10.2 性能测试结果
性能对比:
| 方式 | 内存占用 | 导出时间 | 成功率 | 适用场景 |
|---|---|---|---|---|
| 传统方式 | 2-3GB | 5-10分钟 | 低 | 小数据量 |
| 流式方式 | 50-100MB | 1-3分钟 | 高 | 大数据量 |
十一、最佳实践
11.1 最佳实践清单
最佳实践:
- 查询优化:
- ✅ 只查询需要的字段
- ✅ 使用索引优化查询
- ✅ 避免复杂的关联查询
- ❌ 不要使用
SELECT *
- 写入优化:
- ✅ 使用流式写入Excel
- ✅ 批量写入提升性能
- ✅ 异步处理不阻塞主线程
- ❌ 不要一次性写入所有数据
- 内存管理:
- ✅ 控制每批数据的大小(1000-10000条)
- ✅ 及时释放内存
- ✅ 定期建议JVM进行GC
- ❌ 不要一次性加载所有数据
- 用户体验:
- ✅ 使用异步导出
- ✅ 提供进度反馈
- ✅ 导出完成后通知用户
- ❌ 不要让用户长时间等待
11.2 配置优化
# application.yml
mybatis:
configuration:
# 游标查询配置
default-fetch-size: 1000
# 批量大小
default-statement-timeout: 300
# JVM配置
server:
tomcat:
threads:
max: 200
min-spare: 10
# 异步配置
spring:
task:
execution:
pool:
core-size: 5
max-size: 10
queue-capacity: 100
11.3 监控和告警
/**
* 导出监控服务
*/
@Slf4j
@Service
public class ExportMonitorService {
/**
* 监控导出任务
*/
public void monitorExport(String exportId) {
ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(1);
// 每30秒检查一次
scheduler.scheduleAtFixedRate(() -> {
try {
OrderExport export = orderExportMapper.selectById(exportId);
// 检查是否超时(超过30分钟)
if (export != null && "PROCESSING".equals(export.getStatus())) {
Duration duration = Duration.between(export.getCreateTime(), LocalDateTime.now());
if (duration.toMinutes() > 30) {
log.warn("导出任务超时,导出ID: {}", exportId);
// 发送告警
sendAlert("导出任务超时", exportId);
}
}
} catch (Exception e) {
log.error("监控导出任务失败", e);
}
}, 0, 30, TimeUnit.SECONDS);
}
/**
* 发送告警
*/
private void sendAlert(String message, String exportId) {
// 发送邮件、短信或钉钉告警
log.info("发送告警: {}, 导出ID: {}", message, exportId);
}
}
十二、总结
12.1 核心要点
12.2 实施建议
- 小数据量(< 1万条):
- 可以使用传统方式
- 一次性查询和写入
- 简单快速
- 中等数据量(1万 - 10万条):
- 使用分页查询
- 控制批次大小(1000-5000条)
- 流式写入Excel
- 大数据量(> 10万条):
- 使用游标查询
- 流式查询和写入
- 建议使用异步导出
- 提供进度反馈
12.3 避坑指南
常见错误:
- ❌ 使用
SELECT *查询所有字段 - ❌ 一次性查询所有数据到内存
- ❌ 不控制批次大小
- ❌ 不释放内存
- ❌ 同步导出大数据量
正确做法:
- ✅ 只查询需要的字段
- ✅ 使用游标或分页查询
- ✅ 控制批次大小(1000-10000条)
- ✅ 及时释放内存
- ✅ 大数据量使用异步导出
记住:大数据量导出的关键在于控制内存占用,使用流式查询和流式写入是避免OOM的最佳方案。选择合适的导出方式,既能满足业务需求,又能保证系统稳定。
希望这篇大数据量导出指南能帮助你解决导出100万条订单数据的问题!如果你有任何问题或使用技巧,欢迎在评论区讨论交流。
更多推荐
所有评论(0)