4. Apache POI 实战:HSSF 与 XSSF 区别、SXSSF 流式写入原理、Workbook/Sheet/Row/Cell 操作、样式与公式注入

Apache POI 是 Java 生态中操作 Office 文档的事实标准。在报表导出场景中,POI 的三大子模块——HSSF、XSSF、SXSSF——分别对应不同的内存模型与写入策略。选错模型可能导致百万行数据导出时 OOM,或 10 万行数据写入耗时数分钟。本章从架构视角拆解三者的差异、流式写入原理,并给出 Workbook/Sheet/Row/Cell 的实操模板,最后覆盖样式与公式注入的常见陷阱。

4.1 HSSF vs XSSF vs SXSSF:三大模型对比

POI 的三个子模块并非简单的版本升级,而是针对不同数据规模与文件格式的独立实现。HSSF 操作 Excel 97-2003 (.xls),XSSF 操作 Excel 2007+ (.xlsx),SXSSF 是 XSSF 的流式变体。下表从核心维度进行对比:

维度 HSSF XSSF SXSSF
文件格式 .xls (BIFF8) .xlsx (OOXML) .xlsx (OOXML)
内存模型 全部加载到内存 全部加载到内存 滑动窗口 + 临时文件
最大行数 65,536 1,048,576 1,048,576
写入速度(10万行) 慢(约 30s) 中等(约 15s) 快(约 5s)
内存占用(10万行) 高(约 500MB) 高(约 600MB) 低(约 50MB)
随机读支持 是 是 否(仅写模式)
适用场景 遗留系统、小数据量 中等数据量、需要模板编辑 大数据量导出、流式写入
架构决策提示: 在报表导出系统中,如果数据量超过 5 万行,应默认选择 SXSSF。HSSF 仅用于兼容旧版 Excel 的遗留接口,XSSF 适合需要频繁读取模板或修改已有工作簿的场景。

4.2 SXSSF 流式写入原理

SXSSF 的核心思想是“滑动窗口 + 临时文件”。它并不将整个 Workbook 保存在内存中,而是维护一个可配置的行窗口(默认 100 行),窗口之外的行被序列化到临时文件。当调用 flushRows() 或窗口滑动时,旧行被写入磁盘,内存只保留当前窗口内的行对象。

SXSSF 流式写入原理:滑动窗口 + 临时文件 内存(滑动窗口) Row 101 - Row 200 (当前活跃行) 窗口大小:默认 100 行 可配置:new SXSSFWorkbook(windowSize) 临时文件(磁盘) Row 1 - Row 100 (已刷出行) 路径:java.io.tmpdir 前缀:poi-sxssf-sheet flushRows() 最终输出:单一 .xlsx 文件

关键参数与行为:

  • 窗口大小(windowSize): 通过 new SXSSFWorkbook(100) 设置,表示内存中最多保留 100 行。超过时最早的行被刷入临时文件。
  • 临时文件清理: 调用 workbook.dispose() 后,临时文件被删除。若 JVM 异常退出,临时文件可能残留,建议在 finally 块中确保清理。
  • 限制: SXSSF 不支持 getRow() 随机读取已刷出的行,因此无法在写入后修改已刷出的数据。如果需要模板读取 + 大数据写入,应先用 XSSF 读取模板,再转为 SXSSF 写入。
常见陷阱: 不要对 SXSSFWorkbook 调用 getSheetAt(0).getRow(0) 来读取已写入的数据——如果该行已被刷出,会返回 null。SXSSF 是“只写”模型,所有读取操作应在刷出前完成。

4.3 Workbook / Sheet / Row / Cell 操作实战

无论使用 HSSF、XSSF 还是 SXSSF,Workbook、Sheet、Row、Cell 的 API 接口是统一的(均继承自 org.apache.poi.ss.usermodel 包)。以下代码展示一个完整的报表导出模板:

// 1. 创建工作簿(根据数据量选择实现类)
SXSSFWorkbook workbook = new SXSSFWorkbook(100); // 窗口大小 100
// XSSFWorkbook workbook = new XSSFWorkbook();   // 小数据量
// HSSFWorkbook workbook = new HSSFWorkbook();   // 仅 .xls

// 2. 创建 Sheet
Sheet sheet = workbook.createSheet("报表数据");

// 3. 创建标题行(Row 0)
Row headerRow = sheet.createRow(0);
String[] headers = {"订单ID", "客户名称", "金额", "状态"};
for (int i = 0; i < headers.length; i++) {
    Cell cell = headerRow.createCell(i);
    cell.setCellValue(headers[i]);
    // 样式注入(见 4.4 节)
    CellStyle headerStyle = workbook.createCellStyle();
    Font headerFont = workbook.createFont();
    headerFont.setBold(true);
    headerFont.setFontHeightInPoints((short) 12);
    headerStyle.setFont(headerFont);
    headerStyle.setFillForegroundColor(IndexedColors.LIGHT_BLUE.getIndex());
    headerStyle.setFillPattern(FillPatternType.SOLID_FOREGROUND);
    cell.setCellStyle(headerStyle);
}

// 4. 写入数据行(从 Row 1 开始)
List<Order> orders = fetchOrders(); // 假设有 10 万条数据
int rowNum = 1;
for (Order order : orders) {
    Row row = sheet.createRow(rowNum++);
    row.createCell(0).setCellValue(order.getId());
    row.createCell(1).setCellValue(order.getCustomerName());
    row.createCell(2).setCellValue(order.getAmount());
    row.createCell(3).setCellValue(order.getStatus());
    
    // 每 1000 行触发一次 flush,防止内存堆积
    if (rowNum % 1000 == 0) {
        ((SXSSFSheet) sheet).flushRows(100); // 保留最近 100 行
    }
}

// 5. 输出到文件
try (FileOutputStream fos = new FileOutputStream("report.xlsx")) {
    workbook.write(fos);
} finally {
    workbook.dispose(); // 清理临时文件
}
性能要点: 对于 SXSSF,建议每 500-1000 行调用一次 flushRows(),避免单次刷出行数过多导致 I/O 抖动。同时,createRow() 和 createCell() 应尽量复用对象引用,减少 GC 压力。

4.4 样式与公式注入

样式和公式是报表导出中最容易出错的环节。POI 的样式对象(CellStyle)是 Workbook 级别的共享资源,每个 Workbook 最多支持约 64,000 种样式(XSSF/SXSSF 限制更宽松,但仍建议复用)。公式注入则需注意公式字符串的语法与 Excel 兼容性。

4.4.1 样式复用策略

错误的做法是为每个 Cell 创建独立的 CellStyle,这会导致样式表爆炸,文件体积剧增。正确的做法是预定义样式池:

// 样式池:在 Workbook 创建后立即初始化
Map<String, CellStyle> stylePool = new HashMap<>();

// 创建并缓存样式
private CellStyle getStyle(String key, SXSSFWorkbook wb) {
    return stylePool.computeIfAbsent(key, k -> {
        CellStyle style = wb.createCellStyle();
        Font font = wb.createFont();
        switch (k) {
            case "header":
                font.setBold(true);
                font.setColor(IndexedColors.WHITE.getIndex());
                style.setFont(font);
                style.setFillForegroundColor(IndexedColors.DARK_BLUE.getIndex());
                style.setFillPattern(FillPatternType.SOLID_FOREGROUND);
                break;
            case "money":
                style.setDataFormat(wb.createDataFormat().getFormat("#,##0.00"));
                break;
            case "date":
                style.setDataFormat(wb.createDataFormat().getFormat("yyyy-MM-dd"));
                break;
        }
        return style;
    });
}

// 使用时直接获取
Cell cell = row.createCell(2);
cell.setCellValue(12345.67);
cell.setCellStyle(getStyle("money", workbook));

4.4.2 公式注入

POI 支持通过 setCellFormula() 注入公式,公式字符串必须使用英文语法,且引用单元格时使用 Excel 的 A1 引用风格。常见场景包括汇总行和条件计算:

// 在数据行写入完成后,在最后一行添加汇总公式
int lastRowNum = sheet.getLastRowNum();
Row summaryRow = sheet.createRow(lastRowNum + 1);

// SUM 公式:对 C2 到 C{lastRowNum} 求和
Cell sumCell = summaryRow.createCell(2);
sumCell.setCellFormula("SUM(C2:C" + lastRowNum + ")");

// 设置公式单元格样式(可选)
CellStyle sumStyle = workbook.createCellStyle();
sumStyle.setDataFormat(workbook.createDataFormat().getFormat("#,##0.00"));
Font sumFont = workbook.createFont();
sumFont.setBold(true);
sumStyle.setFont(sumFont);
sumCell.setCellStyle(sumStyle);

// 公式不会自动计算,需要手动触发或依赖 Excel 打开时计算
// 如果需要预计算,使用 FormulaEvaluator
FormulaEvaluator evaluator = workbook.getCreationHelper().createFormulaEvaluator();
evaluator.evaluateFormulaCell(sumCell);
公式注入陷阱:
  • SXSSF 不支持公式预计算(evaluateFormulaCell 会抛出异常),因为已刷出的行无法被读取。解决方案:在写入数据时同时维护一个内存中的汇总值,最后直接写入计算结果而非公式。
  • 公式中的行号引用必须使用绝对行号(如 C2:C100000),不要使用 C:C 整列引用,否则 Excel 打开时可能因引用范围过大而卡死。
  • 跨 Sheet 引用时,Sheet 名必须用单引号包裹(如 'Sheet1'!A1),且 Sheet 名中不能包含特殊字符。

4.4.3 样式与公式的架构建议

在报表导出系统中,样式和公式应作为“模板配置”的一部分,而非硬编码在代码中。推荐的做法是:

  • 样式模板化: 将颜色、字体、边框、数据格式等定义在配置文件或数据库中,运行时动态构建样式池。
  • 公式模板化: 使用占位符(如 ${sumRange})替换公式中的动态部分,避免字符串拼接错误。
  • 性能监控: 在导出日志中记录样式创建数量(workbook.getNumCellStyles()),超过 1000 种样式时发出告警,提示优化样式复用。
样式与公式注入架构:从模板配置到单元格 配置层 样式定义(颜色/字体/格式) 公式模板(占位符替换) 引擎层 样式池(Map<String, CellStyle>) 公式解析器(占位符 → 实际引用) 执行层 Row.createCell() cell.setCellStyle() cell.setCellFormula() 输出:样式数量可控(通常 < 50 种),公式引用正确,文件体积最小化