摘要:

Hidden Partitioning(隐藏分区)Apache Iceberg 在用户体验和架构设计上最重要的创新之一。传统的 Apache Hive 分区模型有一个广为人知却鲜少被彻底解决的”分区列陷阱”——如果查询条件没有使用分区列,就会触发全表扫描;如果分区列是从其他列派生的(如 dateevent_ts 派生),用户还需要在写入时手动维护冗余的分区列,一旦忘记写或者写错,数据就进了错误的分区。Iceberg 的 Hidden Partitioning 把分区规则(包括变换函数)存储在表元数据中,而不是编码在文件路径里——对用户完全透明。这带来了两个革命性的工程能力:查询无需知道分区方案(Iceberg 自动做分区推导和剪枝)、分区方案可以原地演进(Partition Evolution,无需重写历史数据)。本文深入剖析 Hidden Partitioning 的物理实现、五种分区变换函数的工作原理、Partition Evolution 的机制,以及它与 Delta Lake / Hudi 分区方案的根本差异。


第 1 章 传统分区模型的设计缺陷

1.1 Hive 分区的工作方式

Apache Hive 的分区本质上是一个目录命名约定:把数据文件存在 hdfs:///data/events/date=2024-01-01/ 目录下,就意味着这批数据属于 date=2024-01-01 分区。

这个设计的优点是直观:人类可以直接在文件系统上看到分区结构,HDFS/S3 浏览器也能直接理解分区层次。

但它的缺点是深层的,分为三类:

缺陷 1:分区列必须出现在表 Schema 中

-- Hive 分区表的 Schema(典型设计)
CREATE TABLE events (
    event_id  STRING,
    user_id   BIGINT,
    event_type STRING,
    event_ts  TIMESTAMP
    -- date 不在这里!
)
PARTITIONED BY (date STRING);  ← 分区列单独声明,不是普通字段
 
-- 插入数据时:
INSERT INTO events PARTITION (date='2024-01-01')
SELECT event_id, user_id, event_type, event_ts
FROM events_raw
WHERE DATE(event_ts) = '2024-01-01';  ← 用户必须手动提取 date

用户必须在插入时手动指定分区列值,且分区列通常是从 event_ts 派生的冗余数据(事实上 dateevent_ts 携带相同的信息)。

缺陷 2:查询必须使用分区列才能触发分区裁剪

这是最臭名昭著的”分区列陷阱”:

-- 查询 1:有分区过滤(高效,触发分区剪裁)
SELECT COUNT(*) FROM events
WHERE date = '2024-01-01';         ← Hive 读取 date=2024-01-01/ 目录,只扫描一天数据
 
-- 查询 2:用原始时间列过滤(低效!全表扫描!)
SELECT COUNT(*) FROM events
WHERE event_ts >= '2024-01-01'
  AND event_ts <  '2024-01-02';   ← Hive 不知道这等价于 date='2024-01-01'
                                   ← 触发全表扫描!扫描所有年的所有天!
 
-- 查询 3:用函数转换(同样低效)
SELECT COUNT(*) FROM events
WHERE DATE(event_ts) = '2024-01-01';  ← 函数阻止了分区剪裁

查询 2 和查询 3 是工程实践中极其常见的写法,但都会绕过分区剪裁,导致灾难性的全表扫描。这个”陷阱”需要工程团队花费大量时间培训、文档说明和代码审查来避免。

缺陷 3:分区方案一旦确定,几乎无法修改

假设业务发展,日数据量从 1GB 增长到 100GB,需要从按天(date)分区改为按小时(date + hour)分区:

问题:
  → 历史数据在 date=2024-01-01/ 目录下
  → 新数据需要在 date=2024-01-01/hour=00/ 目录下
  → 同一张 Hive 表不能有"部分分区按天,部分分区按小时"的混合结构
  → 唯一选择:创建新表,将历史数据全量重写

代价:
  → 重写 10PB 历史数据 = 几天时间 + 巨大成本
  → 期间双表并存,下游 ETL 需要知道分界点
  → 迁移结束后还需要清理旧表

1.2 Delta Lake 和 Hudi 的分区也有同样问题

这个问题不是 Hive 独有的——Delta LakeApache Hudi 沿用了同样的”目录层次结构”分区模型:

# Delta Lake 分区
spark.write.format("delta") \
    .partitionBy("date") \    ← 分区列编码到目录路径
    .save("s3://bucket/events/")
 
# 文件结构:
# s3://bucket/events/date=2024-01-01/part-00000.parquet
# s3://bucket/events/date=2024-01-02/part-00000.parquet
 
# 查询时:WHERE date = '2024-01-01' 触发分区剪裁
#         WHERE event_ts = '2024-01-01T12:00:00' 不触发(全表扫描)

Iceberg 的 Hidden Partitioning 是目前唯一系统性解决了这三个缺陷的方案。


第 2 章 Hidden Partitioning 的实现原理

2.1 核心思想:分区规则存在元数据,不在路径里

Iceberg 的分区规则(partition spec)存储在 metadata.json 中,而不是编码在文件路径里。数据文件可以使用任意路径(通常是 UUID 命名),分区信息通过 Manifest 文件中的 partition 字段记录。

传统分区(路径编码分区):
  文件路径:s3://bucket/events/date=2024-01-01/part-00000.parquet
  分区信息来源:解析路径字符串

Iceberg Hidden Partitioning(元数据编码分区):
  文件路径:s3://bucket/events/data/00000-1-abc123.parquet  ← 路径不含分区信息!
  分区信息来源:Manifest 文件中的 partition 字段
               {event_ts_month: 649}  ← 649 = 2024年1月(epoch 月数)

  分区规则定义(metadata.json 中的 partition-specs):
  {
    "spec-id": 0,
    "fields": [{
      "source-id": 4,           ← 指向 event_ts 字段(field-id=4)
      "field-id": 1000,
      "name": "event_ts_month", ← 分区字段的内部名称
      "transform": "month"      ← 变换函数:取月份
    }]
  }

2.2 写入时的分区计算

用户写入数据时,不需要指定分区列:

# Iceberg 写入:用户只写业务字段,完全不涉及分区
spark.write.format("iceberg") \
    .mode("append") \
    .save("s3://bucket/events/")
 
# Iceberg 引擎在写入时自动:
#   1. 从 metadata.json 读取 partition-spec("按 month(event_ts) 分区")
#   2. 对每条记录计算 partition 值:month(event_ts) = month(2024-01-15T12:00:00) = 649
#   3. 将相同 partition 值的记录路由到同一个数据文件
#   4. 在 Manifest 中记录每个文件的 partition 值
#   5. 文件路径:随机 UUID,不携带分区信息

用户写入代码与分区方案完全解耦——即使分区方案从 month(event_ts) 改为 day(event_ts),用户写入代码不需要任何修改。

2.3 查询时的分区推导

查询时,Iceberg 自动将查询谓词(WHERE 条件)转换为分区过滤:

-- 用户查询:使用原始业务列
SELECT COUNT(*) FROM events
WHERE event_ts >= '2024-01-01'
  AND event_ts <  '2024-02-01';
 
-- Iceberg 内部执行:
-- Step 1:读取 partition-spec:month(event_ts)
-- Step 2:将查询谓词转换为分区谓词:
--   event_ts >= '2024-01-01' → month(event_ts) >= 649 (2024年1月)
--   event_ts <  '2024-02-01' → month(event_ts) <  650 (2024年2月)
--   因此:month(event_ts) = 649 (只有2024年1月)
-- Step 3:在 ManifestList 中,过滤 partition 的 lower_bound ~ upper_bound 包含 649 的条目
-- Step 4:在 Manifest 中,过滤 partition = 649 的文件
 
-- 结果:自动等价于高效的分区扫描,不需要用户写 WHERE month(event_ts) = '2024-01'

这就消除了”分区列陷阱”——用户永远使用业务语义的查询条件(WHERE event_ts >= ...),Iceberg 自动完成分区推导,永远不会因为”忘写分区列”而触发意外的全表扫描。


第 3 章 五种分区变换函数

Iceberg 内置五种分区变换函数,覆盖了绝大多数实际分区需求:

3.1 identity(等值分区)

transform: "identity"
等价于传统 Hive 分区,直接用字段值作为分区键

适用:低基数的字符串或枚举字段(如 country、status、region)
例:PARTITIONED BY (country)
  → 文件按 country 值分组,查询 WHERE country = 'CN' 精准过滤

限制:高基数字段(如 user_id)会产生海量小分区文件,不适合使用 identity

3.2 year / month / day / hour(时间截断)

transform: "year"  → 取年份整数(2024 = 54,从 1970 起计)
transform: "month" → 取月份整数(2024年1月 = 649)
transform: "day"   → 取天数整数(2024年1月1日 = 19723)
transform: "hour"  → 取小时数整数(2024年1月1日00时 = 473352)

适用:时间类字段的时间窗口分区

例:PARTITIONED BY (MONTH(event_ts))
  → 按月分组文件
  → 查询 WHERE event_ts >= '2024-01-15' 自动推导为 month >= 649(2024年1月),精准过滤

时间变换的重要特性

-- 查询时间范围跨多个月的例子
SELECT COUNT(*) FROM events
WHERE event_ts BETWEEN '2024-01-15' AND '2024-03-20';
 
-- Iceberg 的分区推导:
-- month(event_ts) in [649(1月), 650(2月), 651(3月)]
-- → 精确命中 3 个月的数据,不会多扫描
 
-- 对比传统 Hive:
-- 必须写 WHERE date BETWEEN '2024-01-15' AND '2024-03-20'
-- 且还需要表中存在 date 这个冗余字段

3.3 truncate(截断/前缀分区)

transform: "truncate[N]"
  对整数:将值截断到 N 的倍数(类似 floor 取整到 N)
  对字符串:取前 N 个字符(前缀分桶)

例 1(整数截断,N=10000):
  user_id = 12345 → truncate[10000](12345) = 10000
  user_id = 23456 → truncate[10000](23456) = 20000
  → 每个分区约包含 10000 个连续 user_id 的记录

例 2(字符串前缀,N=3):
  product_code = "ABC-001" → truncate[3]("ABC-001") = "ABC"
  product_code = "ABC-002" → truncate[3]("ABC-002") = "ABC"
  → 相同前缀的产品聚合到一个分区

适用:高基数数值字段(如 user_id)的范围分区,避免 identity 产生海量分区

3.4 bucket(哈希分桶)

transform: "bucket[N]"
  取字段值的哈希,对 N 取模,将结果作为分区值(0 ~ N-1)

例(N=64):
  user_id = 12345 → bucket[64](12345) = hash(12345) % 64 = 47
  user_id = 23456 → bucket[64](23456) = hash(23456) % 64 = 15
  → 记录被均匀分布到 64 个桶中

适用:高基数字段的均匀分桶(如 user_id、order_id)
好处:数据分布均匀,避免热点
限制:范围查询无法利用桶分区(WHERE user_id BETWEEN 10000 AND 20000 无法做分区剪裁)

bucket 变换 vs Hudi Bucket Index 的区别

Hudi 的 Bucket Index 是索引机制(路由 Upsert 到正确文件),与分区无关。 Iceberg 的 bucket 变换是分区机制(决定文件的分区归属),与 Hudi 的 Index 完全不同的概念。 两者都用哈希取模,但作用层次不同:Hudi Bucket Index 作用于写入路由,Iceberg bucket 变换作用于分区组织。


第 4 章 Partition Evolution:分区方案的原地演进

4.1 什么是 Partition Evolution,为什么重要

Partition Evolution(分区演进) 是 Iceberg 最颠覆性的能力之一——允许在不重写任何历史数据的前提下,修改表的分区方案。旧数据保持在旧分区方案下,新数据写入新分区方案,查询时 Iceberg 自动感知每个文件使用的分区方案,统一处理。

这个能力的价值是巨大的,因为分区方案变更在生产数据湖中极其常见:

典型的分区演进场景:

场景 1:数据量增长,分区粒度需要细化
  旧:PARTITIONED BY (MONTH(event_ts))   ← 每月一个分区(早期数据量小)
  新:PARTITIONED BY (DAY(event_ts))     ← 每天一个分区(数据量大了,月分区太大)

场景 2:业务重构,增加分区维度
  旧:PARTITIONED BY (DAY(event_ts))
  新:PARTITIONED BY (DAY(event_ts), region)  ← 增加 region 维度,改善区域性查询

场景 3:分区字段更换
  旧:PARTITIONED BY (MONTH(created_at))  ← 按创建时间分区
  新:PARTITIONED BY (MONTH(updated_at))  ← 改为按更新时间分区(因为查询模式变了)

在传统 Hive/Delta/Hudi 中,以上每个场景都需要:

  1. 创建新表(新分区方案)
  2. 将旧表数据全量重写到新表
  3. 下游应用切换到新表
  4. 旧表存档或删除

这个迁移过程需要数天时间(对于 PB 级数据),且期间数据服务中断。

4.2 Partition Evolution 的实现机制

Iceberg 的 partition-specs 支持多版本,每个数据文件记录了它所使用的 partition_spec_id:

metadata.json 中的 partition-specs(演进后):

{
  "partition-specs": [
    {
      "spec-id": 0,              ← 旧分区方案(2023年以前的数据使用此方案)
      "fields": [{
        "source-id": 4,          ← event_ts
        "name": "event_ts_month",
        "transform": "month"
      }]
    },
    {
      "spec-id": 1,              ← 新分区方案(2024年以后的数据使用此方案)
      "fields": [{
        "source-id": 4,          ← event_ts(同一源字段)
        "name": "event_ts_day",
        "transform": "day"       ← 变为按天分区
      }]
    }
  ],
  "default-spec-id": 1           ← 新写入使用 spec-id=1
}

Manifest 文件的每个数据文件条目记录了其对应的 partition_spec_id:

Manifest 中文件 A(2023年写入,spec-id=0):
  partition_spec_id: 0
  partition: {event_ts_month: 636}  ← 2022年12月

Manifest 中文件 B(2024年写入,spec-id=1):
  partition_spec_id: 1
  partition: {event_ts_day: 19723}  ← 2024年1月1日

查询时,Iceberg 对每个 Manifest 条目,根据其 partition_spec_id 选择对应的分区规则做分区剪枝:

SELECT COUNT(*) FROM events
WHERE event_ts BETWEEN '2022-12-15' AND '2024-01-15';
 
-- 对 spec-id=0 的文件(旧方案):
--   month(event_ts) in [636(2022-12), 637(2023-01), ..., 648(2023-12), 649(2024-01)]
--   → 按月过滤
 
-- 对 spec-id=1 的文件(新方案):
--   day(event_ts) in [19719(2022-12-15), ..., 19731(2024-01-15)]
--   → 按天过滤(更精细!)
 
-- 结果:两种方案的文件各自用自己的规则做分区剪枝,统一返回

这就是 Partition Evolution 的工程魔法:旧数据按旧规则过滤,新数据按新规则过滤,对查询完全透明。

4.3 执行 Partition Evolution 的 SQL 命令

-- 查看当前分区规范
SELECT * FROM analytics.events.specs;
 
-- 演进分区方案(将按月改为按天,对新数据生效)
ALTER TABLE analytics.events
REPLACE PARTITION FIELD event_ts_month  -- 移除旧分区字段
WITH DAY(event_ts) AS event_ts_day;     -- 添加新分区字段
 
-- 从此时起,新写入的数据使用 day(event_ts) 分区
-- 历史数据保持 month(event_ts) 分区不变,无需重写
-- 另一种常见演进:增加分区维度(纯新增,不替换)
ALTER TABLE analytics.events
ADD PARTITION FIELD region;    -- 在现有 day(event_ts) 基础上增加 region 分区
 
-- 现在新数据同时按 day(event_ts) 和 region 分区
-- 旧数据只有 day(event_ts) 分区(region 为 null)

Partition Evolution 的生产注意事项

分区演进后,查询时 Iceberg 需要同时处理多个 partition-spec,这会增加一些元数据解析开销。对于历史数据极多(如 10 年的历史数据使用旧 spec)的表,建议在业务低峰期通过 CALL system.rewrite_data_files(table => 'db.events') 异步将旧文件重写为新分区方案——这是可选的性能优化,不是正确性要求。


第 5 章 Hidden Partitioning 对比传统方案

5.1 工程体验对比

维度传统 Hive/Delta/HudiIceberg Hidden Partitioning
写入分区维护用户必须手动维护分区列自动,用户无感知
查询分区剪裁必须使用分区列过滤自动从业务谓词推导
分区列冗余需要额外的分区列(如 date 字段)不需要冗余列
分区方案修改需要全量重写历史数据原地演进,无需重写
分区变换只支持 identity(等值)支持 5 种变换函数
多层分区支持(但路径结构复杂)支持(Manifest 中多字段)
分区陷阱风险高(易犯错,全表扫描无警告)极低(Iceberg 自动处理)

5.2 与 Delta Lake Z-Order 的对比

Delta Lake 的 Z-Order 是一种多维数据聚合技术,通过 Z 曲线映射使多个维度的数据在空间上局部化,从而提升多维过滤的 Data Skipping 效率:

-- Delta Lake Z-Order(对 user_id 和 event_date 做多维聚合)
OPTIMIZE events ZORDER BY (user_id, event_date);

Z-Order 和 Hidden Partitioning 解决不同层次的问题:

维度Iceberg Hidden PartitioningDelta Lake Z-Order
作用层次文件组织(哪些记录在同一文件里)文件内数据布局(Row Group 排列)
粒度文件级过滤Row Group 级过滤
支持字段类型时间、字符串、数值均支持任意字段
自动性写入时自动需要手动运行 OPTIMIZE ZORDER
分区演进原生支持需要重新 ZORDER

两种技术不是互斥的——Iceberg 表在做 Hidden Partitioning 的同时,也可以通过 sort_order(排序规范)在文件内做类似 Z-Order 的优化。


小结

Hidden Partitioning 是 Iceberg 对数据湖分区模型的一次彻底重新设计:

  • 消除分区列陷阱:查询谓词自动转换为分区过滤,用户永远不会”踩陷阱”触发意外全表扫描
  • 消除冗余分区列:分区规则存在元数据中,不需要在 Schema 中维护额外的派生列
  • 实现 Partition Evolution:旧文件和新文件可以使用不同的分区方案共存,无需重写历史数据

这三个能力共同使 Iceberg 成为在分区设计和演进维度上远超 Delta Lake 和 Hudi 的方案。

下一篇 04 事务与并发控制——乐观锁与 Optimistic Concurrency 将深入 Iceberg 如何通过 Snapshot 隔离和乐观并发控制实现 ACID 事务,以及与 Delta Lake 的事务隔离级别机制的精准对比。

思考题

  1. Iceberg 的 Hidden Partitioning 让查询引擎根据用户的过滤条件(如 WHERE event_time >= '2024-01-01')自动推导应该访问哪些分区。如果过滤条件是 WHERE to_date(event_time) = '2024-01-01'(通过函数处理),Iceberg 能否自动识别并应用分区裁剪?用户需要如何改写 SQL 来确保裁剪生效?
  2. Partition Evolution 支持修改分区规则而不重写历史数据。如果分区演进次数很多(表的分区规则经历了 10 次演进),查询规划阶段需要维护 10 套分区规则,这是否会显著增加查询规划的复杂度和时间?
  3. Iceberg 的 Bucket 分区(BUCKET(col, N))与 Hive 的分桶表(CLUSTERED BY)相比,在 JOIN 优化(Bucket Map Join)上有什么异同?如果两张表都按相同 Key 和相同 Bucket 数量做了 Bucket 分区,Trino 能否自动识别并利用这个 Co-location 属性优化 Join,避免 Shuffle?