Fastexcel 扩展实战:批处理并发写入与注解驱动读写,40W 数据仅需 2.6 秒

基于 fastexcel 扩展批量并发导出与注解式读写能力

https://github.com/sunnyboy3/fastexcel.git 欢迎fork

在 Java 项目中处理 Excel,常见诉求通常有两个:

  1. 大数据量导出要快,不能因为一次性加载过多数据导致内存压力过大。
  2. 业务代码要简单,最好能像 EasyExcel 一样,用实体类注解完成 Excel 列和 Java 字段的映射。

fastexcel 本身是一个轻量、高性能的 Excel 读写库,底层面向流式写入和读取,适合在服务端导出大文件。本文介绍的是在原有 fastexcel 框架上扩展出的两个模块,以及围绕批量导出补充的样式能力:

  • fastexcel-batch:支持分页、并发预取、多 Sheet 批量写入,提升大数据导出性能。
  • fastexcel-annotation:模仿 EasyExcel 的注解式用法,支持通过实体类属性注解完成 Excel 读取和写入。
  • 写入样式支持:通过 CellStyleSheetStyleMergedRegion 声明表头样式、列样式、列宽、冻结窗格和合并单元格。

这两个模块组合起来,可以让业务代码既保留 fastexcel 的性能优势,又获得更接近业务模型的开发体验。

一、为什么要扩展

原始 fastexcel 更偏底层 API,写入时通常需要直接操作 WorkbookWorksheet,例如指定某一行某一列写入什么值。这种方式足够灵活,但在业务系统里会带来一些重复代码:

  • 每个导出场景都要手动写表头。
  • 每个字段都要手动指定列号。
  • 大数据量导出时,需要自己处理分页、Sheet 拆分、线程池、异常和资源关闭。
  • 读取 Excel 时,需要自己从 RowCell 中取值再塞回实体类。

因此扩展的方向很明确:

  • 写入层:把分页、多线程、Sheet 拆分封装起来。
  • 映射层:把 Excel 列和 Java 字段的关系声明到实体类上。
  • 样式层:把表头、列宽、数字格式、冻结窗格、合并区域等写入样式声明成可复用模板。

二、模块一:fastexcel-batch 分页并发写入

fastexcel-batch 的核心类是:

java 复制代码
org.dhatim.fastexcel.batch.BatchExcelWriter

它的设计目标是把一个大数据导出任务拆成多个 Sheet,每个 Sheet 对应一次分页数据请求。在写当前 Sheet 的同时,后台线程可以提前获取后续 Sheet 的数据,从而减少写入线程等待数据源的时间。

2.1 核心能力

fastexcel-batch 主要提供以下能力:

  • 分页读取数据:通过 SheetDataProvideroffsetlimit 获取每个 Sheet 的数据。
  • 自动拆分 Sheet:通过 sheetSize 控制每个 Sheet 的最大行数。
  • 并发预取:通过 threadPoolSize 控制后台获取数据的线程数。
  • 预取队列背压:通过 queueCapacity 控制已经获取但尚未写入的 Sheet 数量。
  • 支持样式和合并单元格:通过 SheetStyleCellStyleMergedRegion 描述布局。
  • 支持注解实体写入:通过 RowWriters.annotation(...)fastexcel-annotation 模块联动。
  • 支持导出结果统计:通过 ExportResult 返回写入总行数和每个 Sheet 的结果。

2.2 导出配置 ExportOptions

批量导出使用 ExportOptions 描述导出参数:

java 复制代码
ExportOptions options = ExportOptions.builder(total)
        .sheetSize(100000)
        .threadPoolSize(10)
        .sheetNamePrefix("销售")
        .build();

常用参数说明:

参数 作用
totalRows 已知总行数,用于计算 Sheet 数量
sheetSize 每个 Sheet 写入多少行数据
threadPoolSize 并发获取分页数据的线程数
queueCapacity 预取队列容量,用于控制背压
sheetNamePrefix Sheet 名称前缀,例如生成 销售_1销售_2
fetchTimeoutMs 获取分页数据的超时时间
flushInterval 每隔多少行 flush 一次,降低大文件内存压力

如果总行数未知,也可以使用:

java 复制代码
ExportOptions options = ExportOptions.unbounded()
        .sheetSize(100000)
        .threadPoolSize(4)
        .build();

这种模式会持续分页获取数据,直到某次返回的数据量小于 sheetSize 或为空。

2.3 实体类定义

示例实体类如下:

java 复制代码
public static class Sale {
    @ExcelProperty(value = "区域", index = 0)
    private String region;

    @ExcelProperty(value = "产品", index = 1)
    private String product;

    @ExcelProperty(value = "数量", index = 2)
    private Integer quantity;

    @ExcelProperty(value = "金额", index = 3)
    private BigDecimal amount;

    public Sale() {
    }

    public Sale(String region, String product, Integer quantity, BigDecimal amount) {
        this.region = region;
        this.product = product;
        this.quantity = quantity;
        this.amount = amount;
    }
}

这里的 @ExcelProperty 同时指定了表头名称和列索引:

  • value:写出时作为表头名,读取时也可以按表头匹配。
  • index:指定字段所在列,从 0 开始。

2.4 分页数据提供者

在测试方法中,使用 mockPage 模拟分页数据:

java 复制代码
private static List<Sale> mockPage(long offset, int limit, int total) {
    List<Sale> list = new ArrayList<>(limit);
    long end = Math.min(offset + limit, total);
    for (long i = offset; i < end; i++) {
        list.add(new Sale(
                "华东",
                "产品-" + (i % 20),
                (int) (i % 100),
                new BigDecimal(String.format("%.2f", 100 + i * 3.5))));
    }
    return list;
}

实际业务中,这里通常会替换成数据库分页查询、ES 查询、接口分页调用等。

2.5 写出 Excel

完整写出逻辑可以参考测试方法:

java 复制代码
org.dhatim.fastexcel.batch.StyledBatchExportExampleTest#testFile

核心代码如下:

java 复制代码
int total = 400000;

ExportOptions options = ExportOptions.builder(total)
        .sheetSize(100000)
        .threadPoolSize(10)
        .sheetNamePrefix("销售")
        .build();

BatchExcelWriter<Sale, Void> writer = new BatchExcelWriter<>(
        options,
        (params, req) -> mockPage(req.getOffset(), req.getLimit(), total),
        RowWriters.annotation(Sale.class, sheetStyle));

ExportResult result = writer.export(
        WorkbookSink.toPath(Paths.get("/Users/wp/Documents/softwears/batch-writter.xlsx")),
        null);

这段代码完成了几件事:

  1. 设置总数据量为 40W。
  2. 每个 Sheet 写入 10W 行。
  3. 使用 10 个线程并发预取分页数据。
  4. 使用注解实体 Sale 自动写表头和数据列。
  5. 输出到本地 Excel 文件。

经测试,40W 数据写入耗时约 2.619s。测试入口为:

java 复制代码
org.dhatim.fastexcel.batch.StyledBatchExportExampleTest#testFile

如果你的计时器输出单位是毫秒或纳秒,建议在博客发布前统一换算单位。对于 40W 行 Excel 写入,通常用秒作为展示单位更符合读者预期。

2.6 写入样式支持

除了分页并发写入,fastexcel-batch 还扩展了写入样式能力。原始 fastexcel 虽然已经提供了底层样式 API,但在批量导出场景下,如果每个导出方法都手写样式设置,代码会很分散,也不容易复用。

因此这里引入了三类声明式对象:

作用
CellStyle 描述单元格样式,例如字体、颜色、填充色、对齐、边框、数字格式
SheetStyle 描述一个 Sheet 的整体布局,例如表头样式、列样式、列宽、冻结窗格、合并区域
MergedRegion 描述合并单元格区域,支持横向合并、纵向合并和任意矩形区域

样式定义示例:

java 复制代码
CellStyle headerStyle = CellStyle.builder()
        .bold()
        .fontColor("FFFFFF")
        .fillColor("4472C4")
        .horizontalAlignment("center")
        .verticalAlignment("center")
        .border("thin")
        .build();

CellStyle moneyStyle = CellStyle.builder()
        .fillColor("FFFFCC")
        .numberFormat("#,##0.00")
        .horizontalAlignment("right")
        .build();

SheetStyle sheetStyle = SheetStyle.builder()
        .headerStyle(headerStyle, 4)
        .columnStyle(3, moneyStyle)
        .columnWidth(0, 12)
        .columnWidth(1, 18)
        .columnWidth(2, 10)
        .columnWidth(3, 16)
        .freezeHeader()
        .dynamicMerges(dataRows -> dataRows >= 2
                ? Collections.singletonList(MergedRegion.column(0, 1, dataRows))
                : Collections.emptyList())
        .build();

这里有几个关键点:

  • headerStyle(headerStyle, 4):把表头样式应用到第 0 行的前 4 列。
  • columnStyle(3, moneyStyle):把金额列设置为统一数字格式和右对齐。
  • columnWidth(...):统一设置列宽,避免导出后列宽过窄。
  • freezeHeader():冻结首行,用户查看大文件时表头始终可见。
  • dynamicMerges(...):根据实际数据行数动态计算合并区域。

这个设计让数据写入和外观设置解耦:

  • RowWriter 只负责写数据。
  • SheetStyle 负责列宽、表头样式、列样式、冻结窗格、合并区域。

这样业务导出可以复用同一套样式模板,也便于维护。

2.6.1 CellStyle 支持哪些样式

CellStyle 使用 Builder 模式声明样式,常用能力包括:

  • 字体名称:fontName("Arial")
  • 字号:fontSize(12)
  • 字体颜色:fontColor("FFFFFF")
  • 加粗:bold()
  • 斜体:italic()
  • 下划线:underline()
  • 删除线:strikethrough()
  • 背景填充色:fillColor("4472C4")
  • 水平对齐:horizontalAlignment("center")
  • 垂直对齐:verticalAlignment("center")
  • 自动换行:wrapText()
  • 文本旋转:rotation(45)
  • 缩进:indent(1)
  • 数字格式:numberFormat("#,##0.00")
  • 边框:border("thin")
  • 边框颜色:borderColor("000000")

例如金额列样式可以这样写:

java 复制代码
CellStyle moneyStyle = CellStyle.builder()
        .fillColor("FFFFCC")
        .numberFormat("#,##0.00")
        .horizontalAlignment("right")
        .build();

这样写出的金额列会带有浅黄色背景、千分位两位小数格式,并右对齐显示。

2.6.2 SheetStyle 负责整张表布局

SheetStyle 不只负责单元格样式,它更像一个 Sheet 模板:

java 复制代码
SheetStyle sheetStyle = SheetStyle.builder()
        .headerStyle(headerStyle, 4)
        .columnStyle(3, moneyStyle)
        .columnWidth(0, 12)
        .columnWidth(1, 18)
        .columnWidth(2, 10)
        .columnWidth(3, 16)
        .freezeHeader()
        .build();

它可以把一类导出的"外观规范"固定下来。比如所有销售报表都使用蓝底白字表头、金额列右对齐、首行冻结,就可以把这套 SheetStyle 作为模板复用。

在批量写入时,通过下面的方式把样式和注解写入器组合起来:

java 复制代码
RowWriters.annotation(Sale.class, sheetStyle)

这样 BatchExcelWriter 写每个 Sheet 时,会自动应用表头样式、列宽、列样式和冻结窗格。

2.6.3 合并单元格:静态合并和动态合并

MergedRegion 用来描述合并区域,坐标都是从 0 开始:

java 复制代码
// 合并 A1:D1,适合作为标题横幅
MergedRegion title = MergedRegion.row(0, 0, 3);

// 合并 A2:A101,适合分组列纵向合并
MergedRegion group = MergedRegion.column(0, 1, 100);

// 任意矩形区域
MergedRegion box = MergedRegion.of(1, 0, 5, 3);

静态合并适合固定位置,例如标题横幅:

java 复制代码
SheetStyle style = SheetStyle.builder()
        .merge(MergedRegion.row(0, 0, 3))
        .build();

动态合并适合行数不固定的批量导出。例如每个 Sheet 的数据行数可能不同,可以根据实际写入行数合并区域列:

java 复制代码
SheetStyle style = SheetStyle.builder()
        .dynamicMerges(dataRows -> dataRows >= 2
                ? Collections.singletonList(MergedRegion.column(0, 1, dataRows))
                : Collections.emptyList())
        .build();

这里的 dataRows 是当前 Sheet 实际写入的数据行数,不包含表头。比如一个 Sheet 写了 100000 行数据,就可以把第 0 列从第 1 行合并到第 100000 行。

2.6.4 为什么样式这样设计

样式支持的设计重点有三个:

  1. 样式声明不可变、可复用

    CellStyleSheetStyle 都是构建后不可变对象,可以在多个 Sheet、多个导出任务中复用。

  2. 数据和外观分离

    业务代码只关心数据怎么来,样式模板只关心 Excel 长什么样。两者通过 RowWriters.styled(...)RowWriters.annotation(..., sheetStyle) 组合。

  3. 兼顾大文件写入性能

    表头样式、列宽、冻结窗格会在写入数据前应用;动态合并在数据写完后统一应用。列样式会尽量复用 fastexcel 底层样式缓存,避免每个单元格重复创建样式对象。

对于后台导出而言,样式不只是"好看",它还直接影响可读性。比如冻结表头、金额格式、列宽、合并分组列,都能让几十万行数据导出后更容易查看和交付。

三、模块二:fastexcel-annotation 注解式读写

fastexcel-annotation 的目标是让 fastexcel 支持类似 EasyExcel 的实体类映射方式。

核心能力包括:

  • @ExcelProperty:声明字段和 Excel 列的关系。
  • @ExcelIgnore:忽略不需要读写的字段。
  • @ExcelConverter:指定字段级自定义转换器。
  • ExcelWriterMapper:把 Java Bean 写入 Excel。
  • ExcelReaderMapper:把 Excel 行读取成 Java Bean。
  • ConverterRegistry:按类型注册全局转换器。

3.1 @ExcelProperty

@ExcelProperty 可以按列索引映射:

java 复制代码
@ExcelProperty(index = 0)
private String name;

也可以按表头名称映射:

java 复制代码
@ExcelProperty("Name")
private String name;

两者同时存在时,index 优先级更高。

3.2 注解式写入

使用 ExcelWriterMapper 可以直接把实体列表写入 Excel:

java 复制代码
ExcelWriterMapper<Sale> mapper = new ExcelWriterMapper<>(Sale.class);

try (Workbook wb = new Workbook(outputStream, "App", "1.0")) {
    Worksheet ws = wb.newWorksheet("销售");
    mapper.write(ws, sales, 0, true);
}

其中 includeHeader = true 时,会自动根据 @ExcelProperty(value = "...") 写出表头。

fastexcel-batch 中,也可以直接这样使用:

java 复制代码
RowWriters.annotation(Sale.class)

这会把注解式写入能力包装成批量写入框架可识别的 RowWriter

3.3 注解式读取

读取时使用 ExcelReaderMapper

java 复制代码
ExcelReaderMapper<Sale> mapper = new ExcelReaderMapper<>(Sale.class);

try (ReadableWorkbook wb = new ReadableWorkbook(file);
     Stream<Row> rows = wb.getFirstSheet().openStream()) {

    List<Sale> sales = rows
            .skip(1)
            .map(mapper::map)
            .collect(Collectors.toList());
}

这里 .skip(1) 是为了跳过表头行。如果实体字段完全依赖表头名称匹配,也可以使用 mapper.stream(sheet),让 mapper 自动消费表头行。

3.4 读取多个 Sheet

如果一个 Excel 文件有多个 Sheet,需要通过 ReadableWorkbook#getSheets() 遍历全部 Sheet:

java 复制代码
ExcelReaderMapper<Sale> mapper = new ExcelReaderMapper<>(Sale.class);
List<Sale> allRows = new ArrayList<>();

try (ReadableWorkbook wb = new ReadableWorkbook(file)) {
    List<Sheet> sheets = wb.getSheets().collect(Collectors.toList());
    for (Sheet sheet : sheets) {
        try (Stream<Row> rows = sheet.openStream()) {
            List<Sale> sheetRows = rows
                    .skip(1)
                    .map(mapper::map)
                    .collect(Collectors.toList());
            allRows.addAll(sheetRows);
        }
    }
}

测试方法:

java 复制代码
org.dhatim.fastexcel.annotation.ExcelReaderMapperTest#readFile

读取 StyledBatchExportExampleTest#testFile 写出的文件时,输出示例:

text 复制代码
销售_1: 100000
销售_2: 100000
销售_3: 100000
销售_4: 100000
total: 400000

如果文件路径不固定,可以通过 JVM 参数指定:

bash 复制代码
-Dfastexcel.reader.test.file=/path/to/your.xlsx

3.5 try-with-resources 自动关闭资源

读取 Excel 时推荐使用 try-with-resources:

java 复制代码
try (ReadableWorkbook wb = new ReadableWorkbook(file)) {
    // read sheets
}

ReadableWorkbook 实现了 Closeable,退出 try 块时会自动调用 close(),释放底层 ZIP 包资源。

如果使用 sheet.openStream(),行流也建议单独关闭:

java 复制代码
try (ReadableWorkbook wb = new ReadableWorkbook(file)) {
    for (Sheet sheet : wb.getSheets().collect(Collectors.toList())) {
        try (Stream<Row> rows = sheet.openStream()) {
            // read rows
        }
    }
}

这对大文件尤其重要,能避免文件句柄、XML 解析器、ZIP entry 等资源长时间占用。

四、整体设计思路

这次扩展可以概括为两层:

4.1 数据层:批量写入流水线

fastexcel-batch 负责处理导出流程:

text 复制代码
分页请求 -> 并发预取 -> 顺序写入 Sheet -> 样式应用 -> 输出结果

它关心的是性能、并发、分页、Sheet 拆分和资源管理。

4.2 映射层:注解式实体映射

fastexcel-annotation 负责处理对象和 Excel 列之间的关系:

text 复制代码
Java Bean <-> @ExcelProperty <-> Excel Row/Cell

它关心的是业务代码简洁性、字段类型转换、表头映射和读写统一。

两个模块组合后,业务代码可以只关心数据从哪里来、导出到哪里去,而不需要反复编写列映射、分页循环、Sheet 拆分等模板逻辑。

五、适用场景

这套扩展比较适合以下场景:

  • 后台管理系统导出几十万到百万级数据。
  • 数据源支持分页查询,例如 MySQL、PostgreSQL、ES、远程 API。
  • 导出结果需要拆分多个 Sheet。
  • 导出列和实体字段关系稳定。
  • 希望减少手写单元格代码。
  • 希望保留 fastexcel 的轻量和高性能优势。

六、注意事项

6.1 Sheet 行数限制

.xlsx 单个 Sheet 最大行数是 1,048,576 行。实际业务中建议设置更小的 sheetSize,例如 5W、10W、20W,便于打开和查看。

6.2 并发数不是越大越好

threadPoolSize 主要影响数据获取阶段。如果数据源是数据库,并发过高可能反而增加数据库压力。建议根据数据源类型调整:

  • 数据库分页查询:一般 4 到 8 个线程即可。
  • 远程接口:可以适当增大,但要考虑限流。
  • CPU 密集型转换:线程数接近 CPU 核数。

6.3 读取大文件时优先使用流式 API

ExcelReaderMapper#map(Sheet) 会先把所有行读到 List 中。对于大文件,更推荐:

java 复制代码
try (Stream<Row> rows = sheet.openStream()) {
    rows.map(mapper::map).forEach(...);
}

或在按表头自动映射时使用:

java 复制代码
try (Stream<Sale> sales = mapper.stream(sheet)) {
    sales.forEach(...);
}

6.4 表头行要特别处理

如果使用列索引读取,表头行不会自动跳过,需要手动 .skip(1)

如果使用表头名称读取,ExcelReaderMapper 可以自动消费第一行作为表头,但要确保实体字段没有显式 index,或者按你的业务逻辑手动调用 resolveHeaderRow

七、总结

本次扩展让 fastexcel 在两个方向上变得更适合业务系统:

  • fastexcel-batch 解决大数据量导出中的分页、并发、拆 Sheet 和样式问题。
  • fastexcel-annotation 解决实体类和 Excel 列之间的映射问题,让读写代码更接近 EasyExcel 的使用体验。

对于需要高性能导出,又希望代码简洁可维护的项目来说,这种设计可以在性能和易用性之间取得一个不错的平衡。

最终效果是:开发者只需要定义实体类、分页数据源和导出参数,就可以完成几十万行 Excel 的高性能导出,并且可以用同一套注解模型再把 Excel 文件读回业务对象。