Java 数据处理与报表

Spring Boot + MyBatis + EasyExcel — 制造业数据分析的工程化方案

01 Java 数据处理速览

💡 Java = 大型工厂,Python = 灵活画笔

Java 做工程化数据处理像运营一个大型工厂:类型安全、编译检查、性能可控、适合团队协作。
数据处理场景:读取设备日志 → 清洗过滤 → 聚合统计 → 生成报表 → 导出 Excel。

Java 数据处理核心工具

工具用途类比
Stream API集合的函数式操作(过滤/映射/聚合)Excel 的筛选 + 公式
MyBatis Cursor海量数据游标查询(不 OOM)逐行读取 Excel
EasyExcel流式读写 Excel(Alibaba 开源)自动化报表生成器
Spring Batch企业级批量处理框架工厂流水线
XXL-JOB分布式定时任务调度自动闹钟 + 任务分配器

Stream API 速览

StreamApiDemo.java — 集合数据处理
// 场景: 统计各产线的良品率
List<ProductionRecord> records = recordMapper.findAllByDate(date);

// 按产线分组, 计算良品率
Map<String, Double> yieldByLine = records.stream()
    .collect(Collectors.groupingBy(
        ProductionRecord::getLineName,  // 按产线名分组
        Collectors.averagingDouble(r ->
            r.isGood() ? 100.0 : 0.0  // 良品率
        )
    ));

// 筛选良品率低于 95% 的产线
List<String> lowYieldLines = yieldByLine.entrySet().stream()
    .filter(e -> e.getValue() < 95.0)
    .map(Map.Entry::getKey)
    .sorted()
    .collect(Collectors.toList());

// 求总产量
int totalOutput = records.stream()
    .mapToInt(ProductionRecord::getQuantity)
    .sum();

02 Spring Boot 数据查询

制造业看板需要从数据库查询产线数据、设备状态、产量统计等。用 Spring Boot + MyBatis 实现灵活查询。

ProductionController.java — 产量查询 API
@RestController
@RequestMapping("/api/production")
@RequiredArgsConstructor
public class ProductionController {

    private final ProductionService productionService;

    // 看板: 今日各产线产量
    @GetMapping("/dashboard/today")
    public Result<List<LineOutputVO>> todayDashboard() {
        return Result.success(productionService.getTodayOutput());
    }

    // 按日期范围查询产量趋势
    @GetMapping("/trend")
    public Result<List<DailyOutputVO>> trend(
            @RequestParam String startDate,
            @RequestParam String endDate,
            @RequestParam(required = false) String lineName) {
        return Result.success(productionService.getTrend(startDate, endDate, lineName));
    }

    // 导出 Excel 报表
    @GetMapping("/export")
    public void export(@RequestParam String date, HttpServletResponse response) {
        response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
        response.setHeader("Content-Disposition", "attachment; filename=production_" + date + ".xlsx");
        productionService.exportExcel(date, response.getOutputStream());
    }
}

03 批量数据处理

⚠️ 大数据量处理的三大陷阱

全量加载到内存:查 100 万条 → OOM。解决:MyBatis Cursor 游标查询。
逐条 INSERT:1 万条数据要 30 秒。解决:批量 INSERT(MyBatis foreach)。
循环中查 DB(N+1 问题):1 万条 → 1 万次 DB 查询。解决:批量查询 + 内存组装。

MyBatis Cursor 游标查询(流式处理)

Mapper — 游标查询接口
@Mapper
public interface ProductionMapper {
    // Cursor: 流式读取, 不一次性加载到内存
    @Select("SELECT * FROM production_records WHERE date = #{date}")
    @Options(fetchSize = Integer.MIN_VALUE)  // MySQL 流式查询关键参数
    Cursor<ProductionRecord> scanByDate(@Param("date") String date);

    // 批量插入
    @Insert("<script>" +
        "INSERT INTO production_records(line_name, quantity, good_count, date) VALUES " +
        "<foreach collection='list' item='r' separator=','>" +
        "(#{r.lineName}, #{r.quantity}, #{r.goodCount}, #{r.date})" +
        "</foreach>" +
        "</script>")
    int batchInsert(@Param("list") List<ProductionRecord> list);
}
Service — 流式处理百万数据
@Service
@RequiredArgsConstructor
public class BatchReportService {

    private final ProductionMapper productionMapper;

    // 流式导出: 内存恒定, 不 OOM
    @Transactional(readOnly = true)
    public void exportLargeData(String date, OutputStream out) {
        try (Cursor<ProductionRecord> cursor = productionMapper.scanByDate(date)) {
            int batch = 0;
            List<ProductionRecord> buffer = new ArrayList<>(1000);

            for (ProductionRecord record : cursor) {  // 逐行读取
                buffer.add(record);
                if (buffer.size() >= 1000) {
                    processBatch(buffer);  // 每 1000 条处理一次
                    buffer.clear();
                    batch++;
                    log.info("已处理 {} 批, {} 条", batch, batch * 1000);
                }
            }
            // 处理剩余
            if (!buffer.isEmpty()) {
                processBatch(buffer);
            }
        }
    }

    // 批量插入: 比逐条插入快 50 倍
    @Transactional
    public void batchInsert(List<ProductionRecord> records) {
        // 每 1000 条一批
        Lists.partition(records, 1000).forEach(productionMapper::batchInsert);
    }
}
🔒 MySQL 批量优化

JDBC URL 加 rewriteBatchedStatements=true,MyBatis foreach 批量 INSERT 会合并为一条 SQL,性能提升 50 倍。

04 连接数据库

application.yml — 数据库配置
spring:
  datasource:
    url: jdbc:mysql://localhost:3306/factory_db?useSSL=false&serverTimezone=Asia/Shanghai&rewriteBatchedStatements=true
    username: root
    password: ${DB_PASSWORD}
    hikari:
      maximum-pool-size: 20
      leak-detection-threshold: 30000

mybatis:
  mapper-locations: classpath:mapper/*.xml
  configuration:
    map-underscore-to-camel-case: true

05 数据 ETL

ETL = Extract(从设备/MES 系统/旧数据库提取数据) → Transform(清洗/过滤/转换) → Load(写入数据仓库/报表库)。

💡 ETL = 工厂质检流水线

Extract = 从各个车间收原材料(从各数据源读数据)
Transform = 质检 + 分拣 + 加工(清洗异常值/格式转换/计算指标)
Load = 入库上架(写入分析库/报表库)

EquipmentDataETL.java — 设备数据清洗入库
@Service
@RequiredArgsConstructor
@Slf4j
public class EquipmentDataETL {

    private final EquipmentMapper equipmentMapper;
    private final EquipmentLogClient equipmentLogClient;  // 设备日志 API

    // 定时 ETL: 每小时从设备 API 拉取日志, 清洗后入库
    @Scheduled(cron = "0 0 * * * ?")
    @Transactional
    public void execute() {
        log.info("开始 ETL...");

        // 1. Extract: 从设备 API 获取原始数据
        List<EquipmentRawDTO> rawData = equipmentLogClient.fetchLastHour();
        log.info("Extract: 获取 {} 条原始数据", rawData.size());

        // 2. Transform: 清洗 + 过滤 + 转换
        List<EquipmentRecord> cleanData = rawData.stream()
            .filter(r -> r.getTemperature() != null)  // 过滤空值
            .filter(r -> r.getTemperature() >= 0 && r.getTemperature() <= 200)  // 合理范围
            .map(this::transform)  // 字段转换
            .collect(Collectors.toList());
        log.info("Transform: 清洗后 {} 条有效数据", cleanData.size());

        // 3. Load: 批量入库
        Lists.partition(cleanData, 1000)
            .forEach(equipmentMapper::batchInsert);
        log.info("Load: 入库完成");
    }

    // 数据转换: raw DTO → 实体 + 计算指标
    private EquipmentRecord transform(EquipmentRawDTO raw) {
        EquipmentRecord record = new EquipmentRecord();
        record.setEquipmentId(raw.getId());
        record.setTemperature(raw.getTemperature().setScale(2, RoundingMode.HALF_UP));
        record.setRecordTime(LocalDateTime.parse(raw.getTimestamp()));
        // 计算是否告警
        record.setAlarm(raw.getTemperature() > 85.0);  // 温度超 85 度告警
        return record;
    }
}

06 报表自动化

用 EasyExcel(Alibaba 开源)生成 Excel 报表,支持流式写入、百万行不 OOM。

pom.xml — EasyExcel 依赖
<dependency>
    <groupId>com.alibaba</groupId>
    <artifactId>easyexcel</artifactId>
    <version>3.3.3</version>
</dependency>
ProductionExcelDTO.java — Excel 行模型
@Data
@HeadRowStyle(fillForegroundColor = 22)  // 表头浅绿色
public class ProductionExcelDTO {

    @ExcelProperty(value = "日期", index = 0)
    @ColumnWidth(15)
    private String date;

    @ExcelProperty(value = "产线名称", index = 1)
    @ColumnWidth(20)
    private String lineName;

    @ExcelProperty(value = "总产量", index = 2)
    private Integer totalQuantity;

    @ExcelProperty(value = "良品数", index = 3)
    private Integer goodCount;

    @ExcelProperty(value = "良品率(%)", index = 4)
    @NumberFormat("0.00")
    private BigDecimal yieldRate;

    @ExcelProperty(value = "状态", index = 5)
    private String status;
}
ReportService.java — 流式导出 Excel
@Service
@RequiredArgsConstructor
public class ReportService {

    private final ProductionMapper productionMapper;

    // 流式写入 Excel: 适合百万行数据
    @Transactional(readOnly = true)
    public void exportReport(String date, OutputStream out) {
        ExcelWriter writer = EasyExcel.write(out, ProductionExcelDTO.class).build();
        WriteSheet sheet = EasyExcel.writerSheet("产量报表").build();

        // 游标查询 + 分批写入
        try (Cursor<ProductionRecord> cursor = productionMapper.scanByDate(date)) {
            List<ProductionExcelDTO> buffer = new ArrayList<>(2000);
            for (ProductionRecord record : cursor) {
                buffer.add(toExcelDTO(record));
                if (buffer.size() >= 2000) {
                    writer.write(buffer, sheet);
                    buffer.clear();
                }
            }
            if (!buffer.isEmpty()) {
                writer.write(buffer, sheet);
            }
        } finally {
            writer.finish();
        }
    }

    private ProductionExcelDTO toExcelDTO(ProductionRecord r) {
        ProductionExcelDTO dto = new ProductionExcelDTO();
        dto.setDate(r.getDate());
        dto.setLineName(r.getLineName());
        dto.setTotalQuantity(r.getQuantity());
        dto.setGoodCount(r.getGoodCount());
        BigDecimal rate = BigDecimal.valueOf(r.getGoodCount() * 100.0 / r.getQuantity());
        dto.setYieldRate(rate.setScale(2, RoundingMode.HALF_UP));
        dto.setStatus(rate.compareTo(new BigDecimal("95")) >= 0 ? "正常" : "偏低");
        return dto;
    }
}

07 定时任务

ScheduledTasks.java — 单机定时任务
@Component
@EnableScheduling
@Slf4j
public class ScheduledTasks {

    @Autowired
    private EquipmentDataETL etlService;
    @Autowired
    private ReportService reportService;
    @Autowired
    private StringRedisTemplate redisTemplate;

    // 每小时执行设备数据 ETL
    @Scheduled(cron = "0 0 * * * ?")
    public void hourlyETL() {
        withDistributedLock("lock:etl", () -> {
            log.info("开始 ETL 任务");
            etlService.execute();
        });
    }

    // 每天凌晨 1 点生成日报
    @Scheduled(cron = "0 0 1 * * ?")
    public void dailyReport() {
        withDistributedLock("lock:daily-report", () -> {
            String yesterday = LocalDate.now().minusDays(1).toString();
            reportService.generateDaily(yesterday);
        });
    }

    // 每 10 秒刷新看板缓存
    @Scheduled(fixedRate = 10000)
    public void refreshDashboard() {
        redisTemplate.delete("dashboard:today");  // 清缓存, 下次查时重建
    }

    // 分布式锁: 防止集群中多个 Pod 重复执行
    private void withDistributedLock(String lockKey, Runnable task) {
        try {
            Boolean locked = redisTemplate.opsForValue()
                .setIfAbsent(lockKey, "1", 2, TimeUnit.HOURS);
            if (Boolean.TRUE.equals(locked)) {
                task.run();
            } else {
                log.info("未获取到锁, 跳过: {}", lockKey);
            }
        } finally {
            redisTemplate.delete(lockKey);
        }
    }
}
⚠️ @Scheduled 在集群中的坑

3 个 Pod 同时部署,@Scheduled 会在每个 Pod 上都执行!解决方案:
① Redis 分布式锁(上面的代码示例);② XXL-JOB 集中调度(推荐);③ K8s CronJob。

08 面试考点 Top 8

Q1 Java Stream API 中 map 和 flatMap 的区别?

答案:map 是一对一转换(Stream<String> → Stream<Integer>)。flatMap 是一对多展开(Stream<List<String>> → Stream<String>),把每个 List 展开后合并成一个流。flatMap 用于"拍平"嵌套结构。

Q2 MyBatis Cursor 游标查询为什么能避免 OOM?

答案:普通查询是一次性把所有结果加载到内存。Cursor(配合 fetchSize=Integer.MIN_VALUE)告诉 MySQL 逐行返回数据,MyBatis 每次只在内存中保留一行。注意:必须在 @Transactional 中使用(Cursor 依赖 Connection 不能提前关闭)。

Q3 批量 INSERT 为什么比逐条 INSERT 快?

答案:① 减少网络往返(1 次 vs N 次);② 减少 SQL 解析次数;③ 减少事务提交次数。MyBatis foreach + JDBC rewriteBatchedStatements=true,1000 条数据从 30 秒 → 0.5 秒。

Q4 EasyExcel 为什么比 Apache POI 快?

答案:Apache POI 把整个 Excel 加载到内存(DOM 模式),10 万行就可能 OOM。EasyExcel 用 SAX 流式解析(逐行读取),无论多少行内存占用恒定(~10MB)。写入也同理,用滑动窗口只保留当前行。

Q5 @Scheduled cron 表达式怎么写?

答案:格式:秒 分 时 日 月 周。示例:"0 0 * * * ?"(每小时整点)、"0 30 2 * * ?"(每天 2:30)、"0 0/15 * * * ?"(每 15 分钟)。注意 Spring 用 6 位(含秒),Linux crontab 用 5 位(不含秒)。

Q6 @Scheduled 在集群中为什么会重复执行?怎么解决?

答案:每个 Pod 独立运行 @Scheduled,3 个 Pod 就执行 3 次。解决:① Redis 分布式锁(SETNX);② XXL-JOB 集中调度(推荐,支持分片/重试/可视化日志);③ K8s CronJob。

Q7 如何设计一个 ETL 流程?

答案:Extract(从数据源读取——API/数据库/CSV/消息队列)→ Transform(清洗——过滤异常值、格式转换、计算指标)→ Load(批量写入目标库)。关键设计:流式处理避免 OOM、分批写入、错误重试、监控告警。Spring Batch 框架提供了标准化的 ETL 管道。

Q8 如何优化大数据量报表导出的性能?

答案:① MyBatis Cursor 游标查询(不 OOM);② EasyExcel 流式写入(内存恒定);③ 异步导出(任务提交 → 生成文件 → OSS 下载链接);④ 分页/分 Sheet 导出(每 10000 行一个 Sheet);⑤ CDN 加速大文件下载。

📚 学习路线建议

入门:Stream API → MyBatis 基础查询 → EasyExcel 导出
进阶:Cursor 游标查询 → 批量优化 → @Scheduled 定时任务
高级:Spring Batch ETL → XXL-JOB 分布式调度 → 大数据量优化
生产:异步报表 → OSS 文件存储 → 监控告警