Gunnar Morling 是 Confluent 的技术专家、Java Champion,也是 One Billion Rows Challenge(1BRC)的发起人。他在最近一期播客中聊了一个很实际的问题:Java 在数据密集场景下到底能不能做到足够快、足够轻?从 1BRC 的极限优化实验,到用 AI 原生方式开发 Apache Hardwood——一个几乎零依赖的 Java Parquet 解析器,他给出的答案是:能,但路径要选对。
1BRC:把 Java 逼到极限
1BRC 的任务很简单:从 10 亿行气象数据中计算每个城市的最低、最高温度和平均温度。听起来像数据库该干的事,但规则要求纯 Java、单线程、不依赖外部框架。这个约束把所有参与者逼到了 JVM 的底层——ByteBuffer 直接映射文件、SIMD 向量化、手动内存布局对齐。
Morling 提到,最终冠军方案的处理速度达到了每秒数 GB,远超很多人对 Java 的预期。关键不是语言本身慢,而是大多数人写 Java 时默认走的路径太重:堆上分配对象、GC 承压、IO 走 InputStream 包装链。1BRC 证明了一件事——当你绕过这些默认路径,直接操作内存和字节,Java 的性能天花板和 C/Rust 并没有本质差距。
轻量依赖不是口号,是工程选择
播客中另一个核心话题是 Apache Hardwood。这是 Morling 正在开发的 Java Parquet 解析器,目标是"最小依赖"——不拉 Hadoop 生态那套重型依赖链,直接解析 Parquet 的列式存储格式。
Parquet 是数据工程领域的事实标准列式格式,但 Java 生态里解析 Parquet 的主流路径要依赖 hadoop-common、hadoop-mapreduce-client-core 等一长串包,光是依赖传递就能拉进几十个 jar。Hardwood 的思路是:只解析格式本身,不碰 Hadoop 的文件系统抽象、不碰分布式调度,把依赖树控制在个位数以内。
这个选择背后有明确的工程判断:如果你只是要从本地或 S3 上的一个 .parquet 文件里读几列数据,为什么要加载整个 Hadoop 运行时?
用 AI 原生方式开发解析器
播客标题里提到的"AI natively developing"值得展开。Morling 不是让 AI 写完整个项目,而是把 AI 当作解析器开发的加速器——Parquet 的 Thrift IDL 定义了文件元数据的结构,AI 可以快速生成对应的 Java 读取骨架;字节偏移、列 chunk 定位这些机械但容易出错的细节,AI 辅助比手写更高效。
他的实践方式大致是:先让 AI 根据 Parquet 规范生成结构定义和基础解析逻辑,再人工审查和优化关键路径(比如字典解码、RLE 解压缩)。这不是"AI 写代码人不管",而是"AI 做粗活人做精调"。
实战:用最小依赖读取 Parquet 文件
Hardwood 还在开发中,但思路可以立刻用到你的项目里。下面演示一个不依赖 Hadoop 生态、直接从 Parquet 文件读取数据的最小化方案——使用 parquet-avro 的精简依赖配置,或者更激进地直接操作字节。
先看一个用 Apache Parquet 的轻量读取方式(只拉必要依赖,避免 Hadoop 传染):
<!-- pom.xml — 只引入 parquet-avro 和 hadoop-common 的最小集 -->
<dependencies>
<dependency>
<groupId>org.apache.parquet</groupId>
<artifactId>parquet-avro</artifactId>
<version>1.13.1</version>
</dependency>
<!-- hadoop-common 是当前不可避免的,但排除重型传递依赖 -->
<dependency>
<groupId>org.apache.hadoop</groupId>
<artifactId>hadoop-common</artifactId>
<version>3.3.6</version>
<exclusions>
<exclusion>
<groupId>org.apache.hadoop</groupId>
<artifactId>hadoop-hdfs-client</artifactId>
</exclusion>
<exclusion>
<groupId>com.google.guava</groupId>
<artifactId>guava</artifactId>
</exclusion>
<exclusion>
<groupId>org.apache.zookeeper</groupId>
<artifactId>zookeeper</artifactId>
</exclusion>
<!-- 根据你的实际需要继续排除 -->
</exclusions>
</dependency>
</dependencies>
对应的 Java 读取代码:
import org.apache.avro.generic.GenericRecord;
import org.apache.hadoop.fs.Path;
import org.apache.parquet.hadoop.ParquetReader;
import org.apache.parquet.avro.AvroParquetReader;
import java.io.IOException;
public class MinimalParquetRead {
public static void main(String[] args) throws IOException {
// 本地文件路径,不依赖 HDFS
Path filePath = new Path("data/weather.parquet");
// 使用 AvroParquetReader 直接读取,无需 MapReduce 上下文
ParquetReader<GenericRecord> reader = AvroParquetReader
.builder(filePath)
.build();
GenericRecord record;
long count = 0;
while ((record = reader.read()) != null) {
// 只读你需要的列,不反序列化整行
String city = record.get("city").toString();
double temperature = (Double) record.get("temperature");
if (count < 5) {
System.out.printf("%s: %.1f°C%n", city, temperature);
}
count++;
}
reader.close();
System.out.printf("Total rows: %d%n", count);
}
}
运行前准备一个测试 Parquet 文件:
# 用 Python 快速生成一个示例 Parquet 文件
pip install pyarrow
python3 -c "
import pyarrow as pa
import pyarrow.parquet as pq
import numpy as np
cities = ['Beijing', 'Shanghai', 'Guangzhou', 'Shenzhen', 'Chengdu']
n = 1000
table = pa.table({
'city': pa.array(np.random.choice(cities, n)),
'temperature': pa.array(np.random.normal(25, 5, n).astype(np.float64))
})
pq.write_table(table, 'data/weather.parquet')
print('Written', n, 'rows to data/weather.parquet')
"
# 编译运行
mvn compile exec:java -Dexec.mainClass="MinimalParquetRead"
如果你想更激进——完全不依赖 Hadoop 类,可以参考 Hardwood 的思路,直接用 ByteBuffer 读取 Parquet 的 magic bytes 和 row group 元数据:
import java.nio.ByteBuffer;
import java.nio.channels.FileChannel;
import java.nio.file.StandardOpenOption;
import java.io.IOException;
public class RawParquetProbe {
public static void main(String[] args) throws IOException {
// 直接打开文件,检查 Parquet magic header
FileChannel channel = FileChannel.open(
java.nio.file.Path.of("data/weather.parquet"),
StandardOpenOption.READ
);
ByteBuffer header = ByteBuffer.allocate(4);
channel.read(header, 0);
header.flip();
String magic = new String(header.array(), "UTF-8");
System.out.println("Parquet magic: " + magic); // 应输出 "PAR1"
// 读取文件尾部的 footer length(最后 4 字节)
long fileSize = channel.size();
ByteBuffer tail = ByteBuffer.allocate(8);
channel.read(tail, fileSize - 8);
tail.flip();
// 最后 4 字节也是 "PAR1",前 4 字节是 footer 长度
int footerLength = tail.getInt();
String tailMagic = new String(tail.array(), 4, 4, "UTF-8");
System.out.printf("Footer length: %d bytes, tail magic: %s%n",
footerLength, tailMagic);
channel.close();
}
}
这段代码零外部依赖,只用 JDK 自带的 NIO API 就能验证 Parquet 文件结构。这正是 Hardwood 要走的路——从字节层开始,不借 Hadoop 的抽象层。
什么时候该走轻量路径
Morling 的实践给出了几个明确的判断点:
- 你的场景是读取而非写入分布式存储——如果只是从本地或对象存储读 Parquet 列数据,Hardwood 式的轻量解析器比 Hadoop 依赖链更合适。
- 你需要嵌入到其他系统——Kafka Connect、Flink 作业、嵌入式分析引擎里,依赖树越浅,冲突越少,启动越快。
- 你在做极限性能优化——1BRC 的经验表明,当你需要压到每秒数 GB 的吞吐,必须绕过 InputStream 包装链和堆上对象分配,直接操作
ByteBuffer或sun.misc.Unsafe(有风险但可控)。
反过来,如果你依赖 HDFS 的纠删码、租约恢复、分布式一致性,那 Hadoop 生态的重量是合理的——不要为了"轻"牺牲你实际需要的系统能力。
检查清单
在决定是否走轻量 Java 数据处理路径时,可以快速过一遍:
- [ ] 当前项目解析 Parquet 时,
hadoop-*依赖是否超过 5 个 jar? - [ ] 你的读取场景是否只涉及本地文件或 S3/OSS 对象存储?
- [ ] 启动时间是否被 Hadoop 初始化拖慢(典型症状:冷启动超过 10 秒)?
- [ ] 是否有依赖冲突导致
NoSuchMethodError或ClassNotFoundException? - [ ] 是否需要单线程或低线程数下的极限吞吐?
如果超过 3 项命中,值得认真评估 Hardwood 式的轻量替代路径。1BRC 已经证明了 Java 在数据密集场景的性能潜力,Hardwood 正在证明依赖树可以砍到多短——这两件事合在一起,才是"高效 Java 开发"的完整含义。