快到鐵人賽挑戰的一半了,我們從 D9 到 D12 走完儲存這一層,Parquet 內部剖了、了解了 Iceberg 四層漏斗、晚物化位圖跟 delete 交集也交代了。接下來剪枝後剩下要吐出去的那些列,資料就交給引擎這一層繼續跑,group by、join、sort、spill 都在這裡。
引擎這一層第一天要問一個聽起來很不友善的問題:
Spark、Presto、DuckDB、Photon 都在了,為什麼還要再做一個 DataFusion?
而且不是再做一個小玩具給論文用,而是 Apple、Meta、Uber、Databricks 都在拿它組裝自己的產品,Comet 更是把它直接塞回 Spark 當執行後端
今天不進 code,先把理解這個動機講,因為它決定了標題中「1+1+1>3」的其中一個 1 是什麼
日常講 MySQL 是資料庫,是把很多東西打包在一起,parser 把 SQL 變成 AST、optimizer 把 AST 變成執行計劃、executor 真的把資料撈出來算、storage 管檔案跟索引、transaction 管 ACID、client protocol 管網路,全部綁死在同一個 process。
查詢引擎是把中間那段挑出來:拿一個計劃,把資料算出來
不管前面 SQL 是誰 parse 的、後面資料存在哪、要用哪個網路協定回給誰。
一句話翻譯:
資料庫 = 引擎 + 儲存 + 交易 + 網路 + parser + 生態,全部一起賣。
引擎 = 只賣「把計劃算出來」這段。
DuckDB、SQLite 是資料庫(自己一整套)。DataFusion、Velox 是引擎(只賣中間那段),而 Spark 則是一半一半,前端跟優化器自己包,執行那段近年來一直想換掉(這也是 Comet 存在的原因)。
可嵌入(embeddable)這個詞比較模糊,拆成三個具體能力:
cargo add 進來就能在你的 process 裡跑 SQL,不用另外起一個 daemon、不用 JDBC、不用 network round trip,這個在前幾天介紹 datafusion-cli 中有提到過。TableProvider 這個 trait 定義了「一個表格式要滿足什麼條件才能被引擎讀」,Iceberg、Delta、Hudi、你家 CDN 上的一堆 JSON 都可以接進來三件事湊在一起,就是引擎跟前端跟儲存互相不綁死,任何一環都可以被替換。
這在 DuckDB、Photon 這種緊密整合的引擎上做不到,它們的優化器跟執行器綁死,想換掉執行器等於重寫半個 DuckDB。
DataFusion 的 SIGMOD 2024 論文 §4.1(論文連結)特別花一段講 LLVM,一開始看不太懂為什麼要類比編譯器,但這個類比其實非常準。
LLVM 出現之前: 每個編譯器都是前端後端綁死。GCC 想支援一個新語言,得從 parser 一路寫到 x86 code generator;想支援一個新 CPU 架構,得為每個既有語言各寫一次 backend。做 N 個語言 × M 個架構 = N × M 份工。
LLVM 出現之後: 中間插一個 IR(Intermediate Representation),前端只負責把源碼變成 IR,backend 只負責把 IR 變成機器碼。於是 Rust、Swift、Julia 全部共用同一個 x86 / ARM / RISC-V backend,N + M 就夠。
查詢引擎的類比是同構的:
DataFusion 之前(緊密整合時代):Spark 有自己的執行器、Presto 有自己的執行器、DuckDB 有自己的執行器,每個都要各寫一次向量化、各接一次 SSD、各支援一次 Iceberg。
DataFusion 之後:把 physical plan 當 IR,Spark 前端(Comet)、Ballista 分散式排程、你自己寫的 DataFrame API 都可以送 plan 進來,共用同一套向量化執行、同一套 memory pool、同一套 spill 機制。
這就是「1+1+1>3」的第一個支點:DataFusion 不是要贏 Spark,是要當 Spark 底下那塊可以被替換的執行引擎。Spark 的分析、優化、AQE 都留著,只把最後執行那段換成 DataFusion,這正是 Comet 在做的事。
有個直覺的反駁:緊密整合的引擎(Photon、DuckDB)在自己的場景明明打得很好,為什麼要模組化?
因為模組化不是為了要跑得更快,是為了同一份執行引擎能在多少地方被重用。
這是一個生態問題,不是效能問題。
想像三個實際案例:
cargo add datafusion 一句話搞定,可以把時間花在自己的差異化上(例如某個特定的分析場景、某個特定的資料源)緊密整合的引擎每次都要為新場景重寫一次,可嵌入引擎寫一次全部通吃。這在硬體變快、儲存格式標準化(Parquet、Iceberg)、Arrow 統一記憶體佈局之後,變成一個非常划算的取捨。
DataFusion 不是第一個想做可嵌入引擎的專案,Postgres FDW、Calcite 都試過類似的事,但為什麼是 2020 年之後才真的長出來?
三個條件同時到位:
Arrow 標準化了記憶體佈局。 D1 講過這件事的重要性,如果想跨語言、跨 process 交換欄式資料,得先有個共同的 in-memory layout。在 Arrow 之前每個系統都自己排 buffer,Arrow 之後大家都吃同一份,這是可嵌入引擎能存在的前提。
Parquet + Iceberg 標準化了儲存。 D9–D12 走完的整條漏斗,都是規格層而不是實作層的東西。當儲存標準化了,引擎才能不管資料是誰寫的,只要對面吐出來的是 Parquet、metadata 是 Iceberg,就有辦法讀。
Rust 讓底層系統可以又快又安全。 C++ 快但難寫難維護(Photon 就是這條路,只有 Databricks 這種規模養得起),Java / Scala 生態成熟但有 JVM 稅(Spark 就是這條路),Rust 卡在中間,async、borrow checker、cargo 生態成熟到可以撐住 DataFusion 這種底層系統。這也是為什麼 Velox(Meta)走 C++、DataFusion 走 Rust,兩條路線都存在。
三個條件單獨都不新,湊在一起才有一個可嵌入的欄式執行引擎這個 niche。
今天先把為什麼再做一個引擎講明白,答案不是因為以前的不夠快,而是以前的都是整套綁死的,把中間那段抽出來當可替換元件,整個生態的性價比會改變。
DataFusion 是那個可替換元件,Arrow / Parquet / Iceberg 是它可以接進來的其他標準元件,Comet 是它跟 Spark 的膠水。
明天 D14 插一天論文導讀,讀 Leis 那篇 SIGMOD 2014 的 Morsel-Driven Parallelism,它是 2014 年寫的,卻是 DataFusion 並行執行的理論骨架。
留一個沒有標準答案的問題:
LLVM 的 IR 可以讓 Rust、Swift、Julia 共用同一個 backend;DataFusion 的 physical plan 也想扮演同樣的角色,可是 Substrait 才是那個「明著要當跨語言 IR」的規格,DataFusion physical plan 只是 Rust 內部的 struct。
為什麼 Comet 選了 protobuf 自訂格式,而不是 Substrait 這個標準?IR 的抽象層級、表達式集合的封閉性、runtime state(記憶體、排程)能不能一起搬——你會怎麼想這個 trade-off?
那就明天見~