D19 最後提到 pushdown 能不能生效,要回到 TableProvider 來看,今天就來說明這一段,順便看 Iceberg 表格式版本的演進。
今天回答三個問題:
Parquet 是檔案格式,管一份檔案內部長怎樣。Iceberg 是表格式,管一堆檔案怎麼組成一張表、怎麼演進、怎麼刪、怎麼快照。
引擎不直接讀 Parquet,而是先問表格式:這張表有哪些檔?這幾個 predicate 你接不接?有沒有 snapshot 可以回到上週?
這個「問」的介面就是契約。DataFusion 叫 TableProvider,Spark DSv2 叫 Table + ScanBuilder,名字不同,要回答的問題一樣。
| 能力 | 引擎會問什麼 | 沒這項會怎樣 |
|---|---|---|
| 分區資訊 | 能不能只讀符合條件的 partition? | scan 只能全掃 |
| 下推支援 | 這幾個 predicate / projection 你接不接? | filter pushdown、column pruning 退化到 executor 層 |
| 併發資訊 | 可以幾個 task 平行讀? | 併行度只能靠檔案數猜 |
| 排序資訊 | 資料本來就排好了嗎? | SortMergeJoin 無法沿用天然順序 |
| 統計資訊 | min / max / null 分布、row count 有嗎? | optimizer 只能猜 selectivity 跟 join order |
| 更新資訊 | 表格式 V 幾?有沒有 delete file? | 讀出來的資料混進「應該被刪」的列 |
少任何一項,optimizer 的決策鏈就斷一節。
這六項都要在規劃階段決定,執行階段太晚了。Iceberg 規劃完的產物叫 FileScanTask:
FileScanTask #1
data file: s3://.../part-00042.parquet
partition: date=2026-08-25
residual predicate: amount > 100
delete files: [dv-00042.puffin]
重點是自足:executor 拿到它就能開工,不用再問 catalog、不用再打開 metadata。這個性質等一下講 Comet 時會直接用到。
每一版都是在補前一版擋住的東西。
Hive 的做法是把分區值寫進資料夾路徑,/sales/dt=2026-08-25/。三個問題:
WHERE ts > '2026-08-25',Hive 不知道那對應到 dt 這個資料夾,剪不掉。V1 的解法是用 metadata 描述檔案,不用目錄。表的狀態是一棵樹:metadata.json 指向 snapshot,snapshot 指向 manifest list,manifest list 指向一堆 manifest,manifest 裡才列出資料檔以及每個檔案的分區值與欄位統計。
這棵樹一換,三件事同時解決:
day(ts))算出來、記在 metadata 裡的。使用者照常寫 WHERE ts > ...,剪枝由 Iceberg 自己做,不用背路徑規則。metadata.json 的指標就是原子操作。時光回溯順便免費拿到。要注意 V1 不是只支援 append,它也能 overwrite,只是變更的最小單位是「整個檔案」。
V1 那個「最小單位是整檔」就是痛點。一張 500 MB 的檔案裡刪掉一列,得把整份讀出來、濾掉那列、重寫一份新的,這就是 D12 講的 copy-on-write。合規刪除、CDC upsert 這類「改動很小但很頻繁」的場景會被打爆。
V2 的解法是另外寫一種檔案,專門記「哪些列不算數」,讀的時候再合併掉,也就是 merge-on-read。兩種形式:
但這裡冒出一個新問題:資料檔跟 delete file 都在長,讀的時候怎麼知道哪個 delete 該套用到哪個資料檔?V2 的答案是 sequence number。每次 commit 拿到一個遞增序號,delete file 只作用在序號比它舊的資料檔上。沒有這個序號,MOR 的結果就沒有明確定義。
V2 用了幾年之後,delete file 本身成了瓶頸。每次刪除都新增一份 delete file,它們會一直堆積,讀一個資料檔可能要先打開十幾份 delete file 再做合併。D12 已經拆過這筆成本。
V3 的主力是 deletion vector:一個資料檔對應一份 bitmap(Roaring bitmap,存在 Puffin 檔裡),記錄哪些列被刪。關鍵不在格式而在數量,新的刪除是改寫那份 bitmap,不是再多疊一份檔案。所以讀成本從「跟刪除次數成長」變成固定的一份。它取代的是 positional delete,equality delete 在 V3 仍然保留。
其他幾項:
_row_id 跟 _last_updated_sequence_number。有了它,找出兩個 snapshot 之間改了哪些列變成一次查詢,而不是一次全表 diff。CDC 真正變便宜是在這裡,不是 V2。 V2 只是讓 CDC 可行。metadata.json 不含資料與統計,不在範圍內)。時間軸別寫太早。 規格 2025 年才定案,引擎落地拖到 2026:AWS 2025 年 11 月、Snowflake 2026 年 5 月 GA、Databricks 2026 年 4 月 Preview,開源 Trino 至今未支援。而且 V4 已經在開發中了。
V1 把目錄換成 metadata,V2 把「整檔重寫」換成 delete file,V3 把「delete file 堆積」換成單一 bitmap。每一版都是把上一版的解法所產生的新成本再收掉一次。
直覺會覺得舊 reader 應該「看得懂多少讀多少」。規格說的正好相反。
Iceberg spec 對 format version 的定義是:版本號遞增就是因為新功能會破壞 forward compatibility,舊 reader 沒辦法正確讀出新表。所以 V1 reader 拿到 V2 表,正確行為是看 format-version,超出支援範圍就直接拒絕開表。
這是 fail closed,不是降級。「保守一點、不讀 delete file」那條路不存在,因為忽略 delete file 等於把刪掉的列讀回來,那正是最典型的錯誤結果。
想讓舊引擎繼續讀,規格給的答案是寫端不要升版。旋鈕在寫端,不在讀端。
(順帶一提,Delta 走的是另一條路:readerFeatures / writerFeatures 兩張清單,client 逐項比對。Iceberg 的 format-version 是單一、全有全無的閘門,沒有這種細粒度。)
在實作層:
| 層級 | 機制 | 粒度 | 不支援時 |
|---|---|---|---|
| 規格層 | Iceberg format-version |
全表 | 拒絕開表 |
| 實作層 | Comet fallback | 逐功能、逐 operator | 退回 Spark 執行,結果仍正確 |
差別在有沒有保底實作可以退。Comet 敢降級,是因為旁邊站著語義完整的 Spark 加 Iceberg Java;Iceberg 規格層沒有這個保底,所以只能拒絕。
Comet 1.0 剛好是範例:支援 Iceberg 1.11,落地第一個 V3 功能「表層加密」,deletion vector 跟 VARIANT 則 fallback。
這裡有個容易做錯的歸因:這不是規格決定的,是實作成熟度決定的。 加密先落地,是因為 Parquet Modular Encryption 早就在 Comet 已經在用的 arrow-rs 裡;deletion vector 還沒接手,是因為 Puffin 加 Roaring bitmap 的解讀在 Rust 側還在做。規格對兩者一視同仁。
comet 攤開的是兩套已經在跑的實作:
FileScanTask 序列化給 iceberg-rust 原生執行。社群標準化在第二條,到 Comet 1.0,切分很明確:規劃留在 driver 的 Iceberg Java,讀取移到 executor 的 iceberg-rust。
Rust 側只要一個 FileScanTask 就能自足執行,完全不需要理解「Iceberg 是什麼」。這跟 D15 講的「Comet 替換邊界在 physical plan 之後」是同一條原則,只是這次邊界更精細,落在 scan planning 之後。
代價要講清楚:規劃開銷原封不動留在 JVM 側。省下來的是掃描本身的成本,不是規劃成本。
六項能力是引擎跟表格式的最小契約面。Iceberg 一路加功能,但相容性策略是寧可讀不到,也不要讀錯:format-version 是單一閘門,支援就開、不支援就拒絕。真正的優雅降級發生在實作層,而哪個功能能走 native,是實作成熟度說了算。
D21 進 Comet 內部第一站,回到 D15 六接點的第一個,plan 攔截:physical plan 什麼時候被換掉?AQE 執行期重規劃時,攔截規則怎麼保持一致?
那就明天見~
TableProvider trait:https://docs.rs/datafusion/latest/datafusion/catalog/trait.TableProvider.html