嵌入式 OLAP 這一塊空白
論文把系統版圖畫成 OLTP 對 OLAP、嵌入式對獨立式的二乘二方格,指出其中三格都有成熟系統,唯獨嵌入式分析那一格是個問號。填補這一格的需求來自兩個看似無關的來源:R 與 Python 上的互動式資料分析,以及邊緣運算——資料應該在節點上就地分析,而不是全部送回中央。作者主張這兩種情境出乎意料地導出相似的需求,因為兩者都把可攜性與低資源消耗視為關鍵。正是這種需求收斂,才讓「做一套系統而不是兩套」站得住腳。
一顆與應用程式同行程執行的 SQL 引擎,把向量化 OLAP 帶進 SQLite 空著的嵌入式位置。
SQLite 證明了開發者確實需要一顆跑在自己行程裡的資料庫,但它為 OLTP 而生,採用列導向(row-major)執行引擎搭配 B-Tree 儲存,在分析型工作負載上效能極差。DuckDB 補上這個空缺:一套從頭為嵌入情境打造的關聯式 DBMS,沒有伺服器行程、沒有 client protocol,透過 C/C++ API 存取,另外提供 SQLite 相容層與 R、Python 綁定。它的架構刻意不追求原創,而是把精簡版 Postgres parser、動態規劃 join 排序、向量化直譯執行引擎、HyPer 的可序列化 MVCC 與 DataBlocks 儲存層兜在一起,因為這些現成零件剛好都符合嵌入式分析的需求。作為一篇 demo 論文,它的貢獻是現場對決而非基準測試研究:四台相同的機器分別跑 SQLite、MonetDBLite、HyPer 與 DuckDB,資料是 TPC-H,觀眾轉動實體旋鈕加大讀取的資料量,看四台機器的每秒查詢數與記憶體壓力如何拉開差距。論文同時列出定義這一類系統的需求清單:兼顧 OLAP 速度而不放棄 OLTP、行程內的低成本資料表傳輸、絕不能拖垮宿主程式,以及不得引入難搞的相依套件或修改行程層級狀態。
到 2019 年,資料管理已經固化成以獨立行程執行的大型單體伺服器,只能透過 client protocol 存取,而同一批作者先前就證明過這層協定是搬運結果集時的嚴重瓶頸。唯一大規模普及的例外是嵌入式情境,由 SQLite 主宰,它是部署量最大的 SQL 引擎,活躍使用中的資料庫超過一兆個,但它是為交易型工作負載設計的,採列導向引擎與 B-Tree 儲存。因此在 R 與 Python 裡做互動式分析的人改用 dplyr 與 Pandas,這些套件的運算子形態與堆疊起來的關聯式代數很像,卻沒有全查詢層級的查詢最佳化,也沒有交易式儲存。作者自己的 MonetDBLite 由 MonetDB 衍生而來,已經證明需求是真的,每月數千次下載,使用者從荷蘭央行到紐西蘭警方都有。但它同時也暴露出一些問題,在一套並非為嵌入而生的系統裡極難解決,這正是他們決定砍掉重練、做出 DuckDB 的直接動機。
論文把系統版圖畫成 OLTP 對 OLAP、嵌入式對獨立式的二乘二方格,指出其中三格都有成熟系統,唯獨嵌入式分析那一格是個問號。填補這一格的需求來自兩個看似無關的來源:R 與 Python 上的互動式資料分析,以及邊緣運算——資料應該在節點上就地分析,而不是全部送回中央。作者主張這兩種情境出乎意料地導出相似的需求,因為兩者都把可攜性與低資源消耗視為關鍵。正是這種需求收斂,才讓「做一套系統而不是兩套」站得住腳。
MonetDBLite 證明了既有分析引擎可以縮成函式庫,也證明了確實有人要用,但它同樣暴露出一些在非為嵌入而設計的系統裡極難處理的問題。作者從這段經驗萃取出一份明確的需求清單:兼顧 OLAP 效率而不放棄 OLTP、行程內資料表的高效傳輸、高度穩定性(包含資源耗盡時能乾淨中止查詢),以及實務上的可嵌入性——編譯期與執行期都不能有麻煩的相依。DuckDB 是照著這份清單從零寫起的。比起任何單一演算法,這份需求清單才是本文真正流傳下來的貢獻。
論文直言:DuckDB 雖然是一類新系統中的第一個,但它沒有任何一個元件本身是革命性的;作者只是把最先進、且最適合自身使用情境的方法與演算法組合起來。Table 1 一頁就講完整個設計:C/C++ 與 SQLite 相容的 API、libpg_query 這個 Postgres parser、依循 Moerkotte 與 Neumann 的成本式最佳化器、依循 MonetDB/X100 的向量化引擎、依循 HyPer 的可序列化 MVCC,以及 DataBlocks 儲存。價值在於挑選與整合,因為每個元件都是照嵌入式限制挑的,不是照基準測試極速挑的。論文也強調 DuckDB 不是研究原型,每一次 commit 都會跑上百萬筆測試查詢。
DuckDB 用向量化的直譯式引擎執行查詢,而不是把 SQL 即時編譯(JIT)成機器碼,論文給的理由是可攜性而非效能。JIT 引擎得仰賴 LLVM 這類龐大的編譯器函式庫,還會牽連一串傳遞性相依,這直接違反了「宿主跑得起來的環境資料庫就要跑得起來」這條可嵌入性需求。向量化則把直譯器的分派成本攤到整個向量的值上,藉此把逐筆處理的額外負擔補回來,讓引擎不必背著整套工具鏈也有競爭力。這是全文中「嵌入限制凌駕純速度考量」最清楚的例子。
DuckDB 以 Multi-Version Concurrency Control 提供 ACID 保證,採用的是 HyPer 針對 OLAP/OLTP 混合系統量身打造的可序列化變體。這個變體會立即就地更新資料,並把先前的版本狀態放進另一個 undo buffer,供並行交易讀取與交易中止時回復使用。作者明確捨棄了 Optimistic Concurrency Control 這類較簡單的方案,因為即使 DuckDB 的主戰場是分析,過去仍不斷有人要求能平行修改資料表。這個選擇直接呼應了引言裡提出的儀表板需求。
持久化儲存採用為讀取最佳化的 DataBlocks 版面:邏輯資料表先水平切成 chunk,chunk 內的欄位再用輕量壓縮方法壓成實體 block。每個 block 為它的每一個欄位帶上 min/max 索引,掃描時可以很快判斷這個 block 對查詢是否可能有貢獻,沒有就整塊跳過。除此之外,block 還為每個欄位帶一個輕量索引,在沒被跳過的 block 內部進一步縮小真正需要掃描的值數量。於是在壓縮資料上同時具備粗粒度與細粒度的裁剪能力,這正是受限硬體上還能掃描大表的關鍵。
既然是嵌入式系統,DuckDB 沒有 client protocol 介面、也沒有伺服器行程,而是由宿主行程內部透過 C/C++ API 存取。為了接收既有使用者,它附上 SQLite 相容層,原本使用 SQLite 的應用程式只要重新連結或做 library overloading 就能改用 DuckDB,應用程式碼不必改。如同 MonetDBLite,它也實作了 R 的 DBI 與 Python 的 PEP 249 標準資料庫 API,讓引擎在資料科學工具鏈裡看起來就像一般的資料庫驅動程式。拿掉協定這道邊界,等於把結果傳輸從序列化問題變成記憶體共享問題。
SQL parser 衍生自 Postgres 的 parser,盡可能精簡後包成 libpg_query 函式庫。這讓 DuckDB 不必自己從零寫文法,就擁有一個功能完整且穩定的前端,去面對它最善變的一種輸入——任意 SQL 查詢字串。parser 吃進查詢字串、吐出一棵 C 結構組成的 parse tree,接著立刻轉換成 DuckDB 自己的 C++ 類別 parse tree,把 Postgres 資料結構的影響範圍限制在這一道邊界上。轉換後的樹由 statement(例如 SELECT、INSERT)與 expression(例如 SUM(a)+1)構成。
邏輯 planner 分成兩部分:binder 與 plan generator。binder 負責把所有指向 schema 物件(資料表、view)的運算式解析出來,補上欄位名稱與型別。plan generator 再把 parse tree 改寫成由 scan、filter、project 等基本邏輯運算子組成的樹,產出型別完全解析完成的邏輯計畫。DuckDB 對已儲存資料維護統計資訊,並在規劃過程中沿著各個運算式樹往上傳遞;這些統計不只供最佳化器使用,也用來在必要時升級型別以防止整數溢位。
最佳化器以動態規劃處理 join 順序,遇到過於複雜、動態規劃跑不完的 join graph 時退回貪婪法。它採用 Neumann 與 Kemper 的解巢技術攤平任意子查詢,並在運算式樹上套用一組改寫規則,例如共同子運算式消除(CSE)與常數摺疊。實體 planner 接著把最佳化後的邏輯計畫映射到實體運算子,在有選擇的地方做決定:scan 可依選擇率估計改用既有索引而不掃描基底資料表,join 則依 join 述詞決定要走 hash join 還是 merge join。
DuckDB 在向量上運算,每個向量有固定的值數上限,預設 1024。整數這類定長型別直接放在原生陣列裡;字串這類變長值則是一個原生的指標陣列,指向獨立的 string heap。NULL 值以另一個 bit vector 記錄,而且只有在向量中真的出現 NULL 時才存在,這讓二元向量運算可以快速對兩邊的 NULL vector 取交集,也省下全非 NULL 這種常見情況的多餘計算。為了避免在過濾之類的操作後大量搬移向量內的資料,向量可以再帶一個 selection vector:一串位移量,標明向量中哪些索引仍然有效。支撐關聯式運算子的是一套龐大的向量運算函式庫,透過 C++ template 為所有支援的資料型別展開程式碼。
執行採用論文所稱的 Vector Volcano 模型:仍是 pull-based 的 Volcano 迭代器管線,只是運算子之間交換的單位是 chunk 而非單筆 tuple。chunk 指的是結果集、查詢中間結果或基底資料表的一段水平子集。查詢執行從實體計畫的根節點拉取第一個 chunk 開始,該節點再遞迴向子節點拉取,最終抵達 scan 運算子,由它讀取持久化資料表產生 chunk。當送達根節點的 chunk 為空時查詢即告完成,因此不需要另外的結束訊號。
交易在 HyPer 的可序列化 MVCC 變體下執行,該變體本來就是為 OLAP/OLTP 混合系統設計的。寫入者會立即就地更新資料,使得掃描最新版本時資料仍然緊密、對快取友善——這點很重要,因為分析型掃描才是主要負載。舊版本狀態不會被丟掉,而是寫進獨立的 undo buffer,並行交易從中讀取以重建自己的快照,中止的交易也從中讀取以回復。之所以選它而非 Optimistic Concurrency Control,正是因為平行修改資料表一直是使用者反覆要求的功能。
持久化資料表以為讀取最佳化的 DataBlocks 版面儲存。邏輯資料表先水平切成 chunk,chunk 內的各欄位再以輕量壓縮方法壓成實體 block。每個 block 為每個欄位帶上 min/max 索引,掃描時可以很快判斷這個 block 有沒有可能包含符合條件的列,沒有就整塊略過。每個 block 另外帶一個輕量的逐欄索引,在沒被略過的 block 內部進一步限制必須掃描的值數量,於是裁剪在兩種粒度上都成立,也不必把資料整批解壓縮。
DuckDB 後來真的成為這篇四頁 demo 所預告的東西:本機、行程內資料工作的預設分析引擎,也就是 Figure 1 上那個問號所指的「SQLite 的 OLAP 對應物」。它的 Python 與 R 綁定,加上與 Arrow、Pandas data frame 的零拷貝交換,讓 SQL 成為 notebook 與 dataframe 管線中的一等公民,dbt、Jupyter 工作流與桌面 BI 工具也紛紛把它當本機引擎使用。第 1 節那份需求清單——不得有外部相依、不得產生行程層級副作用、絕不能拖垮宿主——實質上成了嵌入式分析這個類別的規格書,後來 ClickHouse 的 clickhouse-local 與 chDB 也加入這個行列。DuckDB-WASM 把同一顆引擎帶進瀏覽器,MotherDuck 在其上打造本機與雲端混合的服務,httpfs 以及 Iceberg、Delta 讀取器等擴充套件,則讓它從只能查本機檔案變成能直接查詢物件儲存的引擎。在架構層面,它讓「可攜引擎選向量化直譯而非 JIT 編譯」的主張變得理所當然,同一波單機分析浪潮中的 Apache DataFusion 與 Polars 也呼應了這個取向。它同時重新證明:一台工程做得好的機器就足以應付大多數真實分析工作負載,動搖了「做分析就得上分散式叢集」的預設。
“The immense popularity of SQLite shows that there is a need for unobtrusive in-process data management solutions. However, there is no such system yet geared towards analytical workloads.”
“While DuckDB is first in a new class of data management systems, none of DuckDB's components is revolutionary in its own regard. Instead, we combined methods and algorithms from the state of the art that were best suited for our use cases.”
“High degree of stability, if the embedded database crashes, for example due to an out-of-memory situation, it takes the host down with it. This can never happen.”