Excel 数据导出实战指南
一、概述
Excel 导出是后台管理系统中的标配功能,用于将系统数据导出为 Excel 文件供用户下载、分析或归档。一个完善的导出功能需要考虑:查询条件过滤、数据补充(远程服务)、Excel 生成、文件上传、大数据量性能等环节。
本文以一个"员工信息导出"场景为例,系统介绍 Excel 导出的完整实现流程。
二、整体流程
- 前端提交导出请求(携带筛选条件)
- Controller 接收请求,委托 Service
- Service 查询数据(不分页,按条件查全部)
- 补充远程数据(Feign 调用)
- 枚举翻译、格式化
- 使用 POI 生成 Excel 文件
- 写入本地临时文件
- 上传到 OSS
- 删除临时文件
- 返回 OSS 下载 URL
三、技术选型
3.1 Excel 生成方式对比
| 方式 | 类 | 文件格式 | 最大行数 | 内存占用 | 适用场景 |
|---|---|---|---|---|---|
| XSSFWorkbook | poi-ooxml | .xlsx | 104万行 | 高(全部在内存) | 数据量 < 5万 |
| SXSSFWorkbook | poi-ooxml | .xlsx | 104万行 | 低(流式写入) | 数据量 5万~100万 |
| HSSFWorkbook | poi | .xls | 6.5万行 | 中 | 兼容旧版 Excel |
| EasyExcel | alibaba | .xlsx | 104万行 | 极低 | 大数据量首选 |
3.2 文件交付方式对比
| 方式 | 实现 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|---|
| OSS 上传返回 URL | 生成文件 → 上传 → 返回链接 | 不占用接口连接时间 | 依赖 OSS 服务 | 生产环境推荐 |
| 直接流式下载 | HttpServletResponse 输出流 | 无需 OSS | 大文件可能超时 | 小数据量/内网 |
| 异步导出 + 通知 | MQ 异步生成 → 通知用户下载 | 不阻塞用户 | 实现复杂 | 超大数据量 |
注:
博客:
https://blog.csdn.net/badao_liumang_qizhi
四、完整实现
4.1 API 接口定义
java
@Tag(name = "员工管理")
@RestController
@RequestMapping("/api/page/employee")
public interface EmployeePageApi {
@Operation(summary = "员工信息导出")
@PostMapping("/export-employee")
RestControllerResult<String> exportEmployee(
@RequestBody EmployeeQueryParamsDto paramsDto);
}
4.2 Controller 实现
java
@Slf4j
@RestController
public class EmployeePageApiController implements EmployeePageApi {
@Resource
private EmployeeService employeeService;
@Override
public RestControllerResult<String> exportEmployee(
@RequestBody EmployeeQueryParamsDto paramsDto) {
RestControllerResult<String> result = new RestControllerResult<>();
result.setSuccess(Boolean.TRUE);
result.setData(employeeService.exportEmployee(paramsDto));
return result;
}
}
4.3 Service 实现
java
@Slf4j
@Service
public class EmployeeServiceImpl implements EmployeeService {
@Resource
private EmployeeMapper employeeMapper;
@Resource
private DepartmentFeign departmentFeign;
@Resource
private AliOssTemplate aliOssTemplate;
@Override
public String exportEmployee(EmployeeQueryParamsDto paramsDto) {
// 1. 查询数据(不分页)
List<EmployeeExportDto> dataList = employeeMapper.listEmployeeForExport(paramsDto);
// 2. 数据补充(Feign 远程调用)
if (dataList != null && !dataList.isEmpty()) {
enrichData(dataList);
}
// 3. 生成 Excel 并上传 OSS
return generateAndUploadExcel(dataList);
}
/**
* 补充远程数据 + 枚举翻译.
*/
private void enrichData(List<EmployeeExportDto> dataList) {
dataList.forEach(dto -> {
// 补充部门名称(Feign 调用)
if (StringUtils.isNotBlank(dto.getDeptCode())) {
try {
RestControllerResult<DeptInfoDto> deptResult =
departmentFeign.getDeptByCode(dto.getDeptCode());
if (deptResult != null && Boolean.TRUE.equals(deptResult.getSuccess())
&& deptResult.getData() != null) {
dto.setDeptName(deptResult.getData().getDeptName());
}
} catch (Exception e) {
log.warn("查询部门信息失败, deptCode={}", dto.getDeptCode());
}
}
// 枚举翻译:状态
if (dto.getStatus() != null) {
dto.setStatusName(EmployeeStatusEnum.getNameByCode(dto.getStatus()));
}
// 格式化:操作人
if (StringUtils.isNotBlank(dto.getStaffNo())
&& StringUtils.isNotBlank(dto.getOperatorName())) {
dto.setOperatorDisplay(dto.getStaffNo() + " " + dto.getOperatorName());
}
});
}
/**
* 生成 Excel 文件并上传 OSS.
*/
private String generateAndUploadExcel(List<EmployeeExportDto> dataList) {
String url = "";
XSSFWorkbook workbook = null;
File file = null;
try {
workbook = new XSSFWorkbook();
Sheet sheet = workbook.createSheet("员工信息");
// 1. 创建表头
createHeader(workbook, sheet);
// 2. 填充数据
fillData(sheet, dataList);
// 3. 设置列宽(可选)
setColumnWidth(sheet);
// 4. 写入临时文件
file = writeToTempFile(workbook);
// 5. 上传 OSS
url = aliOssTemplate.uploadFile(file);
} catch (Exception e) {
log.error("导出Excel失败", e);
throw new JshCheckException("导出失败,请稍后重试");
} finally {
// 6. 关闭资源
closeWorkbook(workbook);
// 7. 删除临时文件
deleteTempFile(file);
}
return url;
}
/**
* 创建表头.
*/
private void createHeader(XSSFWorkbook workbook, Sheet sheet) {
// 表头样式
Font fontStyle = workbook.createFont();
fontStyle.setBold(true);
fontStyle.setFontHeightInPoints((short) 11);
CellStyle headerStyle = workbook.createCellStyle();
headerStyle.setFont(fontStyle);
headerStyle.setAlignment(HorizontalAlignment.CENTER);
// 表头内容
String[] headers = {"工号", "姓名", "部门", "手机号", "状态", "操作人", "创建时间"};
Row headerRow = sheet.createRow(0);
for (int i = 0; i < headers.length; i++) {
Cell cell = headerRow.createCell(i);
cell.setCellValue(headers[i]);
cell.setCellStyle(headerStyle);
}
}
/**
* 填充数据行.
*/
private void fillData(Sheet sheet, List<EmployeeExportDto> dataList) {
if (dataList == null || dataList.isEmpty()) {
return;
}
SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
for (int i = 0; i < dataList.size(); i++) {
EmployeeExportDto dto = dataList.get(i);
Row row = sheet.createRow(i + 1);
row.createCell(0).setCellValue(nullToEmpty(dto.getStaffNo()));
row.createCell(1).setCellValue(nullToEmpty(dto.getStaffName()));
row.createCell(2).setCellValue(nullToEmpty(dto.getDeptName()));
row.createCell(3).setCellValue(nullToEmpty(dto.getPhone()));
row.createCell(4).setCellValue(nullToEmpty(dto.getStatusName()));
row.createCell(5).setCellValue(nullToEmpty(dto.getOperatorDisplay()));
row.createCell(6).setCellValue(
dto.getCreateTime() != null ? sdf.format(dto.getCreateTime()) : "");
}
}
/**
* 设置列宽.
*/
private void setColumnWidth(Sheet sheet) {
sheet.setColumnWidth(0, 4000); // 工号
sheet.setColumnWidth(1, 4000); // 姓名
sheet.setColumnWidth(2, 6000); // 部门
sheet.setColumnWidth(3, 5000); // 手机号
sheet.setColumnWidth(4, 3000); // 状态
sheet.setColumnWidth(5, 6000); // 操作人
sheet.setColumnWidth(6, 6000); // 创建时间
}
/**
* 写入临时文件.
*/
private File writeToTempFile(XSSFWorkbook workbook) throws IOException {
SimpleDateFormat dateFmt = new SimpleDateFormat("yyyyMMdd");
String dateStr = dateFmt.format(new Date());
int randomNum = new Random().nextInt(9000) + 1000;
String fileName = "员工信息导出-" + dateStr + randomNum + ".xlsx";
String tempPath = System.getProperty("java.io.tmpdir") + File.separator + fileName;
File file = new File(tempPath);
try (FileOutputStream fos = new FileOutputStream(file)) {
workbook.write(fos);
}
return file;
}
/**
* 关闭 Workbook.
*/
private void closeWorkbook(XSSFWorkbook workbook) {
if (workbook != null) {
try {
workbook.close();
} catch (IOException e) {
log.warn("关闭Workbook失败", e);
}
}
}
/**
* 删除临时文件.
*/
private void deleteTempFile(File file) {
if (file != null && file.exists()) {
try {
file.delete();
} catch (Exception e) {
log.warn("删除临时文件失败: {}", file.getAbsolutePath());
}
}
}
private String nullToEmpty(String value) {
return value != null ? value : "";
}
}
4.4 Mapper(不分页查询)
java
public interface EmployeeMapper {
List<EmployeeExportDto> listEmployeeForExport(
@Param("param") EmployeeQueryParamsDto param);
}
<select id="listEmployeeForExport"
resultType="com.example.dto.EmployeeExportDto">
SELECT
e.staff_no AS staffNo,
e.staff_name AS staffName,
e.dept_code AS deptCode,
e.phone AS phone,
e.status AS status,
e.create_time AS createTime,
e.create_user_id AS createUserId
FROM employee e
<where>
<if test="param.staffName != null and param.staffName != ''">
AND e.staff_name LIKE CONCAT('%', #{param.staffName}, '%')
</if>
<if test="param.deptCode != null and param.deptCode != ''">
AND e.dept_code = #{param.deptCode}
</if>
<if test="param.status != null">
AND e.status = #{param.status}
</if>
<if test="param.createTimeStart != null and param.createTimeStart != ''">
AND e.create_time >= #{param.createTimeStart}
</if>
<if test="param.createTimeEnd != null and param.createTimeEnd != ''">
AND e.create_time <= #{param.createTimeEnd}
</if>
</where>
ORDER BY e.id DESC
</select>
五、文件命名规范
5.1 命名规则
{业务名称}-{日期}{随机数}.xlsx
5.2 实现方式
java
SimpleDateFormat dateFmt = new SimpleDateFormat("yyyyMMdd");
String dateStr = dateFmt.format(new Date());
int randomNum = new Random().nextInt(9000) + 1000; // 4位随机数
String fileName = "员工信息导出-" + dateStr + randomNum + ".xlsx";
// 结果示例:员工信息导出-202605291234.xlsx
5.3 为什么加随机数
- 避免同一秒内多次导出文件名冲突
- OSS 上传时如果文件名相同会覆盖
六、大数据量导出优化
6.1 使用 SXSSFWorkbook(流式写入)
java
// XSSFWorkbook:所有数据在内存中,10万行可能占用 1GB+
// SXSSFWorkbook:只保留最近 N 行在内存中,其余写入临时文件
SXSSFWorkbook workbook = new SXSSFWorkbook(100); // 内存中保留100行
Sheet sheet = workbook.createSheet("数据");
// 使用方式与 XSSFWorkbook 完全一致
for (int i = 0; i < dataList.size(); i++) {
Row row = sheet.createRow(i + 1);
row.createCell(0).setCellValue(dataList.get(i).getName());
}
// 写入文件
workbook.write(outputStream);
workbook.dispose(); // 清理临时文件(SXSSFWorkbook 特有)
6.2 分页查询 + 流式写入
java
// 避免一次性加载全部数据到内存
int pageSize = 5000;
int pageNum = 1;
int rowIndex = 1;
while (true) {
List<EmployeeExportDto> pageData = employeeMapper.listEmployeePager(
paramsDto, pageSize, pageNum);
if (pageData == null || pageData.isEmpty()) {
break;
}
for (EmployeeExportDto dto : pageData) {
Row row = sheet.createRow(rowIndex++);
fillRow(row, dto);
}
pageNum++;
}
6.3 数据量与方案选择
| 数据量 | 推荐方案 | 说明 |
|---|---|---|
| < 1万 | XSSFWorkbook + 同步 | 简单直接 |
| 1万~10万 | SXSSFWorkbook + 同步 | 流式写入控制内存 |
| 10万~50万 | SXSSFWorkbook + 分页查询 | 避免一次性加载 |
| > 50万 | 异步导出 + MQ + 通知 | 避免接口超时 |
七、Feign 数据补充优化
7.1 逐条调用(简单但慢)
java
// 每条数据调用一次 Feign,N 条数据 = N 次网络请求
dataList.forEach(dto -> {
DeptInfoDto dept = departmentFeign.getDeptByCode(dto.getDeptCode());
dto.setDeptName(dept.getDeptName());
});
7.2 批量查询 + Map 映射(推荐)
java
// 收集所有部门编码,一次性查询
List<String> deptCodes = dataList.stream()
.map(EmployeeExportDto::getDeptCode)
.filter(StringUtils::isNotBlank)
.distinct()
.collect(Collectors.toList());
// 一次 Feign 调用
Map<String, String> deptMap = departmentFeign.batchGetDeptNames(deptCodes);
// 遍历赋值
dataList.forEach(dto -> {
if (deptMap.containsKey(dto.getDeptCode())) {
dto.setDeptName(deptMap.get(dto.getDeptCode()));
}
});
7.3 无批量接口时的折中
java
// 加 try-catch,单条失败不影响整体导出
dataList.forEach(dto -> {
try {
// Feign 调用
} catch (Exception e) {
log.warn("补充数据失败, id={}", dto.getId());
// 该字段留空,不影响导出
}
});
八、异常处理
| 异常场景 | 处理方式 |
|---|---|
| 查询数据为空 | 生成只有表头的空 Excel(或提示"无数据") |
| Feign 调用失败 | 该字段留空,不影响导出 |
| Excel 生成异常 | 抛出业务异常"导出失败,请稍后重试" |
| OSS 上传失败 | 抛出业务异常"文件上传失败" |
| 临时文件删除失败 | 仅记录 warn 日志,不影响返回 |
九、资源管理
9.1 try-finally 模式
java
XSSFWorkbook workbook = null;
File file = null;
try {
workbook = new XSSFWorkbook();
// ... 生成 Excel ...
file = writeToTempFile(workbook);
url = aliOssTemplate.uploadFile(file);
} catch (Exception e) {
throw new JshCheckException("导出失败");
} finally {
// 确保资源释放
if (workbook != null) {
try { workbook.close(); } catch (Exception ignored) {}
}
if (file != null && file.exists()) {
file.delete();
}
}
9.2 try-with-resources(FileOutputStream)
java
// FileOutputStream 用 try-with-resources 自动关闭
try (FileOutputStream fos = new FileOutputStream(file)) {
workbook.write(fos);
}
十、导出与列表查询的关系
| 维度 | 列表查询 | 导出 |
|---|---|---|
| 分页 | 有(pageNum/pageSize) | 无(查全部) |
| 筛选条件 | 相同 | 相同 |
| 返回格式 | JSON | Excel 文件 URL |
| Feign 补充 | 当前页数据 | 全部数据 |
| SQL | 带分页参数 | 不带分页参数 |
建议 :Mapper 中定义两个方法,一个带分页(列表用),一个不带分页(导出用),SQL 条件部分可以用 <sql> 片段复用。
xml
<sql id="queryCondition">
<if test="param.staffName != null and param.staffName != ''">
AND e.staff_name LIKE CONCAT('%', #{param.staffName}, '%')
</if>
<!-- 其他条件 -->
</sql>
<select id="listEmployeePager">
SELECT ... FROM employee e
<where><include refid="queryCondition"/></where>
ORDER BY e.id DESC
</select>
<select id="listEmployeeForExport">
SELECT ... FROM employee e
<where><include refid="queryCondition"/></where>
ORDER BY e.id DESC
</select>
十一、最佳实践清单
- 导出 SQL 不带分页:与列表查询共用筛选条件,但不限制条数
- Feign 补充加 try-catch:单条失败不影响整体导出
- 优先批量查询:有批量接口时一次性获取,避免 N+1
- 临时文件及时清理:finally 中删除,避免磁盘堆积
- Workbook 及时关闭:避免内存泄漏
- 文件名加随机数:避免并发导出时文件名冲突
- 大数据量用 SXSSFWorkbook:流式写入控制内存
- 设置合理列宽:提升用户体验
- 日期格式化:统一使用 yyyy-MM-dd HH:mm:ss
- 空数据处理:null 转空字符串,避免 Excel 中显示 "null"