昨天把一份 98 MB 的 Parquet 剖開,檔案最後有一塊 metadata 叫 footer,記了每一段 row group(水平切開的一組列)裡每欄的 min/max。WHERE id > 4000000 靠這份統計,五段裡剪掉三段,只讀 1.41 MB
問題是:生產環境一張表很少只有一個檔,常見是幾千到幾百萬個 Parquet 散在 S3 上SELECT COUNT(*) FROM sales WHERE date = '2026-08-25'
引擎為什麼知道只要打開其中三個檔、另外 999,997 個連 footer 都不用碰?
昨天介紹的單位是 row group,而 footer 是寫在那個 Parquet 的最後,只認得自己裡面那幾段,沒有隔壁那份檔的 min/max。
當有一百萬個檔的時候,不能把一百萬個 footer 都打開再決定要哪些,所以需要有一份檔案外面的目錄:每個 data file 的路徑、它屬於哪個日期(或別的分區)、每欄的 min/max,先寫在別處,引擎看完目錄,才決定要不要去碰那個 Parquet。
這份目錄舊做法寫在資料夾路徑裡,Iceberg 改寫成一棵 metadata 樹。
今天要解決的問題是:
Hive 風格是把篩選條件寫進資料夾路徑,資料按日期分堆,路徑長這樣:
s3://warehouse/sales/date=2026-08-25/file-001.parquet
查 WHERE date = '2026-08-25' 時,引擎只要請物件儲存列出這個資料夾底下的檔,別天的連檔名都看不到。
這有兩個問題:
WHERE id > 4000000 這種條件不在路徑裡,引擎還是得把當天每一個 Parquet 的 footer 打開來看。Apache Iceberg 是一種 table format(資料表格式),不是檔案格式,也不是查詢引擎。
Parquet 管的是一個檔裡面資料怎麼排;Hive 資料夾管的是用路徑當目錄。
Iceberg 管的是這張表:現在有哪些檔、schema 是什麼、哪個版本是最新。底下的資料通常還是一堆 Parquet,散在 S3 這類物件儲存上。
物件儲存沒有跨物件的交易
Iceberg 要把「一張表」做成有版本、commit 是原子的,得先決定可變狀態放哪。答案在 metadata.json。

圖上的檔名是示意:v42 是這張表的 metadata 改寫過 42 次;snap-N.avro 是 snapshot N 的 manifest list;m-1.avro 是一份 manifest。最底下的 .parquet 才是資料,上面四層都是 metadata。
S3 上改兩個 key 沒有一起成功或一起失敗這件事
Iceberg 把可變狀態壓縮成一個指標:catalog 裡「這張表 current 的 metadata.json 在哪」。commit 就是對這個指標做一次 compare-and-swap(比較並交換)。其他檔(manifest、Parquet)寫出去就不改。失敗就整次 commit 不算,不會出現一半新、一半舊。
所以 metadata.json 裡很多欄位是複數,再另用一個 id 指「現在用哪一個」:
schemas + current-schema-id,不是單一 schema
partition-specs + default-spec-id,不是單一 partition-spec
snapshots + current-snapshot-id
要留歷史,是因為舊的資料檔是用舊的 spec 寫出來的。讀的時候得用當時的 partition spec 去解它的 partition 值,用當時的 schema id 做欄位對應。Hive metastore 往往只記一份「現在的 schema」,舊檔對不上新欄位,schema evolution 才會那麼痛。
snapshots 每次 commit 長一筆,各自指向一份 manifest list。current-snapshot-id 選其中一個。讀舊版本就是改指到另一個 snapshot,不必另做一套 time travel 機制。
下一節的問題是:一個 snapshot 怎麼記住「我有哪些檔」?
最笨的做法是一份大清單,列出全表每一個 Parquet。每次 commit 都要重寫這份清單,成本跟表有多大成正比。
Iceberg 拆成兩層:
commit 只重寫真正變動的那幾份 manifest,沒改到的 manifest 被新的 manifest list 重新引用。寫放大變成跟「這次改了多少」成正比,不是跟「全表有多大」成正比。這就是為什麼不能合成一層。
剪枝資訊也照同一套邏輯分兩層:
file_path、partition、record_count、file_size_in_bytes,加上欄位統計 lower_bounds、upper_bounds、null_value_counts、value_counts。bounds 有一件容易寫錯的事:它是保守的,字串還可能被截斷,只留前幾個 byte。所以 bounds 只能拿來排除檔案(證明這個檔一定沒有目標值),不能拿來確認檔裡一定有。剪枝必須是「證明一定沒有才跳過」。這個不對稱跟昨天 Parquet footer 的 min/max 一樣:可能有,不是一定有。
前兩節是資料結構,讀一張表時,引擎把它收成一條漏斗,越前面讀的 byte 越少、剪掉的量越大:
current-snapshot-id 選一個 snapshotid > 4000000 這種不在路徑裡的條件,在這裡才派得上用場)標題那句「讀一張表要打開幾個檔案」,答案就是這條漏斗的輸出。開頭那句 WHERE date = '2026-08-25',多數 manifest 在第 2 步就掉了;再加 AND id > 4000000,第 4 步還能再砍一批檔。Hive 資料夾做得到第 2 步的一部分,做不到第 4 步。
兩個接縫值得單獨記
Residual predicate(殘留謂詞) 若檔案就在 date=2026-08-25 這個 partition,date = '2026-08-25' 已被 partition 完全滿足,不必再對每一列重算一次,可以從殘留謂詞裡拿掉。id > 4000000 還在,得繼續往下推到 row group、再進頁面。分區剪枝跟述詞下推的接縫就在這裡,實作漏掉的話,等於 partition 白切了還是每列比一次日期。
Delete files scan planning 不只吐出「要讀哪些 Parquet」,還會把對應的 delete 綁到每個 data file 上。下游拿到的是「檔案 + 要套用的刪除集合」。檔案不能改的時候「這列被刪了」寫在哪,是 D12 的題。
架構的最後一截,已經決定打開這個 Parquet 了,頁面裡怎麼只解該解的列,是明天 D11 的晚物化。
留一個沒有標準答案的問題:一張表 10 萬個 Parquet,manifest 要切幾份?1 個 manifest 存 10 萬列,檔案數少,但每次改都要重寫那一份;10 萬個 manifest 每檔一列,細,metadata 會爆。
這個取捨誰決定、依什麼調?
那就明天見~