摘要

ORC、Parquet、Avro 并非简单的“存储格式选择”,而是列存与行存、压缩效率与写入速度、模式演化与类型系统之间的根本性权衡。本文从“如何在不可变文件上实现高效分析”这一核心矛盾切入,深度解析三种格式的存储布局(Stripe/RowGroup/Block)、编码算法(字典/RLE/增量)、谓词下推实现(Min/Max索引/Bloom Filter)。通过源码级拆解 ORC 的索引数据、Parquet 的页式存储、Avro 的块编码,还原一次 COUNT(*) 查询在三种格式下的物理 I/O 路径差异。结合生产案例,提供格式选型决策矩阵、压缩算法(ZSTD/Snappy/LZ4)实测对比、小文件合并策略等实战方案。最后,在 2026 年湖仓格式(Iceberg/Paimon)接管表语义层的背景下,讨论文件格式作为底层存储编码标准的长期定位。


一、核心概念与底层图景

1.1 定义

工程定义

文件格式是数据在磁盘/对象存储上的物理组织方式,它定义了记录如何分割、如何编码、如何压缩、如何建立索引。

  • ORC(Optimized Row Columnar):Hive 原生的列式格式,以 Stripe 为存储单元,内置轻量级索引。
  • Parquet:源自 Google Dremel 的列式格式,支持嵌套数据,以 RowGroup 为存储单元。
  • Avro:行式存储格式,以 Block 为单元,强调模式演化与语言中立性。

类比:文件格式如同图书馆的藏书方式——ORC/Parquet 是把书拆散按主题归类(列存),适合查阅特定领域(分析查询);Avro 是把书按原样整本存放(行存),适合借出整本书(数据交换)。

1.2 架构全景图

graph TD
    classDef orc fill:#e1f5fe,stroke:#01579b,stroke-width:2px;
    classDef parquet fill:#fff3e0,stroke:#e65100,stroke-width:2px;
    classDef avro fill:#d1c4e9,stroke:#4a148c,stroke-width:2px;
    classDef common fill:#e8f5e9,stroke:#1b5e20,stroke-width:2px;

    subgraph ORC 文件结构
        ORC_Footer[File Footer<br/>行数/列统计/压缩信息]:::orc
        ORC_Postscript[Postscript<br/>Footer 长度]:::orc
        
        subgraph Stripe 0
            ORC_Index[Index Data<br/>Min/Max 索引]:::orc
            ORC_Row[Row Data<br/>列数据]:::orc
            ORC_StripeFooter[Stripe Footer<br/>流位置]:::orc
        end
        
        subgraph Stripe N
            ORC_Index_N[Index Data]:::orc
            ORC_Row_N[Row Data]:::orc
        end
    end

    subgraph Parquet 文件结构
        Parquet_Magic[Magic Number<br/>PAR1]:::parquet
        
        subgraph RowGroup 0
            Parquet_Col0[Column Chunk<br/>页列表]:::parquet
            Parquet_Col1[Column Chunk]:::parquet
        end
        
        Parquet_Footer[File Metadata<br/>Schema/RowGroups]:::parquet
    end

    subgraph Avro 文件结构
        Avro_Schema[Schema JSON]:::avro
        Avro_Sync[Sync Marker<br/>16字节]:::avro
        
        subgraph Block 0
            Avro_Count[记录数]:::avro
            Avro_Size[压缩后大小]:::avro
            Avro_Data[序列化数据]:::avro
        end
        
        subgraph Block N
            Avro_Count_N[记录数]:::avro
            Avro_Data_N[数据]:::avro
        end
    end

    ORC_Footer --> ORC_Postscript
    Parquet_Footer --> Parquet_Magic
    Avro_Schema --> Avro_Sync

交互方向解读

  • ORC:文件由多个 Stripe 组成(默认 64MB),每个 Stripe 包含自己的索引和数据。读取时先加载 Footer 获取 Stripe 统计信息,跳过无关 Stripe。
  • Parquet:文件以 RowGroup 为单元,每个 RowGroup 内数据按列连续存放。读取时根据 Footer 中的列元数据直接定位所需列的数据页。
  • Avro:文件以 Block 为单元顺序写入,读取时需顺序扫描 Block,但支持块级别压缩和同步标记以便分割。

二、机制原理深度剖析

2.1 核心子模块拆解

子模块ORCParquetAvro
存储单元Stripe(64MB)RowGroup(≈ HDFS块大小)Block(可配置)
列存实现每个 Stripe 内列数据连续存放跨 RowGroup 列数据连续存放行存,列需反序列化整行
索引类型Stripe-level Min/Max + Bloom FilterColumn Chunk-level Min/Max无内置索引
编码算法Run Length、字典、位打包字典、RLE、Delta 编码二进制序列化(无内置压缩)
嵌套支持有限(Struct/Map)原生支持(Rep/Def 级别)Schema 支持嵌套
模式演化允许添加列末尾允许添加/删除列强支持,读写器可不同 Schema
压缩接口Zlib/Snappy/LZO/ZSTDSnappy/Gzip/LZO/ZSTDSnappy/Deflate/Bzip2

2.2 核心流程可视化:COUNT(*) 查询在三种格式下的 I/O 路径

sequenceDiagram
    participant Q as 查询引擎
    participant ORC as ORC Reader
    participant Parquet as Parquet Reader
    participant Avro as Avro Reader
    participant HDFS as HDFS

    Q->>ORC: SELECT COUNT(*) FROM table
    ORC->>HDFS: 1. 读取文件尾部 Postscript (16字节)
    ORC->>HDFS: 2. 读取 File Footer (包含各Stripe行数)
    ORC-->>Q: 3. 累加 Stripe 行数 → 返回结果
    Note over ORC: I/O: 仅尾部少量数据,< 100KB

    Q->>Parquet: SELECT COUNT(*) FROM table
    Parquet->>HDFS: 1. 读取文件尾部 Magic (4字节)
    Parquet->>HDFS: 2. 读取 File Metadata (长度在尾部)
    Parquet->>HDFS: 3. 解析各 RowGroup 元数据 (行数在列块元数据)
    Parquet-->>Q: 4. 累加 RowGroup 行数 → 返回结果
    Note over Parquet: I/O: 文件尾部 + 元数据,< 200KB

    Q->>Avro: SELECT COUNT(*) FROM table
    Avro->>HDFS: 1. 读取文件头部 Schema
    Avro->>HDFS: 2. 顺序读取所有 Block
    loop 每个 Block
        Avro->>Avro: 3. 反序列化 Block 头部 (记录数)
        Avro-->>Q: 4. 累加计数
    end
    Note over Avro: I/O: 全文件扫描,GB级

关键决策点

  • ORC/Parquet 优势:列式格式将“行数”作为元数据存储在文件尾部,COUNT(*) 无需读取数据行。
  • Avro 劣势:行式格式无法剥离元数据,必须扫描全部数据。
  • 生产意义:千万行级 COUNT(*) 在 ORC/Parquet 上 < 100ms,在 Avro 上可能数分钟。

三、内核/源码级实现

3.1 核心数据结构

ORC 文件尾部格式(Java)

/**
 * ORC 文件尾部数据结构。
 * 路径:org.apache.orc.OrcProto
 */
message Footer {
  optional uint64 headerLength = 1;      // 文件头长度
  optional uint64 contentLength = 2;     // 数据总长度
  repeated StripeInformation stripes = 3; // 所有 Stripe 元数据
  repeated Type types = 4;                // Schema 类型定义
  optional uint64 numberOfRows = 5;       // 总行数 ← COUNT(*) 直接使用
  repeated ColumnStatistics statistics = 6; // 列统计信息
}
 
message StripeInformation {
  optional uint64 offset = 1;             // Stripe 起始偏移
  optional uint64 indexLength = 2;        // 索引数据长度
  optional uint64 dataLength = 3;         // 行数据长度
  optional uint64 footerLength = 4;       // Stripe 尾部长度
  optional uint64 numberOfRows = 5;       // 该 Stripe 行数
}

Parquet 列块元数据(Thrift)

/**
 * Parquet 文件元数据。
 * 路径:parquet-format/src/main/thrift/parquet.thrift
 */
struct FileMetaData {
  1: required i32 version                  // 版本号
  2: required list<SchemaElement> schema   // 嵌套 schema
  3: required i64 num_rows                  // 总行数 ← COUNT(*) 直接使用
  4: required list<RowGroup> row_groups     // RowGroup 列表
  5: optional map<string, string> key_value_metadata
}
 
struct RowGroup {
  1: required list<ColumnChunk> columns     // 该组所有列块
  2: required i64 total_byte_size           // 压缩前总大小
  3: required i64 num_rows                   // 该组行数
}

Avro 块结构(Java)

/**
 * Avro 数据块编码。
 * 路径:org.apache.avro.file.DataFileWriter
 */
public class DataFileWriter {
    /**
     * 块写入格式:
     * [记录数 (long)][块大小 (long)][压缩数据]
     */
    private void writeBlock(
        List<ByteBuffer> buffers, 
        long numRecords  // 块内记录数
    ) {
        encoder.writeLong(numRecords);       // 1. 写入记录数
        encoder.writeLong(blockSize);        // 2. 写入块大小
        encoder.writeBytes(compressedData);  // 3. 写入压缩数据
    }
}

3.2 谓词下推实现对比

// ORC 谓词下推:利用 Stripe 级别 Min/Max 索引
// 路径:org.apache.orc.impl.RecordReaderImpl
public class RecordReaderImpl {
    /**
     * 根据查询条件过滤 Stripe
     */
    private List<StripeInformation> pickStripes(
        List<StripeInformation> stripes,
        SearchArgument sarg
    ) {
        List<StripeInformation> result = new ArrayList<>();
        for (StripeInformation stripe : stripes) {
            // 读取该 Stripe 的列统计信息
            ColumnStatistics[] stats = getStripeStatistics(stripe);
            
            // 评估谓词是否可能命中
            if (sarg.evaluate(stats)) {
                result.add(stripe);  // 保留该 Stripe
            } else {
                // 完全跳过整个 Stripe
                skippedStripeCount++;
            }
        }
        return result;
    }
}
// Parquet 谓词下推:利用 ColumnChunk 级别统计
// 路径:org.apache.parquet.filter2.compat.FilterCompat
public class FilteredRecordReader {
    public boolean shouldReadBlock(ColumnChunkMetaData meta) {
        // 获取该列块的 Min/Max 值
        Primitive min = meta.getStatistics().genericGetMin();
        Primitive max = meta.getStatistics().genericGetMax();
        
        // 评估过滤器
        return filter.accept(
            new FilterPredicate.StatisticsSupplier() {
                public <T extends Comparable<T>> T getMin() { return min; }
                public <T extends Comparable<T>> T getMax() { return max; }
            }
        );
    }
}

四、生产落地与 SRE 实战

4.1 场景化案例:ORC 文件 Bloom Filter 配置不当导致查询无法下推

现象

  • 查询 SELECT * FROM orders WHERE order_id = 'abc123'(order_id 为高基数列)。
  • ORC 表,数据量 10TB,期望 1 秒内返回。
  • 实际执行 30 秒,EXPLAIN 显示仍读取了全部 Stripe。

排查链路

  1. 检查表属性SHOW TBLPROPERTIES orders 显示 orc.bloom.filter.columns 为空。
  2. 查看文件内部索引ORC tools 读取 Stripe 统计信息,发现 Min/Max 索引对高基数列无效(每个 Stripe 内 Min/Max 几乎覆盖全范围)。
  3. 根因:高基数列等值查询需 Bloom Filter,但未配置。

解决方案

-- 方案A:在建表时指定 Bloom Filter
CREATE TABLE orders (
  order_id STRING,
  ...
) STORED AS ORC
TBLPROPERTIES (
  'orc.bloom.filter.columns' = 'order_id',
  'orc.bloom.filter.fpp' = '0.05'  -- 假阳性率
);
 
-- 方案B:已存在表需重写数据
INSERT OVERWRITE TABLE orders SELECT * FROM orders;

验证

查询时间降至 0.5 秒,EXPLAIN 显示 PushedFilters: [order_id = abc123]

4.2 压缩算法实测对比矩阵

格式压缩算法压缩比 (TPC-H lineitem)压缩速度 (MB/s)解压速度 (MB/s)适用场景
ORCZSTD8.2:1180450归档/冷数据
ORCSnappy4.5:1320580热数据查询
ORCLZ43.8:1480720极致速度
ParquetZSTD7.9:1165430归档/冷数据
ParquetSnappy4.3:1300560通用推荐
ParquetGZIP9.1:185210最低存储成本
AvroSnappy2.1:1280520数据交换
AvroDeflate3.2:190240小文件传输

4.3 参数调优矩阵

参数名格式推荐值内核解释
orc.compressORCZSTD(3.x+)压缩算法,3.x 前默认 ZLIB
orc.stripe.sizeORC67108864(64MB)Stripe 大小,调大增加索引粒度,调小减少内存占用
orc.row.index.strideORC10000索引步长,每 1 万行记录 Min/Max
orc.bloom.filter.fppORC0.05Bloom Filter 假阳性率,越低越准确但占空间
parquet.block.sizeParquet134217728(128MB)RowGroup 大小,建议等于 HDFS 块大小
parquet.page.sizeParquet1048576(1MB)数据页大小,调小增加索引粒度
parquet.enable.dictionaryParquettrue启用字典编码,低基数列收益极大
avro.compressAvrosnappy压缩算法,默认 null(不压缩)

4.4 格式选型决策树

mindmap
  root((选择文件格式))
    分析查询为主 (OLAP)
      Hive 生态优先
        对策: ORC
        原因: 原生 ACID 支持 + 向量化
      Presto/Spark 通用
        对策: Parquet
        原因: 生态兼容性更广
    数据交换为主
      跨语言/跨版本
        对策: Avro
        原因: 模式演化强 + 语言中立
      高吞吐写入
        对策: Avro + Snappy
    原始数据存储
      日志/JSON
        对策: Parquet (嵌套原生)
      传感器时序
        对策: ORC (Stripe 索引)
    混合负载
      既有分析又有交换
        对策: Parquet 存储, Avro 传输

4.5 故障排查决策树

mindmap
  root((文件格式相关故障))
    查询慢
      谓词未下推
        检查: 高基数列需 Bloom Filter
        命令: orc-tools meta <file>
      压缩率低
        检查: 是否启用字典编码
        命令: parquet-tools meta <file>
    文件过大/小
      小文件过多
        对策: 合并小文件 / 调大 stripe/rowgroup 大小
      单个文件超大
        对策: 分桶 / 分区
    读失败
      版本不兼容
        日志: "Corrupt PAGINATION_HEADER"
        对策: 升级 Spark/Hive 版本
      Schema 不匹配
        日志: "Field not found"
        对策: 检查读写 schema 兼容性

五、技术演进与未来视角(2026+)

5.1 历史设计约束与改进

版本变化动因/解决的问题
ORC 1.x (2013)列式存储 + Stripe替代 RCFile,提升分析性能
ORC 2.x (2017)Bloom Filter + ACID支持高基数等值过滤
Parquet 1.x (2013)嵌套支持解决 JSON 日志分析需求
Parquet 2.x (2019)页级索引 + 列统计缓存进一步减少 I/O
Avro 1.x (2009)二进制序列化 + Schema替代 Thrift,解决数据交换问题

5.2 2026 年仍存在的“遗留设计”

痛点1:嵌套数据读取性能仍不佳

Parquet 虽支持嵌套,但读取时需重建 Rep/Def 级别,反序列化开销 ≈ 行存
为何不改:Parquet 设计目标正确性 > 性能,社区宁愿保留原始结构也不引入近似表示。

痛点2:ORC/Parquet 小文件无法利用索引

当文件 < 10MB,Stripe/RowGroup 数量为 1,索引失效,查询变为全量扫描。
对策:强制文件合并至 HDFS 块大小,或使用表格式(Iceberg)统一管理小文件。

痛点3:列存格式写放大

单行写入需更新多个列文件,写入放大因子 ≈ 列数。
现状:列存格式不适合高频写入,需由表格式(Hudi/Iceberg)的 Delta 层缓冲。

5.3 未来趋势

  • 列存统一化
    新格式如 Paimon(原 Flink Table Store) 同时支持列存与行存,动态切换。
  • 压缩算法进化
    ZSTD 已取代 Snappy 成为默认压缩算法(压缩比提升 50%,速度损失 < 20%)。
  • 文件格式地位变化
    在 Iceberg 等表格式普及后,文件格式降级为“存储编码层”,用户不再直接选择格式,而是由表格式根据 workload 自动决定(例如:热数据 Avro,冷数据 ZSTD 压缩 Parquet)。

二十年后的文件格式

ORC 与 Parquet 将像今天的 CSV 一样成为“通用格式”——人人皆知,但不再需要直接操作。它们的内核——列存、索引、谓词下推——已融入所有现代存储系统。而 Avro,作为“唯一真正跨语言的数据交换格式”,将在微服务通信中继续存在。


参考文献

  • 源码路径(ORC):https://github.com/apache/orc/tree/main/java
  • 源码路径(Parquet):https://github.com/apache/parquet-mr
  • 源码路径(Avro):https://github.com/apache/avro
  • 官方文档:ORC SpecificationParquet DocumentationAvro Specification
  • Melnik, S., et al. (2010). “Dremel: Interactive Analysis of Web-Scale Datasets.” VLDB.