土法炼钢兴趣小组的算法知识备份

【数据湖与开放表格式】隐藏分区与分区演进

文章导航

分类入口
databasestorage
标签入口
#iceberg#hidden-partitioning#partition-evolution#transform#bucket#partition-spec#table-format

目录

Iceberg 元数据树 里,我们看到每个 data file 的分区元组直接存在 manifest 里,planner 靠它做 partition pruning。这一篇回答两个 Hive 时代长期烧人的问题:

证据锚定 Apache Iceberg 表规范 的《Partitioning》《Partition Transforms》《Partition Evolution》《Scan Planning》各节,与 apache/iceberg 1.x 的 org.apache.iceberg.transforms.*。实验沿用第 8 篇那张 sales.orders 表,在它上面真实演进 spec 并追加数据,带 == 的输出均来自实跑。

实验环境:Intel Core i9-12900K,32 GB 内存,Linux 6.6(WSL2,Arch),Python 3.14.5,pyiceberg 0.11.1,pyarrow 24.0.0;catalog=SQLite,warehouse=本地 FS,format-version=2


一、Hive 分区的「分区裂脑」

Hive 把分区做成目录:/table/dt=2026-01-02/...。这套模型有三个反复咬人的问题:

  1. 分区列是一份独立的物理列dt 通常是从 ts 派生出来、再单独写进去的列。源列 ts 和分区列 dt 是两份数据,靠写入端自觉保持一致——一旦写入逻辑算错时区、跨天边界处理不一致,dtts对不上,查询按 dt 裁到的文件里混着别的天。这种「同一语义两份数据各说各话」可以叫分区裂脑。
  2. 查询必须显式写分区谓词。优化器一般不知道 dt = func(ts) 这个隐含关系,用户写 WHERE ts >= '2026-01-02' 而不写 dt,引擎只能全表扫;写了 dt 又得保证和 ts 谓词语义一致。负担全压在写 SQL 的人身上。
  3. 分区方案几乎不可演进。目录结构写死了分区键。想从「按天」改成「按小时」或「天 + 分桶」,老目录布局变不了,通常只能重建表、重写历史数据。

Iceberg 的设计目标之一(表规范《Goals》)直接对着这三点:「Partitioning will be table configuration. Reads will be planned using predicates on data values, not partition values.」——分区是表的配置,读用数据值谓词来 plan,而非分区值。下面看它怎么落地。


二、partition spec 与 transform

partition spec 的结构

表规范《Partitioning》定义:一张表配一个 partition spec,spec 是一组 partition field,每个 field 由四部分构成:

关键点:spec 存的是「从表数据到分区值的变换」,不只是分区值本身。规范明确这份变换被用来把数据列谓词转成分区谓词——这正是隐藏分区与跨 spec 裁剪能成立的根。源列必须是基本类型(可嵌在 struct 里,但不能在 map/list 内)。

第 8 篇建表时用的 spec 0(实跑 dump):

spec-id 0 [(field_id=1000, name='ts_day', transform='day', source_id=2)]

含义:对源列 ts(id=2)施加 day 变换,产出名为 ts_day、partition field id=1000 的分区字段。

内置 transform

表规范《Partition Transforms》给出全部内置变换(源类型与结果类型摘录):

transform 含义 源类型(摘录) 结果类型
identity 原值不变 除 geometry/geography/variant 外任意 源类型
bucket[N] 哈希后对 N 取模 int/long/decimal/date/time/timestamp(tz)/string/uuid/fixed/binary int
truncate[W] 截断到宽度 W int/long/decimal/string/binary 源类型
year 取年(自 1970 起的年数) date/timestamp(tz)/timestamp_ns int
month 取月(自 1970-01 起的月数) date/timestamp(tz)/timestamp_ns int
day 取日(自 1970-01-01 起的天数) date/timestamp(tz)/timestamp_ns date
hour 取小时(自 1970-01-01 00:00 起的小时数) timestamp(tz)/timestamp_ns int
void 恒为 null 任意 源类型或 int

几条要点:

bucket 与 truncate 的细节

bucket[N](表规范《Bucket Transform Details》):用 32 位 Murmur3 哈希(x86 变体,seed=0),先丢掉符号位再对 N 取模:

\[ \text{bucket}_N(x) = \big(\,\text{murmur3\_x86\_32}(x)\ \&\ \texttt{Integer.MAX\_VALUE}\,\big)\ \%\ N \]

& Integer.MAX_VALUE(即 & 0x7fffffff)保证结果非负。哈希按类型的字节编码细节见规范 Appendix B;对应源码 org.apache.iceberg.transforms.Bucket(apache/iceberg 1.x)。bucket 适合高基数列(如用户 id)做均匀打散,避免「按 identity 分区导致海量小分区」。

truncate[W](表规范《Truncate Transform Details》)按类型不同:

类型 规则
int/long \(v - (v \bmod W)\),余数取正 W=101→0-1→-10
decimal 按列 scale 缩放 W 后同上 W=50, s=210.65→10.50
string 取前 L 个码点:v.substring(0, L) L=3iceberg→ice
binary 取前 L 字节 L=3\x01\x02\x03\x04\x05→\x01\x02\x03

整型 truncate 的余数必须取正,规范给了在 % 可能返回负值的语言里的正确写法 \(v - (((v \bmod W) + W) \bmod W)\)truncate 适合「按前缀分区」,例如按字符串前缀或数值区间。


三、隐藏分区:查询不写分区列也能裁剪

「隐藏」体现在两处:分区字段不是用户要手填的独立列,存储布局对查询透明;查询只在源数据列上写谓词,Iceberg 自动推导分区谓词。

机制是表规范《Scan Planning》的核心一句:scan 谓词被转换成分区谓词来筛文件,转换用「写该 manifest 的 partition spec」。具体到 transform,叫包含投影(inclusive projection):把数据列谓词投影成一个不漏匹配文件的分区谓词。例如 day 是单调的,ts >= 2026-01-02 00:00:00 可安全投影成 ts_day >= day(2026-01-02) = 20455

第 8 篇那次实跑已经验证:只在 ts 上加谓词,planner 精确剔除了 2026-01-01 的文件:

== scan with hidden-partition predicate (ts only) ==
planned data files for ts>=2026-01-02: 4   # 全表 5 个,剔除了 2026-01-01 那个
   00000-0-c9fb…​.parquet spec 1 Record[20456, 2]
   00000-1-c9fb…​.parquet spec 1 Record[20456, 0]
   00000-0-0c18…​.parquet spec 0 Record[20455]
   00000-0-cc2d…​.parquet spec 0 Record[20455]

我们没有写 ts_day,分区列对查询是隐藏的;裁剪却照常发生。这就消解了 Hive 的两个痛点:分区列不再是用户维护的第二份数据(裂脑没了),查询也不必显式写分区谓词(写错/忘写导致全表扫的坑没了)。

不是所有谓词都能投影。投影的有效性取决于 transform 的单调性与谓词形态:

所以隐藏分区不是「任何写法都自动最优」,而是「在源列上按 transform 语义写谓词,就能拿到分区裁剪」。


四、实验:演进 partition spec

现在给 sales.orders 演进分区方案:在原有 day(ts) 基础上追加 bucket[4](category)。pyiceberg 实跑:

from pyiceberg.transforms import BucketTransform

with tbl.update_spec() as us:
    us.add_field("category", BucketTransform(4), "category_bucket")

演进后 dump 所有 spec(实跑):

== partition specs after evolution ==
spec-id 0 [(1000, 'ts_day', 'day', src=2)]
spec-id 1 [(1000, 'ts_day', 'day', src=2), (1001, 'category_bucket', 'bucket[4]', src=3)]
default-spec-id 1

对照表规范《Partition Evolution》:

接着在演进后的 spec 1 下再追加一批(id 6/7,日期 2026-01-03)。看落盘的目录布局(实跑):

== data files on disk ==
/sales/orders/data/ts_day=2026-01-01/00000-0-dffc…​.parquet                    # spec 0
/sales/orders/data/ts_day=2026-01-02/00000-0-cc2d…​.parquet                    # spec 0
/sales/orders/data/ts_day=2026-01-02/00000-0-0c18…​.parquet                    # spec 0
/sales/orders/data/ts_day=2026-01-03/category_bucket=0/00000-1-8278…​.parquet  # spec 1
/sales/orders/data/ts_day=2026-01-03/category_bucket=2/00000-0-8278…​.parquet  # spec 1

布局直接体现演进:spec 0 时期的文件只有 ts_day=.../,spec 1 之后的文件多了一层 category_bucket=.../。两种布局并存于同一张表,老文件原地不动。

这里的目录名(ts_day=2026-01-03)只是 pyiceberg 写文件时的可读路径约定,不是 Iceberg 判断分区的依据。Iceberg 认的是 manifest 里记录的分区元组(第 8 篇),即便目录名乱了也不影响 planning。这与 Hive「目录名即分区」是本质区别。


五、分区演进为何不重写历史

核心机制:每个文件携带它所属的 spec,planning 时按文件各自的 spec 转换谓词。

第 8 篇的 inspect.entries 已显示:老文件(seq 1–3)的分区元组是 {ts_day, category_bucket: None},新文件(seq 4)是 {ts_day, category_bucket: 实值}。再看 manifest list(实跑),partition_spec_id 把新旧 manifest 分得清清楚楚:

== inspect.manifests ==
 spec_id  added  partition_summaries
   1      2      [{low:2026-01-03, up:2026-01-03}, {low:0, up:2}]   # 新 manifest:两个分区字段摘要
   0      1      [{low:2026-01-02, up:2026-01-02}]                  # 老 manifest:只有一个
   0      1      [{low:2026-01-02, up:2026-01-02}]
   0      1      [{low:2026-01-01, up:2026-01-01}]

为什么必须「一个 manifest 只装一个 spec 的文件」?表规范《Manifests》给了原因:manifest 文件的 schema 由它的 partition spec 决定(分区元组的结构跟着 spec 走),所以 spec 变了就必须写新 manifest,老文件留在老 manifest 里。manifest list 用 partition_spec_id 标明每个 manifest 用哪个 spec。

planning 时(表规范《Scan Planning》):「Conversion uses the partition spec that was used to write the manifest file regardless of the current partition spec.」——用写该 manifest 的 spec,而非当前 spec 来把数据谓词转成分区谓词。于是:

这就是第 8 篇 scan plan 里 spec 0 与 spec 1 文件混在一起被正确裁剪的原因。整个过程没有重写任何老数据:演进的代价只是表元数据多一个 spec、之后的新文件走新布局。

flowchart TD
  P["查询谓词(源列)<br/>ts >= 2026-01-02"]
  P --> S0["对 spec 0 的 manifest<br/>投影 → ts_day >= 20455"]
  P --> S1["对 spec 1 的 manifest<br/>投影 → ts_day >= 20455"]
  S0 --> F0["裁出 spec 0 老文件"]
  S1 --> F1["裁出 spec 1 新文件"]
  F0 --> R["候选文件集合(新旧并存)"]
  F1 --> R

演进的代价与边界:


六、与 Hive 静/动态分区对比

维度 Hive 静态/动态分区 Iceberg 隐藏分区
分区列来源 用户显式声明并写入的独立物理列 由 transform 从源列派生,不需用户单独维护
查询谓词 必须写分区列谓词才裁剪 在源列写谓词,自动投影成分区谓词
分区识别 靠目录名(dt=... 靠 manifest 里的分区元组,与目录名解耦
一致性风险 源列与分区列可能不一致(裂脑) 分区值由 transform 确定,无第二份数据
分区演进 基本要重建表/重写历史 加新 spec,老文件原地保留,按各自 spec 裁剪
planning 递归 LIST 目录 读 manifest(O(1) 量级远程调用)
动态分区写入 INSERT ... PARTITION、易产生大量小分区 写入端只写数据,分区由 spec 决定

要点不在「Iceberg 全面更好」,而在两者把同一职责放在了不同位置:Hive 把分区当成用户负责的物理列与目录约定,Iceberg 把分区当成表配置 + 元数据派生。前者把一致性与裁剪正确性的负担压给写 SQL 和写 ETL 的人,后者把它收进表格式自己。


七、边界与坑

实跑 inspect.partitions(含 spec id,印证「分区按各自 spec 归属」):

== inspect.partitions ==
 partition                                  spec_id  record_count  file_count
 {ts_day:2026-01-03, category_bucket:2}     1        1             1
 {ts_day:2026-01-03, category_bucket:0}     1        1             1
 {ts_day:2026-01-02, category_bucket:None}  0        3             2
 {ts_day:2026-01-01, category_bucket:None}  0        2             1

category_bucket: None 的两行是 spec 0 老分区,spec id 标为 0;spec 1 新分区两个字段都有值。同一张表里新旧分区方案并存、各按自己的 spec 统计。


八、transform 选型与写入布局

分区设计的目标是「让常见查询能裁掉大部分文件,同时不制造海量小文件」。不同 transform 适配不同的列与查询形态。

按 transform 的适用场景

transform 典型源列 适配查询 风险
identity 低基数枚举(地区、租户少时) 等值/IN 高基数列会炸出海量分区
day/hour 时间戳 时间范围(保序裁剪) 粒度过细 → 小文件;过粗 → 裁不动
month/year 时间戳 长跨度时间范围 单分区过大
bucket[N] 高基数 id(user_id、order_id) 等值点查、按桶 join 不保序,范围谓词不裁;N 难改
truncate[W] 字符串前缀、数值区间 前缀/区间查询 W 选得不当裁剪弱
void 演进时「逻辑丢弃」字段 —— 仅用于演进,不产生新裁剪

常见组合是「时间 transform + 高基数 bucket」:day(ts) 控制时间裁剪,bucket[N](user_id) 把每天的数据均匀打散到 N 个桶,既能按时间裁,又能按 user_id 等值裁,还避免单分区过大。本文实验的 day(ts) + bucket[4](category) 就是这个套路的缩小版。

bucket 数怎么定

bucket[N] 的 N 一旦写入 spec,改它就是分区演进(见第五节),新老桶号不可比。规划时按「每个桶的目标文件大小」反推:希望单桶单次写约落到一个合理大小的文件(如几十到上百 MB),就用「预估每批行数 × 行宽 ÷ 目标文件大小」估 N。宁可一开始 N 略大,也别频繁演进。bucket 用 32 位 Murmur3(seed=0)哈希,分布均匀性对一般业务键足够,但严重倾斜的键(少数值占绝大多数行)即便分桶也会让对应桶偏大——这类列更适合先 truncate 或换分区键。

分区不是排序

分区把文件分到不同「桶/目录元组」,桶内文件不保证有序。要让桶内也利于裁剪与范围扫,需要写入排序(sort order,规范《Sorting》)或 compaction 时 sort/z-order(第 17 篇)。分区解决「跳过整批文件」,排序解决「文件内/文件间的有序裁剪」,两者互补。第 8 篇 dump 的 default-sort-order-id: 0 表示本例未设排序。

多参数 transform(V3)

表规范 V3 引入多参数 transform(一个分区/排序字段可由多个源列产生),扩展了分区表达力。该特性属于 V3(已采纳),但能否使用取决于所用引擎/库版本对 V3 的支持程度;本系列遇到时按「规范已定义、引擎支持视版本而定」标注,不当作随处可用。本文实验为 format-version=2,分区字段都是单源列。


九、小结

Iceberg 把「分区」从「用户维护的物理列 + 目录约定」改成「表配置的 transform + 元数据派生」,由此得到两个 Hive 给不了的能力:

这两点都建立在第 8 篇的元数据树之上:分区元组存在 manifest 里、与目录名解耦,spec 是「历史全集 + 当前指针」。下一篇进入行级删除与 Merge-on-Read,看 delete file 如何挂到 data file 上,以及 V2 delete file 与 V3 deletion vector 的差异。


上一篇Iceberg 元数据树

下一篇行级删除与 Merge-on-Read

返回 系列目录


附录、扩展阅读与工程注记

下面是设计与演进分区时常踩的点,尽量锚定规范条款或实跑观察。

bucket 为何要丢符号位

Murmur3 哈希结果是有符号 32 位整数,可能为负。& Integer.MAX_VALUE& 0x7fffffff)先清掉符号位再 % N,保证桶号落在 [0, N)(规范《Bucket Transform Details》)。直接对负数取模在不同语言会得到负余数,破坏桶号连续性。

为什么 bucket 范围谓词不裁

bucket[N] 把相邻值哈希到任意桶,不保序category > 'book' 在 bucket 维度无法对应一段连续桶号,所以包含投影对范围谓词在 bucket 维度退化为「不裁」(仍可用其他分区字段裁)。只有等值谓词 category = 'book' 才能投影成 category_bucket = bucket4('book')

day 分区值为何是整数

day 结果类型是 date,但规范要求读端也接受 int(自 1970-01-01 起的天数)。实跑 scan plan 里看到 20455/20456 就是这个整数表示(2026-01-02、2026-01-03)。这也是为什么按 ts 的范围谓词能投影成对该整数的范围比较。

void 不是删除分区

void 把分区字段的 transform 换成「恒 null」,使该字段在新写入中失去区分度——在 V1 里用它「逻辑丢弃」一个分区字段(因为 V1 不能安全删字段)。V2 起可显式增删 partition field,但 void 仍可用于「停用某分区维度但不改字段 id 布局」。

演进只影响新写入

加了新 spec 后,只有之后写入的文件走新布局;历史文件保持老 spec。想让老数据也按新布局重新组织,要显式 rewrite_data_files(第 17 篇),这会读老文件、按新 spec 重写、产生新 snapshot——代价等同一次批量重写,不是「免费」的。

同一 manifest 不混 spec

规范《Manifests》要求一个 manifest 只装单一 spec 的文件,因为 manifest 的 schema(分区元组结构)由其 spec 决定。实跑 inspect.manifests 里 spec 0 与 spec 1 分属不同 manifest,正是这条规则的体现。

partition field id 不可乱动

演进时已有 partition field 的 id 必须保持不变(实跑里 ts_day 始终是 1000),因为这些 id 被用作 manifest 分区元组的字段 id。改 id 会让老 manifest 的分区元组对不上 spec。新字段从 last-partition-id 递增分配(这里 1001)。

目录名仅为可读性

ts_day=2026-01-03/category_bucket=0/ 这类路径是写文件时的人类可读约定,Iceberg planning 不读目录名,只读 manifest 里的分区元组。把文件挪到别的目录(路径仍被 manifest 记录)不影响裁剪——这与 Hive「目录名即分区真理」相反。

隐藏分区不消除小文件

隐藏分区改变的是「分区如何被声明与裁剪」,不改变物理文件数。过细的分区(高基数 identity、过大的 bucket N、hour 粒度)照样产生大量小文件,仍需 compaction。分区设计要同时权衡「裁剪收益」和「小文件成本」。

写倾斜数据的注意

bucket 假设键分布大致均匀。若某些值占绝大多数行(热点键),即使分桶,热点值所在的桶也会偏大。可考虑对热点列先 truncate、或在应用层加盐(salting)打散后再 bucket——但加盐会改变等值查询的写法,要权衡。

与时间旅行的关系

分区演进产生新 snapshot,读老 snapshot 时看到的是当时的 spec 与文件布局(第 16 篇)。所以「演进后再回看历史快照」是自洽的:每个 snapshot 引用的 manifest 各自带 spec id,按当时状态裁剪。

跨 spec 查询的统计口径

inspect.partitions 把不同 spec 的分区分别列出(实跑里 spec 0 的分区 category_bucketNone,spec 1 的有值)。做容量分析时要意识到「同一张表的分区可能来自不同 spec」,不能假设所有分区都有相同的字段集合。

identity 分区的陷阱

identity 对高基数列(如时间戳原值、UUID)会产出近乎一行一分区的灾难性布局——这正是很多 Hive 表「分区爆炸」的根因。需要分桶/截断/时间归一的列,不要用 identity 直接分区。


参考资料

  1. Apache Iceberg Table Spec, Partitioningiceberg.apache.org/spec
  2. Apache Iceberg Table Spec, Partition Transforms / Bucket Transform Details / Truncate Transform Details
  3. Apache Iceberg Table Spec, Partition Evolution
  4. Apache Iceberg Table Spec, Scan Planning / Manifests(每 manifest 单一 spec、按写入 spec 转换谓词)
  5. Apache Iceberg Table Spec, Goals(reads planned on data values, not partition values)
  6. apache/iceberg 1.x 源码:org.apache.iceberg.transforms.{Bucket, Truncate, Days, Hours, Months, Years}org.apache.iceberg.PartitionSpecUpdatePartitionSpec
  7. 实验环境:pyiceberg 0.11.1、pyarrow 24.0.0、Python 3.14.5、Linux 6.6(WSL2/Arch)、Intel i9-12900K、32 GB;catalog=SQLite,warehouse=本地 FS,format-version=2

同主题继续阅读

把当前热点继续串成多页阅读,而不是停在单篇消费。

2026-06-30 · database / storage

【数据湖与开放表格式】Lakehouse 全景:从 Hive 表到开放表格式

Hive 目录式分区表把『表』等同于『一组目录加 metastore 里的分区行』,于是没有原子提交、planning 要 LIST 目录、schema 与分区演进常要重写。本文用这三个硬伤切入,讲清 lakehouse 把表拆成『不可变数据文件 + 可变元数据指针 + catalog』三层后各自解决了什么,并给出全系列的分层地图。

2026-06-30 · database / storage

【数据湖与开放表格式】表格式为什么存在

目录式分区表(Hive 表)在对象存储上有三处硬伤:并发写部分提交、list planning 太贵、缺快照隔离与原子提交。本文拆开放表格式补上的四件事——原子提交、快照隔离、文件级统计裁剪、schema 与分区演进,并抽象出三家共有的『元数据指针 + 不可变数据文件』骨架。

2026-06-30 · database / storage

【数据湖与开放表格式】Iceberg 元数据树

拆解 Iceberg 的四层元数据:catalog 指针 → metadata.json → manifest list(snapshot)→ manifest file → data file。讲清 snapshot 与 manifest 里的分区数据和列级 stats(lower/upper bound、null/value count)如何让一次查询不 list 目录就收敛到文件集合,并给出表规范 V1/V2/V3 的版本边界。基于 pyiceberg 0.11.1 真实建表逐层 dump。

2026-06-30 · database / storage

【数据湖与开放表格式】行级删除与 Merge-on-Read

Iceberg 在不可变文件上做行级删除的两条路线:copy-on-write(重写整文件)与 merge-on-read(写 delete 文件,读时合并)。讲清 position delete 与 equality delete 的语义、字段与作用域规则,写放大/读放大的取舍,V2 delete file 到 V3 deletion vector(Puffin 承载)的差异与迁移,以及读路径如何把 data file 与 delete 合并出可见行。基于 pyiceberg 0.11.1 实测 CoW 写放大并观察 MoR 回退。


By .