跳至主要內容
論文精煉 · Query processing

DuckDB: an Embeddable Analytical Database(DuckDB:可嵌入的分析型資料庫)

一顆與應用程式同行程執行的 SQL 引擎,把向量化 OLAP 帶進 SQLite 空著的嵌入式位置。

作者Mark Raasveldt、Hannes Mühleisen(CWI,阿姆斯特丹) 發表於SIGMOD 2019(demonstration 論文),荷蘭阿姆斯特丹 年份2019–2020 年代
閱讀原始論文 PDF 所有論文

一口氣講完 — 整篇論文的濃縮

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 的直接動機。

問題 — 當時真正壞掉的地方

  • SQLite 專注於交易型工作負載,採列導向執行引擎搭配 B-Tree 儲存,結果就是分析型工作負載的效能極差,使得系統版圖中「嵌入式 OLAP」這一格始終空白。
  • 獨立的資料庫伺服器要正確架設得花上不少功夫,而且資料存取被 client protocol 綁住;相對地嵌入式系統與宿主共用同一個位址空間,必須把這個獨特機會用在資料表的高效進出上。
  • 分析師實際在用的資料操作套件,R 的 dplyr 與 Python 的 Pandas,形態很接近堆疊起來的關聯式運算子,卻缺少全查詢層級的查詢最佳化與交易式儲存。
  • 邊緣運算場景例如連網電表,目前都把資料轉送到中央位置才做分析,這在無線介面的頻寬限制下相當棘手,也帶來隱私疑慮。
  • 嵌入式分析引擎必須對 OLAP 高效,但不能完全犧牲 OLTP,因為儀表板場景常常是某些執行緒在更新資料,同時另一些執行緒在跑驅動視覺化的分析查詢。
  • 嵌入式資料庫一旦崩潰,例如遇到記憶體不足,就會把宿主行程一起拖垮,所以查詢必須能在資源壓力下乾淨中止,函式庫也不得依賴外部套件、不得處理 signal、不得呼叫 exit(),更不得修改 locale 或工作目錄這類行程層級的單一狀態。

核心概念 — 主要貢獻,以及它們為何成立

嵌入式 OLAP 這一塊空白

論文把系統版圖畫成 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 都會跑上百萬筆測試查詢。

選擇向量化直譯而非 JIT

DuckDB 用向量化的直譯式引擎執行查詢,而不是把 SQL 即時編譯(JIT)成機器碼,論文給的理由是可攜性而非效能。JIT 引擎得仰賴 LLVM 這類龐大的編譯器函式庫,還會牽連一串傳遞性相依,這直接違反了「宿主跑得起來的環境資料庫就要跑得起來」這條可嵌入性需求。向量化則把直譯器的分派成本攤到整個向量的值上,藉此把逐筆處理的額外負擔補回來,讓引擎不必背著整套工具鏈也有競爭力。這是全文中「嵌入限制凌駕純速度考量」最清楚的例子。

為混合工作負載選的可序列化 MVCC

DuckDB 以 Multi-Version Concurrency Control 提供 ACID 保證,採用的是 HyPer 針對 OLAP/OLTP 混合系統量身打造的可序列化變體。這個變體會立即就地更新資料,並把先前的版本狀態放進另一個 undo buffer,供並行交易讀取與交易中止時回復使用。作者明確捨棄了 Optimistic Concurrency Control 這類較簡單的方案,因為即使 DuckDB 的主戰場是分析,過去仍不斷有人要求能平行修改資料表。這個選擇直接呼應了引言裡提出的儀表板需求。

為讀取最佳化的 DataBlocks 儲存

持久化儲存採用為讀取最佳化的 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,讓引擎在資料科學工具鏈裡看起來就像一般的資料庫驅動程式。拿掉協定這道邊界,等於把結果傳輸從序列化問題變成記憶體共享問題。

運作方式 — 具體的機制

以精簡版 Postgres parser 解析 SQL

SQL parser 衍生自 Postgres 的 parser,盡可能精簡後包成 libpg_query 函式庫。這讓 DuckDB 不必自己從零寫文法,就擁有一個功能完整且穩定的前端,去面對它最善變的一種輸入——任意 SQL 查詢字串。parser 吃進查詢字串、吐出一棵 C 結構組成的 parse tree,接著立刻轉換成 DuckDB 自己的 C++ 類別 parse tree,把 Postgres 資料結構的影響範圍限制在這一道邊界上。轉換後的樹由 statement(例如 SELECT、INSERT)與 expression(例如 SUM(a)+1)構成。

binder 與邏輯計畫產生

邏輯 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 執行模型

執行採用論文所稱的 Vector Volcano 模型:仍是 pull-based 的 Volcano 迭代器管線,只是運算子之間交換的單位是 chunk 而非單筆 tuple。chunk 指的是結果集、查詢中間結果或基底資料表的一段水平子集。查詢執行從實體計畫的根節點拉取第一個 chunk 開始,該節點再遞迴向子節點拉取,最終抵達 scan 運算子,由它讀取持久化資料表產生 chunk。當送達根節點的 chunk 為空時查詢即告完成,因此不需要另外的結束訊號。

就地更新加 undo buffer 的 MVCC

交易在 HyPer 的可序列化 MVCC 變體下執行,該變體本來就是為 OLAP/OLTP 混合系統設計的。寫入者會立即就地更新資料,使得掃描最新版本時資料仍然緊密、對快取友善——這點很重要,因為分析型掃描才是主要負載。舊版本狀態不會被丟掉,而是寫進獨立的 undo buffer,並行交易從中讀取以重建自己的快照,中止的交易也從中讀取以回復。之所以選它而非 Optimistic Concurrency Control,正是因為平行修改資料表一直是使用者反覆要求的功能。

DataBlocks 持久化與區塊跳過

持久化資料表以為讀取最佳化的 DataBlocks 版面儲存。邏輯資料表先水平切成 chunk,chunk 內的各欄位再以輕量壓縮方法壓成實體 block。每個 block 為每個欄位帶上 min/max 索引,掃描時可以很快判斷這個 block 有沒有可能包含符合條件的列,沒有就整塊略過。每個 block 另外帶一個輕量的逐欄索引,在沒被略過的 block 內部進一步限制必須掃描的值數量,於是裁剪在兩種粒度上都成立,也不必把資料整批解壓縮。

論文證明了什麼 — 量測數據與證明

  • 撰稿當下,DuckDB 已能跑完所有 TPC-H 查詢,TPC-DS 則只差兩題未過;作者預期到 demo 展示時可完整覆蓋 TPC-DS。
  • DuckDB 已能通過 SQLite SQL logic test suite 的大部分測試,該測試集包含數百萬筆查詢;此外每一次 commit 都會跑上百萬筆測試查詢,以確保運作正確且 SQL 介面完整。
  • 執行引擎使用固定上限的向量,預設 1024 個值;定長型別放原生陣列,字串以指標指向獨立的 string heap,NULL 的 bit vector 只有在真的出現 NULL 時才會存在。
  • demo 場地擺四台完全相同的測試機,分別跑 SQLite、MonetDBLite、HyPer 與 DuckDB,各自預先載入 TPC-H 資料表,並以乙太網路連到第五台管理機;螢幕至少顯示每秒查詢完成數(QpS)與記憶體壓力,桌上的實體旋鈕則控制當前查詢要讀取多少輸入資料。
  • 論文並未提供任何實測數字,只做定性預測:資料量很小時四套系統表現相當;資料變大後 SQLite 會被列導向執行模型拖累,MonetDBLite 會因 bulk processing 模型產生過量中間結果物化而吃虧;HyPer 雖然查詢處理極快,但因為走 socket client protocol,傳輸結果集的速度追不上 DuckDB。
  • 需求面的論證靠的是已公布的部署數字而非實驗:SQLite 被引為部署量最大的 SQL 資料庫引擎,活躍使用中的資料庫超過一兆個;MonetDBLite 則每月有數千次下載,使用者從荷蘭央行到紐西蘭警方都有。

限制與取捨 — 論文自承的,以及後來被發現的

  • 論文自承:DataBlocks 儲存方案與基數估計(cardinality estimation)都尚未完成,buffer manager 也還沒實作(但已規劃),因此撰稿當下的儲存與記憶體管理仍是半成品。
  • 論文自承:DuckDB 目前只支援查詢間(inter-query)平行,查詢內(intra-query)平行還沒做,另外還規劃 work stealing 排程器,用來在長短查詢之間、以及與宿主應用程式之間平衡資源。
  • 論文自承:還有兩題 TPC-DS 查詢跑不起來,所謂完整覆蓋只是對 demo 當天的預期,而非既成事實。
  • 論文未明說、但文本可見:全文完全沒有量化評估,與 SQLite、MonetDBLite、HyPer 的對決是對現場展示結果的預測,不是受控實驗;而捨 JIT 取直譯的理由建立在可攜性上,論文自己也承認基於 JIT 的 HyPer 在查詢處理上極快。
  • 後續發展揭露:這裡列出的 roadmap 幾乎每一項後來都變成真正的重新設計——DuckDB 之後以自研的欄式儲存格式取代了源自 DataBlocks 的方案,補上 buffer manager,並改採 morsel-driven 的查詢內平行,因此本文描述的儲存與平行機制應視為早期快照,而非讓 DuckDB 成功的最終架構。至於論文提出的自我檢查構想(對持久化與中間資料維護 checksum,並把驗證搭載在 scan 運算子上)在本文中也僅止於提案。

它後來變成什麼 — 繼承這個想法的系統

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.”

§2 設計與實作

“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.”

§1 引言

術語 — 依本篇論文的用法

嵌入式資料庫(embedded database)
以連結函式庫的形式完全跑在宿主行程內的資料庫系統,沒有伺服器行程、也沒有 client protocol。SQLite 是最典型的代表,而 DuckDB 被定位為它在分析領域的對應物。
向量化直譯執行(vectorized interpreted execution)
承襲自 MonetDB/X100 的執行方式:運算子仍是直譯執行,但每次呼叫處理一整個向量的值,而非單筆 tuple。之所以捨棄 JIT 編譯,是因為 JIT 需要 LLVM 這類龐大編譯器函式庫,會破壞可嵌入性。
Vector Volcano 模型
DuckDB 的執行模型:仍是典型 pull-based 的 Volcano 迭代器管線,只是每次 next 傳遞的是一個由向量組成的 chunk,而非單筆 tuple。執行從計畫根節點開始,當送達根節點的 chunk 為空時結束。
chunk
運算子之間傳遞的資料單位,論文定義為結果集、查詢中間結果或基底資料表的一段水平子集。chunk 由 scan 運算子讀取持久化資料表產生,並沿著計畫樹往上流動。
selection vector(選擇向量)
一串指向向量內部的位移量,用來標明目前哪些索引位置仍然有效。有了它,filter 之類的運算子就能標記倖存的列,而不必在向量內實際搬移資料。
string heap
存放變長值的獨立記憶體區域,向量以原生指標陣列指向其中的內容。這讓向量本身維持定寬,向量化運算才能在各種型別上維持一致的處理方式。
可序列化 MVCC(HyPer 變體)
DuckDB 採用的並行控制機制,專為 OLAP/OLTP 混合系統設計:資料立即就地更新,先前的版本狀態存進獨立的 undo buffer,供並行交易與交易中止使用。之所以優先於 Optimistic Concurrency Control,是因為平行修改資料表一直是使用者常提的需求。
DataBlocks
DuckDB 採用的讀取最佳化持久化儲存版面:資料表水平切成 chunk,chunk 內的欄位以輕量壓縮壓成實體 block。每個 block 為每個欄位帶 min/max 索引以便整塊跳過,另帶一個輕量的逐欄索引,用來限制實際需要掃描的值數量。
libpg_query
把 Postgres 的 SQL parser 抽出成獨立 C 函式庫並加以精簡,作為 DuckDB 的前端。它輸出的 C 結構 parse tree 會立刻轉成 DuckDB 自己的 C++ parse tree,讓 Postgres 的資料結構不會滲進系統其餘部分。

在時間軸上的位置 — 這篇論文在整段故事中的座標

在時間軸上查看