Hardwood 1.1.0.Beta2 在北京时间 10 月 12 日凌晨发布,这是 1.1 的第二个 Beta。写入端可以自己决定在哪里切 row group,单条坏记录也能跳过,writer 不会因此中断。tryWriteRow 所在的 RowWriter 和它返回的 RowWriteResult 在源码里都标着 @Experimental,footer 缓存 API 也被官方公告列为实验性,后续版本里形状还可能变。我在 Spring Boot 4.1.1 里按月份往一个文件写了 3000 行,切 row group 和跳过坏记录这两项都和发布说明一致。

Hardwood 由 Gunnar Morling 发起,用来读写 Apache Parquet 文件,不用拉进 Hadoop、Avro 和 parquet-java 那一整串依赖,除了可选的压缩库没有传递依赖,Apache 2.0 许可。发布公告见他的博客 Hardwood 1.1.0.Beta2 Is Out,完整变更见 GitHub release。下面的特性介绍以这两处为准,示例和输出是我自己跑的。

写入端多了什么

以前 row group 多大,全看行数和内存占用两个阈值。现在多了 ParquetFileWriter.endRowGroup(),调用一次就把当前 row group 收尾。公告里的例子是按月写事件,月份一变就切一次,这样查 10 月数据时,读端只碰 10 月的 row group。

RowWriter.tryWriteRow(...) 用来处理坏记录。schema 装不下的记录,比如 REQUIRED 字段给了 null,会返回 RowWriteResult.Rejected,writer 还能接着写,close() 时所有被接受的记录照常落盘。原来的 writeRow 遇到同样情况会抛 IllegalArgumentException,writer 随之失效,不再接收记录,close() 时整份输出都会丢弃。

另外两条和失败有关。写的过程中抛了异常,close() 会丢弃输出,不再留下半个文件。abort() 可以主动放弃这次写入。

写出的文件现在也带 page index(ColumnIndex 和 OffsetIndex)和 Bloom filter。Bloom filter 只为指定的列生成,默认误判率 1%,可以按列调。OutputFile.inMemory() 能直接把文件写进内存。

读取端和性能

公告说远程读 S3 时请求数少了。page index 只取投影到的列,一次请求覆盖多个 row group。读一个 row group 时会预取下一个的第一个 column chunk。多文件读取改成读到哪个文件才打开哪个。

项目组拿 Beta1 和 Beta2 跑了基准对比,25 个场景里 21 个变快,另外 4 个在噪声范围内。S3 上的 Bloom filter 点查从 6.8 s 降到 163 ms。

footer 缓存的扩展点叫 MetadataSource,通过 HardwoodContext.builder() 装上。缓存的 footer 会用文件标识核对,S3 上就是 ETag,对不上会抛 StaleMetadataException。字典编码的列可以用 getDictionaryIds() 按字典 id 读,省掉逐行解码字符串。

升级前要看的破坏性变更

ColumnReaders、RowReader、PqStruct 上按下标取值的方法,现在按 ColumnProjection.columns(...) 里列出的顺序算,不再按 schema 顺序。之前按下标取值又用了投影的代码要检查一遍。

异常模型也改了。IOException 只表示传输失败。文件损坏抛的是非受检的 ParquetReadException,SchemaIncompatibleException 继承自它。原来靠 catch IOException 处理坏文件的代码会漏掉这类异常。

这一版还修了几处过滤结果出错的问题,涉及 NaN、无符号整数列等情况。用了这些过滤条件的,结果可能和以前不一样。

在 Spring Boot 里跑一遍

环境是 Spring Boot 4.1.1、OpenJDK 21.0.12.1,项目从 start.spring.io 生成,只加了 dev.hardwood:hardwood-core:1.1.0.Beta2 一个依赖。示例写 202608、202609、202610 三个月的事件,每月 1000 行,id 从 1 递增,schema 是 month、id、user 三列,都是 REQUIRED,id 是 400 的倍数时故意给 user 塞一个 null。

写入时月份一换就调 endRowGroup(),每条记录交给 tryWriteRow:

if (m > 0) {
    writer.endRowGroup();   // 换月,收尾上一个 row group
}
RowWriteResult result = rows.tryWriteRow(row -> row
        .setInt("month", month)
        .setLong("id", rowId)
        .setString("user", user));
if (result instanceof RowWriteResult.Rejected rejected) {
    log.info("skipped id={} field={} reason={}", rowId, rejected.fieldPath(), rejected.message());
}

读回时用 FilterPredicate.eq 只取 202610 这个月:

try (RowReader rows = reader.buildRowReader()
        .filter(FilterPredicate.eq("month", 202610))
        .build()) {
    while (rows.hasNext()) {
        rows.next();
        matched++;
        minId = Math.min(minId, rows.getLong(1));
    }
}

./mvnw package 打包后用 java -jar 运行,日志如下(省去了时间戳、线程等前缀):

skipped id=400 field=user reason=Field user is REQUIRED; it must be set to a non-null value in every record
skipped id=800 field=user reason=Field user is REQUIRED; it must be set to a non-null value in every record
skipped id=1200 field=user reason=Field user is REQUIRED; it must be set to a non-null value in every record
skipped id=1600 field=user reason=Field user is REQUIRED; it must be set to a non-null value in every record
skipped id=2000 field=user reason=Field user is REQUIRED; it must be set to a non-null value in every record
skipped id=2400 field=user reason=Field user is REQUIRED; it must be set to a non-null value in every record
skipped id=2800 field=user reason=Field user is REQUIRED; it must be set to a non-null value in every record
rejected rows: 7
file rows=2993 rowGroups=3
rowGroup[0] rows=998
rowGroup[1] rows=997
rowGroup[2] rows=998
month=202610 rows=998 firstId=2001

7 条坏记录被跳过,每条都带了字段名和原因,writer 没有中断。文件最后是 2993 行、3 个 row group,每个 row group 正好是一个月,行数等于 1000 减去当月被跳过的条数(8 月 2 条、9 月 3 条、10 月 2 条)。按月份过滤拿回 998 行,第一条 id 是 2001,就是 10 月的第一条。

被拒的原因文本和 writeRow 抛异常时的消息相同,RowWriteResult.Rejected 的源码注释里也是这么写的。

Hardwood 1.1 目前还在 Beta。公告说项目正接近 release candidate 阶段,接下来写入端会补上 S3 支持和 column chunk 并行。

— 感谢阅读 —
广告

一起交流

分享你的思考,让讨论更进一步。