当你的Java应用还在“啃”CSV时,聪明人已经用Parquet “喝”数据了

8 阅读6分钟

当你的Java应用还在“啃”CSV时,聪明人已经用Parquet “喝”数据了

各位Java开发同胞们,大家好! 今天咱们不聊虚无缥缈的架构,也不卷那些花里胡哨的新框架。咱们来聊聊一个 “看着平平无奇,实则内功深厚” 的文件格式——Apache Parquet

如果你现在的项目还在用 CSV 存海量数据,每次读取都像在等Windows 98开机一样漫长,那这篇博客就是为你量身定做的“急救包”。


一、 什么是 Parquet?别再把它当成普通文件了

简单来说,Parquet 是一种为 Hadoop 生态圈设计的列存式(Columnar)存储格式

我知道,听到“Hadoop”有些同学已经开始关网页了,别急!这玩意儿早就出圈了,现在是大数据的通用货币。

行存 vs 列存:一场关于吃面的战争 🍜

为了让你秒懂,咱们举个栗子🌰:

假设你有一张用户表:ID, Name, Age, Email, Address

  • CSV(行存) :就像一碗杂酱面。所有的数据(面条、菜码、炸酱)都拌在一起。你想吃一口黄瓜丝(只查Address字段),对不起,你得把这勺面全吃下去(扫描整行),再把剩下的吐出来(过滤其他字段)。IO 开销极大,数据库表示很累。
  • Parquet(列存) :就像回转寿司。ID是一条传送带,Name是一条传送带。你想看所有人的Email?直接去Email那条传送带拿就行了,不用碰其他数据。而且因为同一列数据类型一致,压缩率极高(通常能比CSV小3-5倍)。

结论: ​ Parquet = 省空间​ + 省IO​ + 速度快


二、 什么时候该用 Parquet?(应用场景)

  1. 数据分析(OLAP) :如果你经常写 SELECT COUNT(*)SUMGROUP BY,Parquet 是你的神。
  2. 数据仓库(Data Lake) :存原始日志、埋点数据。几百G的日志文件?用 Parquet 压缩一下,老板看了都说省钱(存储费)。💸
  3. ETL 中间结果:数据管道中,不同阶段的数据交换格式。
  4. Java 微服务里的批量导出:别再导出Excel把内存撑爆了,试试 Parquet。

三、 Java 落地实战:如何优雅地“盘”它?

好了,干货来了。在 Java 里操作 Parquet,主要有两种方式: “原装硬核派” (直接使用 Parquet libs)和 “工具人躺平派” (使用 Apache Arrow 或 Spark,但在 JVM 里运行)。

对于大多数后台开发,直接使用 Parquet-MR (parquet-avro/parquet-hadoop) ​ 是最直接的。

1. 依赖准备:先把锅烧热 🍳

在你的 pom.xml里加上这些佐料:

<dependencies>
    <!-- Parquet 核心 -->
    <dependency>
        <groupId>org.apache.parquet</groupId>
        <artifactId>parquet-hadoop</artifactId>
        <version>1.13.1</version> <!-- 版本号请检查最新 -->
    </dependency>
    <!-- 我们需要 Avro 来定义 Schema,因为它最方便 -->
    <dependency>
        <groupId>org.apache.parquet</groupId>
        <artifactId>parquet-avro</artifactId>
        <version>1.13.1</version>
    </dependency>
    <dependency>
        <groupId>org.apache.avro</groupId>
        <artifactId>avro</artifactId>
        <version>1.11.3</version>
    </dependency>
</dependencies>

2. 定义 Schema:画图纸 📐

Parquet 是强 Schema 的。不像 CSV 那样“乱来”,你必须先定义数据结构。我们用 Avro 来定义:

{
  "namespace": "com.example.parquet",
  "type": "record",
  "name": "User",
  "fields": [
    {"name": "id", "type": "int"},
    {"name": "name", "type": "string"},
    {"name": "age", "type": ["int", "null"]}, // 允许 null
    {"name": "email", "type": "string"}
  ]
}

3. 写入 Parquet:造数据 🏭

下面这段代码展示了如何把 Java 对象写成 Parquet 文件。

import org.apache.parquet.column.ParquetProperties;
import org.apache.parquet.hadoop.ParquetWriter;
import org.apache.parquet.hadoop.metadata.CompressionCodecName;
import org.apache.parquet.avro.AvroParquetWriter;
import org.apache.avro.Schema;
import org.apache.avro.generic.GenericData;
import org.apache.avro.generic.GenericRecord;

import java.io.IOException;
import java.nio.file.Paths;

public class ParquetWriteExample {

    public static void main(String[] args) throws IOException {
        // 1. 加载 Schema
        Schema schema = new Schema.Parser().parse(
            ParquetWriteExample.class.getResourceAsStream("/user.avsc")
        );

        String filePath = "users.parquet";

        // 2. 创建 Writer (Builder模式,很现代)
        try (ParquetWriter<GenericRecord> writer = AvroParquetWriter
                .<GenericRecord>builder(Paths.get(filePath))
                .withSchema(schema)
                .withCompressionCodec(CompressionCodecName.SNAPPY) // 必选Snappy,快且稳
                .withRowGroupSize(ParquetProperties.DEFAULT_BLOCK_SIZE)
                .build()) {

            // 3. 写入数据
            for (int i = 0; i < 1000; i++) {
                GenericRecord user = new GenericData.Record(schema);
                user.put("id", i);
                user.put("name", "User_" + i);
                user.put("age", i % 10 == 0 ? null : 20 + i % 30); // 模拟空值
                user.put("email", "user" + i + "@example.com");

                writer.write(user);
            }
        }
        System.out.println("✅ Parquet 文件写入完成!快去看看文件大小吧。");
    }
}

4. 读取 Parquet:吃数据 🍽️

读取更有趣,特别是投影下推(Projection Pushdown) ,这是 Parquet 的灵魂!

import org.apache.parquet.hadoop.ParquetReader;
import org.apache.parquet.avro.AvroParquetReader;
import org.apache.avro.generic.GenericRecord;

import java.io.IOException;
import java.nio.file.Paths;

public class ParquetReadExample {

    public static void main(String[] args) throws IOException {
        String filePath = "users.parquet";

        // 注意:这里我们没有指定 Schema,它会自动读取文件自带的 Schema
        try (ParquetReader<GenericRecord> reader =
                     AvroParquetReader.<GenericRecord>builder(Paths.get(filePath)).build()) {

            GenericRecord record;
            int count = 0;
            while ((record = reader.read()) != null) {
                // 假设我们只关心 name 和 email
                // 即使文件里有 id 和 age,只要你不 get,底层 IO 就不会读那一列!
                String name = record.get("name").toString();
                String email = record.get("email").toString();
                
                if (count++ < 10) { // 只打印前10个
                    System.out.println("👤 Name: " + name + ", 📧 Email: " + email);
                }
            }
            System.out.println("✅ 总共读取了 " + count + " 条记录。");
        }
    }
}

四、 避坑指南(血泪史)⚠️

  1. Schema Evolution(模式演化)

    • 可以:加字段(给默认值)、删字段。
    • 不可以:改字段类型(比如 Int 变 String)。这会让你的 Reader 直接报错给你看。😡
  2. 小文件问题

    • Parquet 是为大文件设计的。如果你生成了成千上万个几KB的小 Parquet 文件,NameNode(如果用HDFS)会爆炸,读取效率也会极低。合并小文件!
  3. Java 内存

    • 虽然 Parquet 省 IO,但如果你一次性 read()太多数据到内存,OOM(内存溢出)还是会找上门的。建议配合流式处理(Streaming)。

五、 总结

特性CSVParquet
体积🐘 巨大🐦 小巧 (压缩率高)
读取速度🐢 慢 (全量扫描)🚀 快 (列裁剪)
Schema❌ 无 (全靠猜)✅ 强类型 (自带元数据)
适用场景Excel打开看看大数据分析、存储

一句话总结:

如果你的数据量超过 10万行,或者你需要频繁按列查询,立刻、马上、现在就把 CSV 换成 Parquet。

别让你的 Java 程序跑得像个老爷车,给它换上 Parquet 这个涡轮增压引擎吧!🏎️💨

有问题欢迎在评论区留言,咱们下期见!👋

六、Java Parquet 工具类

1、设计目标

能力说明
✅ 读写分离写 / 读 / 投影读取解耦
✅ 泛型支持支持 POJO ↔ Parquet
✅ Schema 管理Avro Schema 自动生成
✅ 压缩策略Snappy / Gzip 可选
✅ 流式读取防止 OOM
✅ 可扩展支持自定义 Converter

2、Maven 依赖(稳定组合)

<dependencies>
    <!-- Parquet -->
    <dependency>
        <groupId>org.apache.parquet</groupId>
        <artifactId>parquet-hadoop</artifactId>
        <version>1.13.1</version>
    </dependency>

    <!-- Avro(Schema & 泛型) -->
    <dependency>
        <groupId>org.apache.parquet</groupId>
        <artifactId>parquet-avro</artifactId>
        <version>1.13.1</version>
    </dependency>

    <dependency>
        <groupId>org.apache.avro</groupId>
        <artifactId>avro</artifactId>
        <version>1.11.3</version>
    </dependency>
</dependencies>

3、核心工具类:ParquetUtils

1️⃣ 工具类入口
package com.example.parquet;

import org.apache.avro.Schema;
import org.apache.avro.generic.GenericRecord;
import org.apache.avro.reflect.ReflectData;
import org.apache.parquet.hadoop.ParquetReader;
import org.apache.parquet.hadoop.ParquetWriter;
import org.apache.parquet.hadoop.metadata.CompressionCodecName;
import org.apache.parquet.avro.AvroParquetReader;
import org.apache.parquet.avro.AvroParquetWriter;

import java.io.IOException;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Iterator;
import java.util.List;
import java.util.function.Function;
import java.util.stream.Stream;
import java.util.stream.StreamSupport;

/**
 * ✅ 生产级 Parquet 工具类
 */
public final class ParquetUtils {

    private ParquetUtils() {}

    /* ========================= 写 ========================= */

    public static <T> void write(
            Path path,
            Iterable<T> data,
            Class<T> clazz,
            CompressionCodecName codec
    ) throws IOException {

        Schema schema = ReflectData.get().getSchema(clazz);

        try (ParquetWriter<GenericRecord> writer = AvroParquetWriter
                .<GenericRecord>builder(path)
                .withSchema(schema)
                .withCompressionCodec(codec)
                .withPageSize(4096)
                .withRowGroupSize(128 * 1024 * 1024)
                .build()) {

            for (T item : data) {
                writer.write(AvroConverter.toRecord(item, schema));
            }
        }
    }

    /* ========================= 读 ========================= */

    public static <T> List<T> readAll(
            Path path,
            Class<T> clazz,
            Function<GenericRecord, T> converter
    ) throws IOException {

        List<T> result = new ArrayList<>();
        try (ParquetReader<GenericRecord> reader =
                     AvroParquetReader.<GenericRecord>builder(path).build()) {

            GenericRecord record;
            while ((record = reader.read()) != null) {
                result.add(converter.apply(record));
            }
        }
        return result;
    }

    /* ========================= Stream ========================= */

    public static <T> Stream<T> stream(
            Path path,
            Function<GenericRecord, T> converter
    ) throws IOException {

        ParquetReader<GenericRecord> reader =
                AvroParquetReader.<GenericRecord>builder(path).build();

        Iterator<T> it = new Iterator<>() {
            private GenericRecord next;

            @Override
            public boolean hasNext() {
                try {
                    next = reader.read();
                    if (next == null) {
                        reader.close();
                        return false;
                    }
                    return true;
                } catch (IOException e) {
                    throw new RuntimeException(e);
                }
            }

            @Override
            public T next() {
                return converter.apply(next);
            }
        };

        return StreamSupport.stream(
                ((Iterable<T>) () -> it).spliterator(), false
        );
    }
}

4、POJO ↔ Avro 转换器(关键点)

final class AvroConverter {

    static <T> GenericRecord toRecord(T obj, Schema schema) {
        return new org.apache.avro.generic.GenericData.Record(obj, schema);
    }
}

推荐:生产环境建议使用 Avro Generated Class​ 或 MapStruct​ 做显式映射,避免反射。


5、示例 POJO

package com.example.model;

public class User {

    private int id;
    private String name;
    private Integer age;
    private String email;

    // getter / setter / constructor
}

6、使用示例(真实业务)

✅ 写 Parquet

List<User> users = userService.loadUsers();

ParquetUtils.write(
    Path.of("/data/users.parquet"),
    users,
    User.class,
    CompressionCodecName.SNAPPY
);
✅ 读 Parquet(投影)
List<String> emails = ParquetUtils.readAll(
    Path.of("/data/users.parquet"),
    User.class,
    record -> record.get("email").toString()
);
✅ 流式读取(防 OOM)
try (Stream<String> stream = ParquetUtils.stream(
        Path.of("/data/users.parquet"),
        r -> r.get("name").toString()
)) {
    stream.limit(1000).forEach(System.out::println);
}

7、生产增强建议(非常重要 ⚠️)

场景建议
大文件RowGroup ≥ 128MB
并发写每个线程一个文件
Schema 演进禁止删字段 / 改类型
监控记录写入行数 & 文件大小
校验写完后校验 footer
云存储支持 HDFS / S3A