Chapter 06

Parquet 与列式存储

深入理解 Parquet 格式内部结构、列存优势原理、Zone Maps 剪枝与现代表格式

6.1 Parquet 格式:内部结构详解

Apache Parquet 是列式存储格式,由 Twitter 和 Cloudera 联合开发,是大数据生态最重要的文件格式之一。理解其内部结构对于优化 DuckDB 查询至关重要。

压缩算法对比

压缩算法压缩率速度推荐场景
SNAPPY(默认)极快实时读写,延迟敏感
ZSTD推荐!兼顾压缩率和速度
GZIP归档存储,不频繁读
LZ4最快临时文件、高频读写
BROTLI最高很慢极少场景

6.2 为什么列式存储比行式快?

以一个 1亿行、10列的销售表为例,对比 SELECT SUM(amount) FROM orders WHERE category='Electronics'

🐌

行式存储(MySQL) 必须读取所有 10 列 × 1亿行 = 约 5GB 数据,仅仅是为了取其中的 amount 和 category 两列。内存带宽浪费 80%。

列式存储(DuckDB) 只读 category 列(过滤)+ amount 列(计算),共约 200MB。CPU 向量化连续处理同类型数据,SIMD 指令高效利用。速度快 10-25 倍。

列存的三大优势

  1. I/O 减少:只读需要的列,跳过其余列
  2. 压缩率高:同列数据类型相同,压缩效果极佳(5-10x)
  3. 向量化友好:连续内存中的同类型数据,CPU SIMD 指令可批量处理

6.3 写入 Parquet 文件

SQL-- 基础写法
COPY orders TO 'orders_export.parquet' (FORMAT PARQUET);

-- 从查询结果写入
COPY (
    SELECT order_id, user_id, amount, order_date
    FROM orders
    WHERE order_date >= '2024-01-01'
) TO 'orders_2024.parquet' (FORMAT PARQUET);

-- 指定压缩算法(推荐 ZSTD)
COPY orders TO 'orders_zstd.parquet' (FORMAT PARQUET, COMPRESSION ZSTD);

-- 写入多个分区文件(按列值分区)
COPY orders TO 'output/' (FORMAT PARQUET, PARTITION_BY (year, month));

Python 中写入 Parquet

PYTHONimport duckdb

con = duckdb.connect()

# SQL 方式
con.execute("""
    COPY (SELECT * FROM 'sales.csv' WHERE amount > 100)
    TO 'sales_filtered.parquet'
    (FORMAT PARQUET, COMPRESSION ZSTD)
""")

# 把 DataFrame 保存为 Parquet(通过 DuckDB)
import pandas as pd
df = pd.read_csv('data.csv')
duckdb.sql("COPY df TO 'data.parquet' (FORMAT PARQUET, COMPRESSION ZSTD)")

# 按月份分区写入
duckdb.sql("""
    COPY (
        SELECT *, year(order_date) as yr, month(order_date) as mo
        FROM 'orders.csv'
    )
    TO 'partitioned_orders/'
    (FORMAT PARQUET, PARTITION_BY (yr, mo), OVERWRITE_OR_IGNORE true)
""")

6.4 Parquet 元数据统计与 Zone Maps 剪枝

每个 Parquet 行组的每列都存储了 min/max/null_count 统计信息,DuckDB 在执行查询前会读取这些统计信息,直接跳过不满足条件的行组,这称为谓词下推(Predicate Pushdown)Zone Maps 剪枝

SQL-- 查看 Parquet 文件的元数据统计
SELECT
    row_group_id,
    column_id,
    column_name,
    min_value,
    max_value,
    null_count
FROM parquet_metadata('orders.parquet');

-- 场景:查询 amount > 10000 的订单
-- DuckDB 扫描每个行组的 amount.max
-- 如果某行组的 max_amount < 10000,整个行组被跳过
-- 可能只扫描 5% 的数据!

SELECT order_id, amount
FROM 'orders.parquet'
WHERE amount > 10000;  -- Zone Maps 自动剪枝
💡

如何让 Zone Maps 更有效 将数据按查询的过滤列排序后写入 Parquet,可以最大化 Zone Maps 的剪枝效果。例如,如果你经常 WHERE order_date BETWEEN ... 过滤,就按 order_date 排序写入 Parquet,相同日期的数据会集中在同几个行组中,大部分行组可以直接跳过。

6.5 Delta Lake 与 Apache Iceberg 简介

Parquet 是文件格式,而 Delta Lake 和 Iceberg 是基于 Parquet 构建的开放表格式(Open Table Format),在 Parquet 之上增加了事务、版本控制、Schema 演化等功能。

特性原始 ParquetDelta LakeApache Iceberg
ACID 事务
时间旅行有(按版本/时间)有(快照)
Schema 演化有限完整支持
增量读写
DuckDB 支持原生通过 delta 扩展通过 iceberg 扩展
创始公司DatabricksNetflix
SQL-- DuckDB 读取 Delta Lake 表
INSTALL delta; LOAD delta;
SELECT * FROM delta_scan('s3://my-bucket/delta-table/');

-- DuckDB 读取 Apache Iceberg 表
INSTALL iceberg; LOAD iceberg;
SELECT * FROM iceberg_scan('s3://my-bucket/iceberg-table/');

6.6 实战:100GB 数据集本地分析

SQL-- 场景:100GB 的电商订单 Parquet 数据集
-- 按年月分区存储:data/year=2024/month=01/part-*.parquet

-- 1. 先查一下有多少数据
SELECT COUNT(*) FROM 'data/**/*.parquet';

-- 2. 利用分区剪枝:只查 2024 年 Q4 的数据
SELECT COUNT(*), SUM(amount)
FROM read_parquet('data/**/*.parquet', hive_partitioning=true)
WHERE year = 2024 AND month >= 10;  -- 只扫描 3 个月的分区!

-- 3. 对大数据集做聚合分析(超内存自动溢写磁盘)
SET memory_limit = '8GB';  -- 限制内存使用
SELECT
    year,
    month,
    product_category,
    SUM(amount) AS revenue,
    COUNT(*) AS orders,
    AVG(amount) AS avg_order_value
FROM read_parquet('data/**/*.parquet', hive_partitioning=true)
GROUP BY ALL
ORDER BY year, month, revenue DESC;

-- 4. 将分析结果导出为小体积 Parquet
COPY (
    SELECT year, month, product_category, SUM(amount) AS revenue
    FROM read_parquet('data/**/*.parquet', hive_partitioning=true)
    GROUP BY ALL
) TO 'summary.parquet' (FORMAT PARQUET, COMPRESSION ZSTD);
💡

本章小结 Parquet 内部结构:行组 → 列块 → 页,Footer 存储元数据统计。列存三大优势:少 I/O、高压缩率、向量化友好。ZSTD 是写入 Parquet 的推荐压缩算法。Zone Maps 利用行组统计信息做谓词下推,按查询列预先排序数据可最大化剪枝效果。Delta Lake/Iceberg 是在 Parquet 上增加事务和版本控制的开放表格式。