全文共 3,440 字 预计阅读 10 分钟
bg

bg4.parquet

InnoDB 的数据按组织:同一行的 idnameagepayload 等所有列的字节物理上紧挨着,一行接一行塞进 16 KB 的数据页里.行存对 OLTP 是最优解。想想典型的在线请求:SELECT * FROM users WHERE id = 42——按主键定位到一行,把这一行整条读出来返回。一行的所有列本来就挨在一起,一次 IO 就拿全,再合适不过。

假设一张订单大宽表 orders,10 亿行,列大致是:

类型 约占字节/行
order_id bigint 8
order_ts timestamp 8
country string ~3
amount decimal ~8
item_detail string(商品明细 JSON) ~200

一行约 227 字节,全表原始数据约 227 GB。现在跑一句分析:

*-- 想知道 7 月以来每个国家的总额,只碰了 country、amount、order_ts 三列*SELECT country, SUM(amount)
FROM orders
WHERE order_ts >= '2026-07-01'GROUP BY country;

InnoDB 没有能覆盖这个查询的索引时,只能全表扫描。而行存全表扫描的致命点在于:它是按行读的。为了拿到每行的 countryamountorder_ts 这 24 字节,它必须把整行 227 字节都从磁盘搬进来——**包括那个压根没碰、每行 200 字节的 ****item_detail**。

痛点定量:你真正需要的数据只占每行的 24 / 227 ≈ 10.6%,却付出了 100% 的 IO。那 89% 是纯浪费,而且浪费的大头正是最占空间的大字段。行存做分析,列越宽、大字段越多,浪费越离谱

这就是列式存储要解决的问题:分析型查询通常只碰整张表的少数几列,却要为不相关的列付全额 IO

列式存储(columnar storage):把表按列拆开存储——第 1 列的所有行的值连续存成一片,第 2 列的所有行的值连续存成另一片,以此类推。逻辑上还是二维表,物理上从"一行接一行"变成"一列接一列"。

用一张 3 行表直观对比(← 表示磁盘上字节的先后顺序):

逻辑表:
  order_id | country | amount
  ---------+---------+-------
     1001  |   JP    |  50
     1002  |   US    |  30
     1003  |   JP    |  80

行存(InnoDB 式)按行铺开:
  [1001,JP,50] → [1002,US,30] → [1003,JP,80]
   └── 第1行 ──┘  └── 第2行 ──┘  └── 第3行 ──┘

列存(Parquet 式)按列铺开:
  [1001,1002,1003] → [JP,US,JP] → [50,30,80]
   └─ order_id 列 ─┘  └country列┘  └amount列┘

这个"按列铺开"就是本篇一切优化的物理基础。两种业界主流的列式文件格式:

  • Parquet(读作 /ˈpɑːrkeɪ/)。理念源自 Google 2010 年的 Dremel 论文(它提出了把嵌套结构"打散再拼装"存成列的方法,即 record shredding and assembly);2013 年由 Twitter + Cloudera 联合开源,现为 Apache 顶级项目。今天是 Spark、大数据湖、云数仓最常用的开放列存格式。

  • ORC(Optimized Row Columnar,优化的行列式)。出自 Hive 生态(Hortonworks 主导,约 2013 年),是更早的 RCFile 的继任者,和 Hive 结合最紧。

列存"快",不是一句笼统的话,而是三个互相独立、可以叠加的机制。逐条给因果链和代价。

①:只读需要的列(投影下推 projection pushdown)

②:同列同类型 → 高压缩比(编码 + 压缩两层)

自问:同样的数据,列存凭什么比行存压得更小?

因为同一列的值,数据类型相同、取值往往高度相似或重复(比如 country 列反复出现 JP/US;order_ts 列是单调递增的时间戳)。而行存里一行的 bigintstringdecimal 混在一起,压缩器看到的是一堆杂乱字节,规律少、压不动。

列存的压缩分两层:

第一层:编码(encoding)——针对数据规律的无损变换

  • 字典编码(dictionary encoding):把列里的重复值建一张字典,数据区只存字典下标。country 列 10 亿个 JP/US,字典是 {0:JP, 1:US},数据区变成一串 0/1。因果:高度重复的低基数(cardinality)列,原始几字节的字符串 → 变成 1~2 个 bit 的下标。

  • RLE(run-length encoding,游程编码):连续相同的值,存成"值 + 重复次数"。字典下标 0,0,0,0,1,1(0×4)(1×2)因果:数据有序或成段重复时,长度骤降。

  • bit-packing(位打包):字典只有 2 个值时,下标只需 1 个 bit,不必占满 1 个字节。因果:按需分配比特位,把"值域很小的整数"压到极致。

第二层:通用压缩(compression codec):在编码后的字节上,再套一层通用压缩算法——Snappy、ZSTD、GZIP、LZ4。它不理解列语义,只做字节级压缩。

  • 省了多少:两层叠加,列存对规律性强的列常见 3~10 倍压缩(具体倍数强依赖数据分布,这是经验范围不是定值)。压得越小 → 从磁盘/对象存储搬的字节越少 → IO 越少 → 扫描越快。这是链条②最终转化成"快"的路径。

  • 代价一:压缩要花 CPU;读时要解压。这是一笔 IO 换 CPU 的交易——分析场景瓶颈通常在 IO,所以划算;但 CPU 紧张时未必。

  • 代价二:字典编码对高基数列(比如 order_id 这种几乎全不重复的列)不但没收益,字典本身还占空间。parquet-mr 的实现会在字典涨过阈值(默认 parquet.dictionary.page.size ≈ 1 MB,以你用的版本为准)时自动回退到不用字典。所以别指望列存能压缩一切,高基数列该大还是大

③:统计信息 → 谓词下推,跳过整块(predicate pushdown)

  • 是什么:文件里为每一段数据预先算好了 min/max/null 数量统计信息。带 WHERE 过滤时,引擎先看统计,发现某一整块数据不可能满足条件,就整块跳过、连读都不读。"谓词(predicate)"就是 WHERE 里的过滤条件。

  • 为什么能做到:Parquet 把文件切成若干 row group(下一节详解),每个 row group 的每一列都记了 min/max。查询 WHERE order_ts >= '2026-07-01',引擎逐个看 row group 里 order_tsmax:如果某个 row group 的 max 是 '2026-06-15'(整块最大值都小于 7 月 1 日),那这块里绝不可能有满足条件的行,直接跳过整块

  • 省了多少:接着上面的例子,若数据大致按时间写入(时间聚簇),12 个月的数据、只查 1 个月,理论上能跳掉约 11/12 的 row group。叠加链条①的投影,最终真正读的字节可以低到"全行扫描"的百分之几量级(示意估算,真实值取决于数据聚簇程度和过滤选择性)。

  • 代价 / 前提:这招只在数据物理有序/聚簇时才灵。如果 order_ts 在文件里乱序,每个 row group 的 [min,max] 都横跨全年,那 min/max 谁都框不掉,一块都跳不了——统计信息形同虚设。所以"排序/聚簇写入"是列存跳块的隐形前提。

要理解上面三条链落到实处,必须看清 Parquet 文件的内部层次。自顶向下四级:

Parquet 文件 (order.parquet)
│
├─ "PAR1"                         ← 文件头魔数(4 字节),标识这是 Parquet
│
├─ Row Group 0                    ← 行组:一批行的水平切片(如 12.8 万行)
│    ├─ Column Chunk: order_id    ← 列块:该 row group 内某一列的全部数据,物理连续
│    │    ├─ Page 0               ← 页:编码+压缩的最小读写单元(默认约 1 MB)
│    │    ├─ Page 1
│    │    └─ ...
│    ├─ Column Chunk: order_ts
│    ├─ Column Chunk: country
│    ├─ Column Chunk: amount
│    └─ Column Chunk: item_detail
│
├─ Row Group 1
│    └─ (同样 5 个 Column Chunk ...)
│
├─ ... 更多 Row Group ...
│
└─ Footer(文件尾,读文件的入口!)
     ├─ schema         ← 表结构:列名、类型、嵌套关系
     ├─ 每个 Row Group 的元数据
     │     └─ 每个 Column Chunk:起始 offset、压缩后大小、编码方式、
     │        以及 min / max / null_count 统计信息  ← 链条③靠它
     ├─ Footer 长度(4 字节)
     └─ "PAR1"         ← 文件尾魔数,和文件头呼应
  • Row Group(行组):把表按行水平切成若干组。它是读取并行的基本单位——一个 row group 通常对应一个读任务(Spark 里一个 task)。也是链条③跳块的粒度。

  • Column Chunk(列块):一个 row group 内、某一列的全部数据,物理上连续存放。链条①的"顺序读一列"就是读一个 column chunk。

  • Page(页):column chunk 再切成页,是编码和压缩的最小单元(parquet-mr 默认 parquet.page.size ≈ 1 MB,以版本为准)。也是解压的最小单位。

  • Footer(文件尾元数据):整个文件的"目录 + 索引"。它放在文件末尾,不是开头。

自问:元数据为什么放在文件末尾,而不是像常识那样放开头?

因为 Parquet 是一次性顺序写的:写的时候,一个个 row group 往后追加,在全部写完之前根本不知道每个 column chunk 最终落在哪个 offset、压缩后多大。所以只能等数据都写完,再把这些"事后才确定"的元数据统一收尾写到最后。代价:读文件时不能从头读,要先跳到尾巴。于是读取时序变成这样:

flowchart TD
    A["查询: SELECT order_id, amount<br/>WHERE order_ts >= '2026-07-01'"] --> B["读文件最后 8 字节<br/>拿到 Footer 长度 + 校验 PAR1"]
    B --> C["按长度回跳, 读出 Footer<br/>(schema + 各 row group 统计)"]
    C --> D{"逐个 Row Group 看统计:<br/>order_ts 的 max < 2026-07-01 ?"}
    D -->|"是, 整块不可能命中"| E["跳过该 Row Group<br/>(链条③: 一个字节都不读)"]
    D -->|"否, 可能命中"| F["只定位需要的 Column Chunk:<br/>order_id / amount / order_ts<br/>(链条①: 跳过 country / item_detail)"]
    F --> G["顺序读这几段连续字节<br/>解压 → 解码 → 过滤 → 返回"]

看这条时序:引擎在真正读数据之前,先用一次很小的尾部读拿到 Footer,就已经知道"哪些块能跳(③)、每列在哪(①)"。所以列存的高效不是读得快,而是读得少——它把"该不该读"这个决策,前置到了读数据之前。

ORC 对照:ORC 的层级几乎一一对应——stripe ≈ row group(默认 stripe 约 64 MB,hive.exec.orc.default.stripe.size,以版本为准),stripe 内也是按列存的,并带轻量级索引(min/max,还可选 Bloom filter);文件尾同样有 footer + postscript。术语不同,机制同源。

下面用 PySpark 跑通"写入 → 带列裁剪和过滤查询",并用执行计划证明引擎确实少读了数据。

读者提示:以下是 PySpark(Python),逐行注释;explain 输出是模拟的,但字段结构与真实 Spark 3.x 一致。

*# ① 写入:把一个 DataFrame 以 Parquet 格式落盘#    Spark 写 Parquet 默认压缩算法是 snappy(spark.sql.parquet.compression.codec 默认 "snappy",Spark 2.0+)*
(orders_df
    .repartition(12)                      *# 分成 12 个文件写(演示用;真实按数据量定,见第七节小文件问题)*
    .write
    .mode("overwrite")
    .parquet("s3://bucket/orders/"))       *# 落到对象存储(第 03 篇)*
*# ② 查询:只要两列 + 一个时间过滤*
df = spark.read.parquet("s3://bucket/orders/")   *# 惰性:此刻只读 Footer 拿 schema,不读数据*
result = (df
    .select("order_id", "amount")         *# 投影 → 链条① 列裁剪的来源*
    .filter(df.order_ts >= "2026-07-01"))  *# 过滤 → 链条③ 谓词下推的来源*

result.explain(mode="formatted")           *# 打印物理计划,看引擎"打算读什么"*

模拟运行结果(explain 关键部分):

== Physical Plan ==
* Project [order_id, amount]
+- * Filter (order_ts >= 2026-07-01 00:00:00)
   +- * ColumnarToRow
      +- FileScan parquet [order_id,amount,order_ts]
            PushedFilters: [IsNotNull(order_ts),
                            GreaterThanOrEqual(order_ts, 2026-07-01 00:00:00)]
            ReadSchema: struct<order_id:bigint,amount:decimal,order_ts:timestamp>

一句话解释结果为何如此:看两个关键字段——ReadSchema只有 order_idamountorder_ts 三列,country 和那个 200 字节的 item_detail 根本不在读取列表里(链条①生效);PushedFilters 里出现了 GreaterThanOrEqual(order_ts, ...),说明这个过滤条件被推到了文件扫描层,扫描时就会拿它去比对每个 row group 的 order_ts 统计、跳过不匹配的块(链条③生效)。两者叠加,这次查询实际从对象存储搬下来的字节,只是全表原始数据的一小部分。

谓词下推默认开启(spark.sql.parquet.filterPushdown 默认 true,Spark 2.x+;**核实途径:官方 SQL 配置文档 **spark.apache.org/docs/latest/sql-performance-tuning.html)。若把它关掉再 explain,PushedFilters 会消失,过滤只能在读完之后由上层 Filter 算子做——数据照样全读,只是白读。

Dataframe 是 PySpark 的工具之一,用于列操作。谓词下推:尽可能早的过滤数据,避免读太多的数据。

Parquest 的问题:

  1. 小文件:

每个 Parquet 文件都有一份 Footer,还需要足够多的数据才能让编码/压缩(链条②)发挥威力。成千上万个几 KB 的小文件会同时踩三个雷:① 每个文件都要单独读 Footer、开一个读任务,调度和元数据开销爆炸;② 数据太少,字典/RLE 压不出效果;③ row group 被迫很小,统计跳块(链条③)也没了施展空间。

  • 成因:流式小批写入、repartition 分区数过大、频繁追加。

  • 对策:合并小文件(compaction),目标单文件约 128 MB ~ 1 GB注意:"文件合并"更完善的方案属于表格式层面的能力(Iceberg 的 rewrite/compaction),这是第 08 篇的内容,本篇只在文件格式层面点到。

  1. Row group 太大 / 太小:
  • 太大:写入时整个 row group 要先在内存里攒齐才能刷盘,内存压力大;而且 row group 是读并行的单位,块太大 → 并行度低,一个 task 干太多活。

  • 太小:压缩效果差、Footer 元数据占比升高、随机 IO 变多、统计跳块粒度太粗。

  • 甜点区:parquet-mr 默认行组目标 parquet.block.size ≈ 128 MB(parquet-mr 默认值;Parquet 官方格式文档建议 512 MB ~ 1 GB,实际取决于写入配置和数据,以版本为准),并尽量对齐 HDFS block 大小,让"一个 row group ≈ 一个 block ≈ 一个读任务"。

  1. Schema 演进受限

Parquet 支持按列名匹配,所以加列(老文件读出来该列为 null)、删列调整列顺序都能应付。但改列名、改数据类型就麻烦——老文件里是旧名/旧类型,读的时候对不上。因为 parquet 的 footer 是只读的,所以最终要改只能改 iceberg 的 catalog。

划重点:Parquet 自身的 schema 演进只到"单个文件级别"。真正好用的表级演进(安全改名、类型提升、分区演进、还能回看历史)是表格式的职责,由 Iceberg 提供。这正是"文件格式 ≠ 表格式"最容易踩混的地方:Parquet 管一个文件里的字节;哪些文件属于这张表、表结构怎么随时间变——那是上面一层的事。

ByteHouse 云数仓版,底层就是列式存储,并把本篇三条链几乎全用上了:

  • 列式 + 每列独立文件:官方明确"ByteHouse 为列式存储数据库,数据存放在不同的列存文件(.bin)中"——这正是链条①的物理基础,和 Parquet 的 column chunk 同源思路。(来源:CnchMergeTree 表引擎文档,https://www.volcengine.com/docs/6517/158235)

  • 稀疏主键索引 + 跳块:数据按 ORDER BY 排序键排好序,以 Granule(默认 8192 行,由 index_granularity 决定) 为单位建稀疏索引,每个数据片段(DataPart)还带 min/max 索引。这就是链条③——先靠排序让数据聚簇,再用 min/max 跳过整段 Granule。注意前提:它的跳块效果同样依赖 ORDER BY 选得好、数据聚簇好,和之前讲的"隐形前提"是同一回事。(来源同上)

  • 压缩与列级编码:compression_codec 默认 LZ4,可选 ZSTD 等;还支持按列指定编码 CODEC,官方示例如 date Date CODEC(Delta, ZSTD)(Delta 即增量编码,对应链条②的编码层)。说明:官方该页明确列出的编码以 Delta 为主,ClickHouse 血统里常见的 DoubleDelta/Gorilla/T64/LowCardinality 等在这份文档里未逐一列出,如需使用请另查对应版本文档,别照本篇想当然。(来源同上;ByteHouse 云原生架构"存算分离、全面继承开源 ClickHouse"见 https://www.volcengine.com/docs/6517/76322 与 76322 同产品线介绍)

  • 架构(推断关联):ByteHouse 采用"Shared-nothing 计算层 + Shared-everything 存储层",数据以列式格式持久化在对象存储/HDFS 上——这与第 03 篇的存储底座、本篇的列存文件正好衔接。(来源:产品架构 https://www.volcengine.com/docs/6517/76322)

一句话:Parquet 是"开放的列存文件格式",ByteHouse 是"把列存 + 编码 + 稀疏索引 + 存算分离打包进引擎的云数仓"。原理同根,前者是可被任何引擎读的文件,后者是自带存储与查询的一体化产品。

LAS:湖上的 Parquet / ORC

LAS(湖仓一体服务),数据文件通常就以 Parquet / ORC 这类开放列存格式落在对象存储上——也就是说,本篇讲的文件内部机制,LAS 里是直接适用的。而"这些文件如何组织成一张可增删改、可时间旅行的表",靠的是 Iceberg / Hudi 这类表格式。

Back to Blog