Java 数据处理与报表
Spring Boot + MyBatis + EasyExcel — 制造业数据分析的工程化方案
01 Java 数据处理速览
Java 做工程化数据处理像运营一个大型工厂:类型安全、编译检查、性能可控、适合团队协作。
数据处理场景:读取设备日志 → 清洗过滤 → 聚合统计 → 生成报表 → 导出 Excel。
Java 数据处理核心工具
| 工具 | 用途 | 类比 |
|---|---|---|
| Stream API | 集合的函数式操作(过滤/映射/聚合) | Excel 的筛选 + 公式 |
| MyBatis Cursor | 海量数据游标查询(不 OOM) | 逐行读取 Excel |
| EasyExcel | 流式读写 Excel(Alibaba 开源) | 自动化报表生成器 |
| Spring Batch | 企业级批量处理框架 | 工厂流水线 |
| XXL-JOB | 分布式定时任务调度 | 自动闹钟 + 任务分配器 |
Stream API 速览
// 场景: 统计各产线的良品率
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 实现灵活查询。
@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
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
@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);
}
}
JDBC URL 加 rewriteBatchedStatements=true,MyBatis foreach 批量 INSERT 会合并为一条 SQL,性能提升 50 倍。
04 连接数据库
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(写入数据仓库/报表库)。
Extract = 从各个车间收原材料(从各数据源读数据)
Transform = 质检 + 分拣 + 加工(清洗异常值/格式转换/计算指标)
Load = 入库上架(写入分析库/报表库)
@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。
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>easyexcel</artifactId>
<version>3.3.3</version>
</dependency>
@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;
}
@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 定时任务
@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);
}
}
}
3 个 Pod 同时部署,@Scheduled 会在每个 Pod 上都执行!解决方案:
① Redis 分布式锁(上面的代码示例);② XXL-JOB 集中调度(推荐);③ K8s CronJob。
08 面试考点 Top 8
答案:map 是一对一转换(Stream<String> → Stream<Integer>)。flatMap 是一对多展开(Stream<List<String>> → Stream<String>),把每个 List 展开后合并成一个流。flatMap 用于"拍平"嵌套结构。
答案:普通查询是一次性把所有结果加载到内存。Cursor(配合 fetchSize=Integer.MIN_VALUE)告诉 MySQL 逐行返回数据,MyBatis 每次只在内存中保留一行。注意:必须在 @Transactional 中使用(Cursor 依赖 Connection 不能提前关闭)。
答案:① 减少网络往返(1 次 vs N 次);② 减少 SQL 解析次数;③ 减少事务提交次数。MyBatis foreach + JDBC rewriteBatchedStatements=true,1000 条数据从 30 秒 → 0.5 秒。
答案:Apache POI 把整个 Excel 加载到内存(DOM 模式),10 万行就可能 OOM。EasyExcel 用 SAX 流式解析(逐行读取),无论多少行内存占用恒定(~10MB)。写入也同理,用滑动窗口只保留当前行。
答案:格式:秒 分 时 日 月 周。示例:"0 0 * * * ?"(每小时整点)、"0 30 2 * * ?"(每天 2:30)、"0 0/15 * * * ?"(每 15 分钟)。注意 Spring 用 6 位(含秒),Linux crontab 用 5 位(不含秒)。
答案:每个 Pod 独立运行 @Scheduled,3 个 Pod 就执行 3 次。解决:① Redis 分布式锁(SETNX);② XXL-JOB 集中调度(推荐,支持分片/重试/可视化日志);③ K8s CronJob。
答案:Extract(从数据源读取——API/数据库/CSV/消息队列)→ Transform(清洗——过滤异常值、格式转换、计算指标)→ Load(批量写入目标库)。关键设计:流式处理避免 OOM、分批写入、错误重试、监控告警。Spring Batch 框架提供了标准化的 ETL 管道。
答案:① MyBatis Cursor 游标查询(不 OOM);② EasyExcel 流式写入(内存恒定);③ 异步导出(任务提交 → 生成文件 → OSS 下载链接);④ 分页/分 Sheet 导出(每 10000 行一个 Sheet);⑤ CDN 加速大文件下载。
入门:Stream API → MyBatis 基础查询 → EasyExcel 导出
进阶:Cursor 游标查询 → 批量优化 → @Scheduled 定时任务
高级:Spring Batch ETL → XXL-JOB 分布式调度 → 大数据量优化
生产:异步报表 → OSS 文件存储 → 监控告警