Spark SQL中数据存储格式与压缩格式

一、数据存储格式

Spark SQL支持多种文件存储格式,主要分为行式存储列式存储两大类。

1. 行式存储:数据按组织,同一行的所有字段值在物理上连续存放。

以一张用户表为例(ID, Name, Age, City):

sql 复制代码
行1:| 1 | 张三 | 28 | 北京 |
行2:| 2 | 李四 | 35 | 上海 |
行3:| 3 | 王五 | 22 | 广州 |

在磁盘上,它会被存成:

sql 复制代码
[1,张三,28,北京][2,李四,35,上海][3,王五,22,广州]...

主要特点:

  • 写入非常快:新增一行直接追加在末尾即可,无需重组数据。

  • 整行读取效率高 :适合SELECT *或需要大部分字段的查询。

  • 压缩率较低:一行内不同字段的数据类型、值分布差异大,压缩算法难以发挥作用。

  • 列查询代价大 :如果只计算平均年龄 SELECT AVG(Age),仍必须扫描所有行的全部字段,I/O浪费严重。

适用场景:

  • OLTP (在线事务处理):频繁的增删改,或者需要查询全部字段的情况。

  • 流式数据写入:数据源逐条到达,快速追加到文件,如Kafka消息落盘成 Avro 文件。

1.1 CSV文本行式

CSV是纯文本表格数据,读写简单,通用性极强,但无内建类型和索引。字段用逗号/制表符等分隔,不包含 Schema,解析开销大,压缩后不可拆分。适用于用户手工文件上传落表等情况。

sql 复制代码
CREATE TABLE user_csv (
  id INT,
  name STRING,
  age INT
) ROW FORMAT DELIMITED FIELDS TERMINATED BY ',' -- 指定按逗号分隔
STORED AS TEXTFILE
-- 使用gzip压缩,数据量不大的情况,也可以删除该行不使用压缩
TBLPROPERTIES ('compression'='gzip');
1.2 JSON半结构化文本行式

JSON是一种轻量化的数据交换格式,广泛应用于Web应用和分布式系统之间的数据交互。相比CSV来说,支持复杂嵌套结构。

Spark 2.2+ 可直接使用 JSONFILE

sql 复制代码
CREATE TABLE user_json (id INT, name STRING, age INT)
STORED AS JSONFILE
TBLPROPERTIES ('compression'='gzip');

或老版本用 Hive SerDe:

sql 复制代码
CREATE TABLE user_json (id INT, name STRING, age INT)
ROW FORMAT SERDE 'org.apache.hive.hcatalog.data.JsonSerDe'
STORED AS TEXTFILE;
1.3 Avro二进制行式

Apache Avro 是一个基于 Schema 的、与语言无关的二进制数据序列化系统 。它不像 JSON 那样是纯文本,也不像 Parquet 那样是列式分析格式。Avro 的核心使命是:让数据在分布式系统的不同组件之间高效、安全地流动,同时允许数据结构随时间自由演化。适用于流处理、Schema 频繁演化的数据落地,比如基于Kafka的数据管道。

核心设计:Schema 与数据分离

  • Schema 用 JSON 定义

    描述数据的字段名、类型、默认值等,人类可读。

  • 数据以紧凑二进制存储

    序列化后的记录不携带字段名和结构信息,只是按 Schema 顺序紧密排列值。这带来了极小的体积和极快的解析速度。

  • 读取必须拥有 Schema

    写入时的 Schema(写 Schema)与读取时的 Schema(读 Schema)可以不同,Avro 会自动按照字段名进行匹配和转换------这正是 Schema 演化的基石。

  • Schema 演化:向后与向前兼容( 双 Schema 解析)

    Avro 数据文件中存储的是 Writer Schema 编码的二进制数据。读取时,若提供不同的 Reader Schema,Avro 解析器会根据字段名映射和默认值规则自动转换数据,而非直接报错。

sql 复制代码
CREATE TABLE user_avro (
  id INT,
  name STRING,
  age INT
) STORED AS AVRO
TBLPROPERTIES ('avro.compress'='snappy');
1.4 SequenceFile二进制键值对行式

Hadoop的键值对二进制格式,常用于Hadoop 中间数据传递(Spark SQL较少直接使用),适用于MapReduce 中间结果、数据合并、小文件归档的情况。


2. 列式存储:数据按组织,同一列的所有值在物理上连续存放。

同样用用户表举例,磁盘布局会变成:

sql 复制代码
ID列:   [1,2,3,...]
Name列: [张三,李四,王五,...]
Age列:  [28,35,22,...]
City列: [北京,上海,广州,...]

主要特点:

  • 分析查询极快:列裁剪(只读需要的列),极大减少I/O。

  • 压缩率极高:同一列数据类型一致,值域往往相近,非常适合游程编码(Run-Length Encoding, RLE)、字典编码(Dictionary Coding,如LZW)等,存储空间通常只有行式的1/3~1/10。

  • 谓词下推与向量化:列存文件内部通常包含列块统计信息(min/max等),可以快速跳过不满足条件的整块数据;向量化引擎能一次处理一批列值,CPU效率高。

  • 写入较慢:需要将行数据拆分成列并缓冲成列块后才能写入,内存和计算开销较大。

  • 单行更新昂贵:如果要修改一行中的某个字段,可能需要重写整个列块,不适合频繁随机更新。

适用场景:

  • OLAP 在线分析查询:大宽表上只对少数列做聚合、分组、过滤。

  • 数据仓库 / 数据湖:海量历史数据存储,追求高压缩比和扫描效率。

2.1 Parquet

Apache Parquet 是一种‌开源的列式存储文件格式‌,专为大数据处理和分析场景设计。

一个Parquet文件的内容由Header、Data Block和Footer三部分组成。在文件的首尾各有一个内容为PAR1的Magic Number,用于标识这个文件为Parquet文件。

Header部分就是开头的Magic Number。

Data Block是具体存放数据的区域,由多个Row Group组成,具体概念内容如下:

  • Row Group(行组) :数据集的水平切片。Parquet 将整张表按行数水平划分为N个 Row Group。每个 Row Group 包含该组内 所有列 的数据,是一个独立的并行处理单元。默认大小为128 MB(与HDFS Block一致),由于一个Map Task一般处理一个Block的数据,这样设置可以增大任务执行并行度。
sql 复制代码
Row Group 0: 包含第 1 ~ 200,000 行的所有列数据
Row Group 1: 包含第 200,001 ~ 400,000 行的所有列数据
...
Row Group 4: 包含第 800,001 ~ 1,000,000 行的所有列数据

这样划分的好处:每个 Row Group 可被不同的 Spark Task 并行读取;Row Group 级别的统计信息(Footer 中)能让我们直接跳过整个不相关的 Row Group。

  • Column Chunk(列块) :一个 Row Group 内某一列的所有数据,被组织成多个 Page
sql 复制代码
Row Group 0
├── Column Chunk for "id"      (200,000 个整数)
├── Column Chunk for "name"    (200,000 个字符串)
├── Column Chunk for "age"     (200,000 个整数)
└── Column Chunk for "city"    (200,000 个字符串)

每个 Column Chunk 在 Footer 中都会记录自己的元数据:

  • 统计信息min_value, max_value, null_count
  • 编码与压缩信息:用了什么编码(如 Dictionary + RLE)、压缩算法(Snappy)
  • 物理偏移量:该 Column Chunk 的起始位置和大小

这些统计正是 谓词下推 的依据。例如 city 列的 Column Chunk 统计显示 min_value = 'Baoding', max_value = 'Shanghai',如果查询条件 WHERE city = 'Zhengzhou',这个 Row Group 的 city 值范围完全不包含 'Zhengzhou',整个 Row Group 就可以被跳过。

  • Page(页):最小的 I/O 和编码单位。包含三种类型:

    • 数据页 (Data Page):存储列的实际值,经过编码和压缩。

    • 字典页 (Dictionary Page):如果该 Column Chunk 使用了字典编码,会有一个字典页记录所有唯一值及其映射,后面的数据页则只存储整数索引。

    • 索引页:Page 级别的偏移量索引(可选)。

sql 复制代码
Column Chunk: city (Row Group 0)
├── Dictionary Page: [0->'Beijing', 1->'Shanghai', ..., 19->'Hangzhou']
├── Data Page 0: (行 1~20,000 的 city 索引) → [0,1,5,0,0,...] (RLE 压缩)
├── Data Page 1: (行 20,001~40,000 的索引)
└── Data Page 2: (行 40,001~60,000 的索引)
...

如果查询需要 city = 'Beijing',Parquet 读取器会先加载字典页,将过滤条件转化为索引 0,然后在每个 Data Page 内查找是否包含 0。Page 级别的索引(Parquet Page Index,2.0+ 版本)还能记录每个 Page 内的 min/max 索引,帮助直接跳过不包含索引 0 的 Page,进一步提升效率。

Footer 部分用来存储整个文件的元数据,由File Metadata、Footer Length和Magic Number三部分组成。

  • **FileMetaData:**记录文件元数据信息,包括Schema和每个Row Group的Metadata。每个Row Group的Metadata又由各个Column的Metadata组成,每个Column Metadata包含了其Encoding、Offset、Statistic信息等等。
sql 复制代码
FileMetaData {
    version          : int (格式版本,如 1 或 2)
    schema           : list<SchemaElement>  (表结构的完整描述)
    num_rows         : long (文件总行数)
    row_groups       : list<RowGroup>      (每个 Row Group 的元数据)
    key_value_metadata : optional list<KeyValue> (自定义属性)
    created_by       : optional string (生成该文件的库与版本,如 "parquet-mr version 1.12.2")
    column_orders    : optional list<ColumnOrder> (列的排序规则)
}
  • **Footer Length:**4 字节有符号整数,用于标识Footer部分的大小,帮助找到Footer的起始指针位置

  • **Magic Number:**再次 "PAR1"

2.2 ORC

与 Parquet 相比,ORC 的 Header/Footer 架构更显"重索引"------它把部分索引从中心元数据下放到 Stripe 内部,牺牲了一点简单性,换取了在 Hive 数仓场景下更锐利的点查询和范围过滤性能。

二、压缩格式

压缩格式 压缩比 压缩速度 解压速度 是否可拆分(文件级) 典型应用场景
Snappy 较低 极快 极快 否(但容器内部可拆分) Parquet/ORC内部默认压缩,追求速度与平衡
Gzip 中等 否(文本gzip整体不可拆分) 长期归档、需要高压缩比的文本/CSV
Zstd 高(可调) 极快 可实现(需特定容器支持) 新一代平衡选择,Parquet/ORC的理想压缩
LZ4 极快 极快 Shuffle数据、临时中间结果压缩
Bzip2 很高 极慢 是(块边界可拆分) 极低存储要求的归档,几乎不用
LZO 中等 需建立索引后支持拆分 Hadoop生态老旧场景,需额外安装库

三、补充:Parquet 在查询执行时的全流程

假设我们要在 Spark SQL 中执行:

sql 复制代码
SELECT AVG(age) FROM users WHERE city = 'Beijing';

1. 读取 Footer,获取全局元数据

Spark 首先读取文件尾部 Footer,得到:

  • Schema:4 个字段及其类型

  • 所有 Row Group 的列表,以及每个 Row Group 内每个 Column Chunk 的统计信息(min/max/null)

2. 基于统计信息跳过 Row Group(谓词下推)

针对 city 列,Footer 显示每个 Row Group 的统计:

  • Row Group 0: city min='Baoding', max='Shanghai' → 包含 'Beijing',需要读取

  • Row Group 1: city min='Chengdu', max='Wuhan' → 不包含 'Beijing',整个跳过

  • Row Group 2: min='Anshan', max='Beijing' → 包含 'Beijing',需要读取

  • Row Group 3: min='Nanjing', max='Zhengzhou' → 不包含,跳过

  • Row Group 4: min='Beijing', max='Shenzhen' → 包含,需要读取

最终只需读取 Row Group 0, 2, 4,I/O 立即减少约 40%。

3. 列裁剪:只读需要的列

在每个需要读取的 Row Group 内,Spark 只读取涉及的两列:

  • city(用于过滤)

  • age(用于聚合)

idname 的 Column Chunk 完全不被访问,再次大幅减少 I/O。

4. 在 Row Group 内部,通过 Page 精确读取

以 Row Group 0 的 city 列为例:

  • 先读取 字典页,将 'Beijing' 转换成索引 0。

  • 扫描 city 的各个 Data Page(可配合 Page Index 跳过不含索引 0 的页),找出所有 city = 'Beijing' 的行号。

  • 根据这些行号,到 age 列的 Column Chunk 中读取对应行的年龄值。因为 age 也是列式存储且同步分页,Parquet 可以只读取包含这些目标行的 age Page。

5. 向量化计算

读取出的 age 值以批处理(向量化)的方式送入 CPU,计算平均值,最终返回结果。

整个过程,Parquet 将全表扫描优化成了:只读部分 Row Group + 只读两列 + 只读满足过滤条件的少数 Page,性能提升可达数十甚至上百倍。

四、补充:Parquet的Row Group、Column Chunk 的元数据信息

sql 复制代码
RowGroup {
    total_byte_size   : long          (该 Row Group 总字节数)
    num_rows          : long          (该 Row Group 包含的行数)
    columns           : list<ColumnChunk>  (每列的 Chunk 元数据)
    file_offset       : optional long (Row Group 在文件中的起始偏移,Parquet 2.0+)
    total_compressed_size : optional long
    sorting_columns   : optional list<SortingColumn>
}
sql 复制代码
ColumnChunk {
    file_path         : string (如果文件是集合中的一个,通常为 null)
    file_offset       : long   (该 ColumnChunk 在文件中的起始偏移量)
    meta_data {
        type          : Type (列的数据类型)
        encodings     : list<Encoding> (使用的编码,如 PLAIN_DICTIONARY, RLE)
        path_in_schema: list<string> (列在 Schema 树中的路径,如 ["users", "name"])
        codec         : CompressionCodec (压缩算法,SNAPPY, GZIP 等)
        num_values    : long   (该 Chunk 中值的数量)
        total_uncompressed_size : long
        total_compressed_size   : long
        key_value_metadata : optional list<KeyValue>

        /** 统计信息,用于谓词下推 **/
        statistics    : Statistics {
            max       : binary (最大值,编码后的形式)
            min       : binary (最小值)
            null_count: long
            distinct_count: long (可选,唯一值大约数量)
            max_value : binary (Parquet 2.0+ 增强统计)
            min_value : binary
        }
    }
    offset_index_offset : optional long (Page 索引位置,2.0+)
    offset_index_length : optional long
    column_index_offset : optional long (列索引位置,2.0+)
    column_index_length : optional long
}