java

关注公众号 jb51net

关闭
首页 > 软件编程 > java > SpringBoot批量数据导入性能优化

SpringBoot批量数据导入性能优化的实战指南

作者:霸道流氓气质

这篇文章主要介绍了企业级系统中Excel批量导入的性能瓶颈,并并提出了优化方案,通过对比逐条查询、批量预查询和异步MQ处理三种方案,并提供了关键技术和完整示例代码,需要的朋友可以参考下

一、问题背景

在企业级系统中,Excel 批量导入是高频业务场景。当导入数据量从几十条增长到数千条时,未经优化的逐条处理方式会导致接口响应时间呈线性增长,甚至触发 HTTP 超时。

核心矛盾:校验逻辑需要逐条判断,但数据库交互不应逐条执行。

二、性能瓶颈分析

典型的未优化导入流程

解析 Excel → 循环每条数据 {
    查询表A验证是否存在(1次DB)
    查询表B验证是否重复(1次DB)
    插入记录(1次DB)
} → 返回结果

N 条数据 = 3N 次数据库交互

耗时分布(5000条数据)

操作单次耗时总次数累计耗时
JDBC 连接获取/释放~1ms15000~15s
SQL 执行~3-5ms15000~45-75s
网络传输开销~0.5ms15000~7.5s
合计~60-90s

三、优化方案对比

方案 A:逐条查询 + 逐条保存(原始方式)

for (ExcelRow row : dataList) {
    Entity entity = repository.findByCode(row.getCode()); // N次
    if (exists(entity)) { fail; continue; }
    Existing record = recordRepo.findByCode(row.getCode()); // N次
    if (exists(record)) { fail; continue; }
    recordRepo.save(newRecord); // N次
}

方案 B:批量预查询 + 内存校验 + 批量保存(推荐)

// 1. 收集所有待校验的 code
List<String> allCodes = extractCodes(dataList);

// 2. 批量查询,转 Map/Set(2次DB)
Map<String, Entity> entityMap = batchQuery(allCodes);
Set<String> existingCodes = batchQueryExisting(allCodes);

// 3. 内存中循环校验(0次DB)
for (ExcelRow row : dataList) {
    Entity entity = entityMap.get(row.getCode()); // O(1)
    if (existingCodes.contains(row.getCode())) { fail; } // O(1)
    successList.add(buildRecord(row));
}

// 4. 批量保存(1次DB)
repository.saveAll(successList);

方案 C:异步 MQ 处理(大数据量场景)

接口层:解析Excel → 发MQ消息 → 立即返回taskId
消费端:校验 + 保存 + 更新导入状态
前端:轮询 taskId 获取进度

方案对比总结

维度方案A(逐条)方案B(批量预查询)方案C(异步MQ)
DB 交互次数3N33(异步)
5000条耗时60-90s5-10s接口<1s
实现复杂度
内存占用
适用场景<100条100~10000条>10000条

四、关键技术点

4.1 批量 IN 查询的注意事项

MySQL 的 IN 子句有长度限制(通常建议不超过 1000 个参数)。当数据量超出时需分批查询:

// 分批查询,每批1000条
private <T> List<T> batchQuery(List<String> codes, Function<List<String>, List<T>> queryFn) {
    List<T> result = new ArrayList<>();
    int batchSize = 1000;
    for (int i = 0; i < codes.size(); i += batchSize) {
        List<String> batch = codes.subList(i, Math.min(i + batchSize, codes.size()));
        result.addAll(queryFn.apply(batch));
    }
    return result;
}

4.2 Map 构建时的去重策略

一个 key 可能对应多条记录时,需指定冲突合并策略:

// (m1, m2) -> m1 表示取第一条
Map<String, Entity> map = list.stream()
    .collect(Collectors.toMap(Entity::getCode, e -> e, (m1, m2) -> m1));

4.3 saveAll 的批量 flush 配置

JPA 默认 saveAll 仍是逐条 persist,需配合 Hibernate 批量配置才能真正发挥批量 INSERT 性能:

spring:
  jpa:
    properties:
      hibernate:
        jdbc:
          batch_size: 500
        order_inserts: true
        order_updates: true

4.4 批量保存的容错降级

批量保存可能因某条数据违反约束而整体失败,需降级为逐条保存以精确定位:

try {
    repository.saveAll(successList); // 尝试批量
} catch (Exception e) {
    // 降级为逐条,精确捕获失败记录
    for (Record record : successList) {
        try {
            repository.save(record);
        } catch (Exception ex) {
            failList.add(record.getCode(), ex.getMessage());
        }
    }
}

4.5 导入条数限制

防止内存溢出和接口超时,应在入口处限制单次导入量:

if (dataList.size() > MAX_IMPORT_SIZE) {
    throw new BusinessException("单次导入不能超过" + MAX_IMPORT_SIZE + "条");
}

五、完整示例代码

5.1 Controller 层

@Slf4j
@RestController
@RequestMapping("/api/employee")
public class EmployeeImportController {

    private static final int MAX_IMPORT_SIZE = 5000;

    @Resource
    private EmployeeImportService employeeImportService;

    @PostMapping("/import")
    public RestResult<ImportResultDto> importEmployee(
            @RequestParam("file") MultipartFile file) {

        // 1. 文件校验
        if (file == null || file.isEmpty()) {
            throw new BizException("导入文件不能为空");
        }

        // 2. 解析Excel
        List<EmployeeExcelDto> dataList = ExcelUtil.parse(file, EmployeeExcelDto.class);
        if (dataList == null || dataList.isEmpty()) {
            throw new BizException("导入数据为空,请重新导入");
        }

        // 3. 条数限制
        if (dataList.size() > MAX_IMPORT_SIZE) {
            throw new BizException("单次导入不能超过" + MAX_IMPORT_SIZE
                    + "条,当前数据量:" + dataList.size() + "条");
        }

        // 4. 执行导入
        ImportResultDto result = employeeImportService.batchImport(dataList);
        return RestResult.success(result);
    }
}

5.2 Service 层(核心优化逻辑)

@Slf4j
@Service
public class EmployeeImportServiceImpl implements EmployeeImportService {

    @Resource
    private DepartmentRepository departmentRepository;

    @Resource
    private EmployeeRepository employeeRepository;

    @Resource
    private AliOssTemplate aliOssTemplate;

    @Override
    public ImportResultDto batchImport(List<EmployeeExcelDto> dataList) {
        ImportResultDto resultDto = new ImportResultDto();
        List<String[]> failList = new ArrayList<>();
        List<Employee> successList = new ArrayList<>();

        // ========== 第一步:提取所有待校验的编码 ==========
        List<String> allDeptCodes = dataList.stream()
                .map(EmployeeExcelDto::getDeptCode)
                .filter(Objects::nonNull)
                .map(String::trim)
                .distinct()
                .collect(Collectors.toList());

        List<String> allEmpNos = dataList.stream()
                .map(EmployeeExcelDto::getEmpNo)
                .filter(Objects::nonNull)
                .map(String::trim)
                .distinct()
                .collect(Collectors.toList());

        // ========== 第二步:批量预查询(仅2次DB) ==========
        // 查询所有部门,转为 Map<deptCode, Department>
        Map<String, Department> deptMap = new HashMap<>();
        if (!allDeptCodes.isEmpty()) {
            List<Department> deptList = departmentRepository.findByCodeIn(allDeptCodes);
            if (deptList != null) {
                deptMap = deptList.stream()
                        .collect(Collectors.toMap(Department::getCode, d -> d, (d1, d2) -> d1));
            }
        }

        // 查询已存在的员工工号
        Set<String> existingEmpNos = new HashSet<>();
        if (!allEmpNos.isEmpty()) {
            List<Employee> existingList = employeeRepository.findByEmpNoIn(allEmpNos);
            if (existingList != null) {
                existingEmpNos = existingList.stream()
                        .map(Employee::getEmpNo)
                        .collect(Collectors.toSet());
            }
        }

        // ========== 第三步:内存中逐条校验(0次DB) ==========
        Set<String> batchDuplicate = new HashSet<>(); // 批次内去重

        for (EmployeeExcelDto dto : dataList) {
            String empNo = dto.getEmpNo();
            String deptCode = dto.getDeptCode();

            // 校验工号不能为空
            if (empNo == null || empNo.trim().isEmpty()) {
                failList.add(new String[]{empNo, "员工工号不能为空"});
                continue;
            }
            String trimmedNo = empNo.trim();

            // 校验批次内重复
            if (batchDuplicate.contains(trimmedNo)) {
                failList.add(new String[]{empNo, "工号在导入文件中重复"});
                continue;
            }

            // 校验部门是否存在(内存Map查找 O(1))
            Department dept = deptMap.get(deptCode != null ? deptCode.trim() : "");
            if (dept == null) {
                failList.add(new String[]{empNo, "部门编码不存在"});
                continue;
            }

            // 校验工号是否已存在(内存Set查找 O(1))
            if (existingEmpNos.contains(trimmedNo)) {
                failList.add(new String[]{empNo, "员工工号已存在"});
                continue;
            }

            // 校验通过,构建实体
            Employee employee = new Employee();
            employee.setEmpNo(trimmedNo);
            employee.setName(dto.getName());
            employee.setDeptId(dept.getId());
            employee.setCreateTime(new Date());
            successList.add(employee);
            batchDuplicate.add(trimmedNo);
        }

        // ========== 第四步:批量保存(1次DB) ==========
        int successCount = 0;
        if (!successList.isEmpty()) {
            try {
                employeeRepository.saveAll(successList);
                successCount = successList.size();
            } catch (Exception e) {
                log.error("批量保存失败,降级为逐条保存", e);
                for (Employee emp : successList) {
                    try {
                        employeeRepository.save(emp);
                        successCount++;
                    } catch (Exception ex) {
                        failList.add(new String[]{emp.getEmpNo(),
                                "保存失败:" + ex.getMessage()});
                    }
                }
            }
        }

        // ========== 第五步:生成失败报告 ==========
        resultDto.setSuccessCount(successCount);
        resultDto.setFailCount(failList.size());
        if (!failList.isEmpty()) {
            resultDto.setFailFileUrl(generateFailExcel(failList));
        }
        return resultDto;
    }

    /**
     * 生成失败数据Excel并上传OSS.
     */
    private String generateFailExcel(List<String[]> failList) {
        try (XSSFWorkbook workbook = new XSSFWorkbook()) {
            Sheet sheet = workbook.createSheet("导入失败数据");

            // 表头样式
            CellStyle headerStyle = createHeaderStyle(workbook);
            Row headerRow = sheet.createRow(0);
            createCell(headerRow, 0, "员工工号", headerStyle);
            createCell(headerRow, 1, "失败原因", headerStyle);

            // 失败原因红色字体样式
            CellStyle failStyle = createFailStyle(workbook);

            // 填充数据
            for (int i = 0; i < failList.size(); i++) {
                Row row = sheet.createRow(i + 1);
                row.createCell(0).setCellValue(
                        failList.get(i)[0] != null ? failList.get(i)[0] : "");
                Cell cell = row.createCell(1);
                cell.setCellValue(failList.get(i)[1] != null ? failList.get(i)[1] : "");
                cell.setCellStyle(failStyle);
            }

            sheet.setColumnWidth(0, 20 * 256);
            sheet.setColumnWidth(1, 35 * 256);

            // 写临时文件 → 上传OSS → 删除临时文件
            File tempFile = File.createTempFile("import-fail-", ".xlsx");
            try (FileOutputStream fos = new FileOutputStream(tempFile)) {
                workbook.write(fos);
            }
            String url = aliOssTemplate.uploadFile(tempFile);
            tempFile.delete();
            return url;
        } catch (Exception e) {
            log.error("生成失败报告异常", e);
            return "";
        }
    }

    private CellStyle createHeaderStyle(XSSFWorkbook workbook) {
        Font font = workbook.createFont();
        font.setBold(true);
        font.setColor(IndexedColors.WHITE.getIndex());
        CellStyle style = workbook.createCellStyle();
        style.setFont(font);
        style.setFillForegroundColor(IndexedColors.ROYAL_BLUE.getIndex());
        style.setFillPattern(FillPatternType.SOLID_FOREGROUND);
        style.setAlignment(HorizontalAlignment.CENTER);
        return style;
    }

    private CellStyle createFailStyle(XSSFWorkbook workbook) {
        Font font = workbook.createFont();
        font.setColor(IndexedColors.RED.getIndex());
        CellStyle style = workbook.createCellStyle();
        style.setFont(font);
        return style;
    }

    private void createCell(Row row, int col, String value, CellStyle style) {
        Cell cell = row.createCell(col);
        cell.setCellValue(value);
        cell.setCellStyle(style);
    }
}

5.3 DTO 定义

@Data
public class ImportResultDto {
    /** 成功数量. */
    private int successCount;
    /** 失败数量. */
    private int failCount;
    /** 失败文件下载URL. */
    private String failFileUrl;
}

@Data
public class EmployeeExcelDto {
    /** 员工工号. */
    private String empNo;
    /** 员工姓名. */
    private String name;
    /** 部门编码. */
    private String deptCode;
}

六、性能对比结论

数据量方案A(逐条)方案B(批量预查询)提升倍数
100条~3s~0.5s6x
1000条~15s~2s7.5x
5000条~60s~5-10s6-12x

核心思想:将 N 次数据库 IO 压缩为常数次,把校验逻辑从"数据库驱动"转变为"内存驱动"。

以上就是SpringBoot批量数据导入性能优化的实战指南的详细内容,更多关于SpringBoot批量数据导入性能优化的资料请关注脚本之家其它相关文章!

您可能感兴趣的文章:
阅读全文