跳至主要內容
論文精煉 · Data warehouse / OLAP

The Vertica Analytic Database: C-Store 7 Years Later

C-Store 商品化七年後的工程覆盤:哪些欄式儲存的研究構想撐過了真實客戶,哪些被丟掉。

作者Andrew Lamb、Matt Fuller、Ramakrishna Varadarajan、Nga Tran 等(Vertica Systems,HP 旗下公司,Cambridge, MA) 發表於PVLDB 5(12),VLDB 2012 年份2005
閱讀原始論文 PDF 所有論文

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

Vertica 就是被真正做出來的 C-Store:核心賭注完全一樣——projection 是唯一的實體結構、資料全序排列、每個欄位各自積極編碼、記憶體中的寫入儲存區再排入不可修改的讀取最佳化檔案、以 epoch 取得快照、以 K-safety 做複本——但整套從零重寫,一行原型的程式碼都沒有沿用。這篇論文難得的地方,在於它明講哪些研究構想沒有活下來:join index 被整個拿掉,改成強制每張表都要有 super projection;讀取儲存區裡的 B-tree 換成輕量的 position index,因為 ROS container 寫完就不再修改;C-Store 以時間窗切分的 epoch 改成提交時就推進;C-Store 隨機決定的 join 順序,換成三個世代的真正查詢最佳化器。活下來的部分則用量測背書:在同一台單核 Pentium 4 上、用 C-Store 論文自己的查詢與測試框架,Vertica 跑完七道查詢只要 9.6 秒(C-Store 是 18.7 秒),磁碟用量 949 MB(C-Store 是 1,987 MB),而這還是在補上多種資料型別、NULL、更新、ACID 交易與查詢最佳化器之後的成績。論文寫作當時,Vertica 已有超過 500 個正式環境部署,其中至少三個明顯超過 1 PB。

在這篇論文之前 — 它所降落的世界

2005 年前後,一體適用的列式(row store)引擎在分析場景早已被拉扯到極限:表格動輒數十億列,而一個為每秒數千筆單列交易調校出來的引擎,把大半 I/O 花在讀取查詢根本沒要的欄位上。C-Store 主張專為分析打造的欄式引擎,可以把這些系統甩開好幾個數量級;Stonebraker 的 One Size Fits All 也在同一時期把這個論點寫成檄文。但 C-Store 終究只是研究原型:只支援 INTEGER、沒有 NULL、不能更新、單執行緒,最佳化器的 join 順序完全隨機,把 segment 配置到各節點的 storage allocator 也只寫在論文裡、從未實作。真正的疑問——也是 Vertica 投資人最擔心的——是原型的效能數字,能不能在補齊「一個真正產品所需的一切」之後仍然成立。與此同時,NoSQL 浪潮正在主張:問題出在 SQL 本身。

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

  • 既有的關聯式系統是為四十年前硬體上的交易型工作負載而設計,無法服務分析型工作負載——後者每秒只有幾十筆交易,但每一筆都要掃過一張表相當大的比例。
  • 要做到線性擴充就不能用 shared disk 或網路儲存,那幾乎立刻會變成瓶頸;資料擺放、查詢最佳化與執行引擎也都必須避免把叢集內部網路吃滿。
  • 分析系統必須以極高速率吞進資料:如果載入要花好幾天,再快的查詢引擎也沒用;而且大量載入不能中斷、也不能過度拖慢同時進行的查詢。
  • 所有操作都必須是線上的:正式環境的叢集不可能為了儲存維護、復原、重新平衡或備份而暫停查詢。
  • C-Store 留下不少沒做完或未經驗證的部分——把 segment 配置到節點的 storage allocator、一個真正的查詢最佳化器,以及 join index 在分散式引擎中的真實代價從未被量測過。
  • 客戶不會為了「需要高人一等的調校」或「效能忽快忽慢」買單,因此實體設計、儲存配置與編碼選擇都必須自動化。

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

projection 是唯一的實體結構

Vertica 的表沒有 heap,也沒有次要索引:資料只以 projection 的形式存在,每個 projection 是該表部分欄位的集合,並依自己選定的排序鍵完全排好序。projection 像物化檢視,但刻意不包含聚合、過濾與一般 join,因為論文指出:在真實的分散式系統裡,維護那種檢視的成本與實作複雜度並不實際。全序排列是其他一切成立的前提:RLE 之類的編碼效果遠勝於在無序資料上,最佳化器也可以放心倚賴順序去做 merge join、串流聚合與省略排序。實務上多數客戶只有一個 super projection 外加零到三個窄 projection——引擎夠快,不必為每個述詞各建一個 projection。

拿掉 join index,改為強制 super projection

C-Store 用 join index 把幾個互不重疊的 partial projection 重組回完整的 tuple。Vertica 完全沒有實作 join index,而是要求每張表至少要有一個包含全部欄位的 super projection。論文給的理由是營運面而非理論面的:join index 實作複雜、在分散式執行期間跨節點重組 tuple 的成本非常高,而且明確存下 row id 對大表來說會吃掉可觀的磁碟。既然欄式壓縮讓一份完整的 super projection 便宜得多,這筆帳就倒向冗餘那一邊;作者並直言沒有打算放寬這個要求。

segmentation 管叢集,partitioning 管裁切

Vertica 把 C-Store 所謂的 horizontal partitioning 拆成兩件事。segmentation 是節點之間的:每個 projection 用 SEGMENTED BY 宣告一個整數運算式(通常是高基數欄位的 HASH),節點以 ring 的方式各自擁有 2^64 值域中的一段連續範圍,於是「值到節點」的對應是決定性的,這正是完全在本機完成的分散式 join 與高基數 distinct 聚合得以成立的原因。partitioning 是節點之內的:以表為單位的 PARTITION BY 保證同一個 ROS container 裡的 tuple 具有相同的分割值,於是刪掉某個月的資料就等於刪檔案,min/max 裁切也更銳利。C-Store 為了平行度而做的節點內 partitioning 則被整個拿掉,因為執行引擎改成在執行期把每個磁碟結構切成邏輯區段來取得節點內平行度。

在排序資料上編碼,並直接對編碼資料運算

每個 projection 的每個欄位都有自己的編碼方式——Auto、RLE、Delta Value、Block Dictionary、Compressed Delta Range、Compressed Common Delta——同一個欄位在不同 projection 裡也可以用不同編碼。這組編碼與 C-Store 的並不相同;論文主張同樣的手法在 Vertica 效果好得多,正是因為儲存是全序的:在已排序的低基數欄位上做 RLE,可以把數百萬列壓成寥寥幾對數值。更關鍵的是,scan、join 與低階聚合都直接在編碼後的資料上運算,而不是先解碼——作者承認這付出了實實在在的實作複雜度,但效能就是從這裡來的。編碼不是靠經驗法則,而是由 Database Designer 用實測挑出來的。

WOS、ROS 與有階層上界的 tuple mover

零星寫入先落在純記憶體、未壓縮的 Write Optimised Store;讀取側則是一般檔案系統上一組不可修改的 ROS container。tuple mover 負責 moveout(WOS 到 ROS)與 mergeout(把小 container 併成大的),而且必須拿捏分寸:太積極會造出一堆小 container,太懶則會讓 WOS 溢位、同樣造出一堆小檔案。與 C-Store 不同的是,它從不在合併時混用 WOS 與 ROS 的資料,於是參與一次 mergeout 的 tuple 就是「從磁碟讀一次、寫一次」。container 依檔案大小被量化到指數成長的階層(strata),上限 2 TB,因此任何一筆 tuple 被重寫的次數都有上界——這與 LSM 壓縮合併的論證如出一轍。

用 epoch 取代日誌

每筆 tuple 都以隱含的 64 位元欄位標上提交時的 epoch,每個刪除標記也標上刪除時的 epoch;全叢集對「某筆交易在哪個 epoch 提交」有共識,因此 epoch 邊界就是一個全域一致的快照。由於儲存永不原地修改,讀取近期歷史的查詢完全不必上鎖,這正是大量載入與長時間分析掃描能同時跑的原因。結果是結構性的:Vertica 不需要傳統交易日誌,因為「資料加上 epoch」本身就是歷史;節點歸隊時,只要從 buddy projection 把錯過的 DML 重放一次即可。C-Store 以時間窗切分 epoch 的做法被放棄,因為在 READ COMMITTED 下的使用者常被搞糊塗——自己提交的資料要等 epoch 前進才看得到;現在 DML 一提交就推進 epoch,順帶也讓 tuple mover 等內部流程變單純。

會呼叫最佳化器的自動實體設計

Database Designer 以兩個依序進行的階段替工作負載挑 projection。查詢最佳化階段先依述詞、group by、order by、聚合欄位與 join 述詞等啟發式規則列出候選 projection,接著對每一道輸入查詢真的去呼叫查詢最佳化器,用產出的執行計畫挑出最好的候選——如此一來,工具不會隨著最佳化器演進而與其成本模型脫節。儲存最佳化階段再依已選定的排序順序,對樣本資料做一系列實測壓縮實驗,挑出最佳編碼。論文回報的結果本身就是這個設計的辯護詞:專家使用者偶爾會手動微調最大幾張表的 segmentation 或排序清單,但幾乎沒有人會去推翻 DBD 挑的欄位編碼——因為那是量出來的,不是猜出來的。

運作方式 — 具體的機制

ROS container 的版面與 position index

一個 ROS container 依 projection 的排序順序存放完整的 tuple,每個欄位寫成兩個檔案:編碼後的欄位資料,以及大小約為其千分之一的 position index。列在 container 內的身分就是它的 position,也就是在檔案中的序位;position 是隱含的、從不明確存下來,重組 tuple 就是從每個欄位檔案取出相同 position 的值。position index 存放每個磁碟區塊的中繼資料,包括起始 position、最小值與最大值,其中 min/max 讓規劃階段可以直接裁掉不可能滿足述詞的整個 container。與 C-Store 不同,這裡沒有 B-tree,因為 ROS container 一旦寫出就不再修改;另有一種把多個欄位放進同一檔案的混合模式,但因為效能與壓縮都要付代價,實務上極少使用。

moveout、mergeout 與指數階層

WOS 填滿時,moveout 會非同步把內容寫成新的 ROS container;若 moveout 還沒做完 WOS 就先飽和,後續載入的資料會繞過 WOS 直接寫入新的 container。mergeout 依檔案大小把既有 container 量化到指數成長的階層,並規劃每次合併,使輸出至少落在比所有輸入都高一階的位置,這就是「重複合併次數有上界」的來源。mergeout 同時也是回收空間的地方:凡是在 Ancient History Mark 之前被刪除的列,都會直接從輸出中略去,因為任何查詢都不可能再看到它們。合併會保留分割與 local segment 的邊界,而且刻意不做全叢集協調,因此兩個存著相同 tuple 的節點,container 版面常常長得完全不一樣。

刪除靠 delete vector,更新等於刪除加插入

資料永遠不原地修改。一次刪除會產生一個 delete vector——被刪除列的 position 清單——先寫入記憶體中的 DVWOS,之後由 tuple mover 搬進磁碟上壓縮過的 DVROS container,格式與使用者資料相同。WOS 可能有多個 delete vector,任一個 ROS container 也可能有多個,而每次掃描都必須套用它們。SQL UPDATE 的實作是刪掉舊列再插入含新值的一列;這也解釋了為什麼「標記刪除」比「刪掉某個分割的檔案」慢得多,以及為何刪除會暫時增加儲存量並拖慢查詢,直到下一次 mergeout 為止。

不用 two-phase commit 的提交

叢集以一套分散式共識與成員管理協定傳遞控制訊息,兼用廣播與點對點投遞;沒收到訊息的節點會被踢出叢集,其餘節點則會被告知這個損失。與 C-Store 一樣,Vertica 不使用 two-phase commit:提交訊息一旦送出,每個節點要嘛完成提交、要嘛離開叢集,只要達到節點的 quorum,這筆交易就算提交成功。回滾就只是丟掉該交易建立的 ROS container 與 WOS 資料;提交過程中掉隊的節點之後靠復原機制歸隊。查詢預設為 READ COMMITTED,讀的是「目前 epoch 減一」;寫入端則由七種模式(S、I、SI、X、T、U、O)的表鎖矩陣管理——其中 Insert 鎖與自己相容,因此多個大量載入可以同時進行,又仍保有交易語意。

K-safety、buddy projection 與兩階段復原

容錯直接沿用 projection 機制:每個 projection 至少要有一個 buddy,欄位相同,且 segmentation 保證同一列不會同時落在同一個節點上;K-safety 就是把每個 segment 的 K+1 份副本放在不同節點。節點歸隊時,先把自己 Last Good Epoch 之後插入的資料全部截斷(LGE 是該 projection 的資料真正落到 ROS 為止的 epoch,之所以逐 projection 追蹤,是因為只存在 WOS 的資料會在節點故障時消失),接著分兩階段復原:歷史階段不持有任何鎖,把 LGE 到某個較早 epoch Eh 之間的資料複製過來;當前階段則取得 Shared 鎖,把剩下的少量資料補齊。若 projection 與 buddy 的排序順序一致,復原就是整個 ROS container 與 delete vector 原封不動複製;否則會用類似 INSERT ... SELECT 的執行計畫搬列(含已刪除的列),並以另一個計畫搬 delete vector。refresh、rebalance 與以硬連結為基礎的備份都沿用同樣的歷史/當前兩階段結構,而且全部都是線上進行。

執行引擎:向量化、管線化、會臨機應變

執行計畫是運算子樹——Scan、GroupBy、Join、ExprEval、Sort、Analytic、Send/Recv——由下游以 pull 的方式驅動,向量化到運算子之間交換的是整批列,並且是多執行緒的:StorageUnion 會派出多條執行緒處理 ROS container 中互不重疊的區段,並在本機重新分段,好讓平行的 GroupBy 能算出完整的分組。Sideways Information Passing 在規劃期建出過濾器並放進 Scan,執行期 Scan 直接查 join 的雜湊表,把不可能配對成功的外側列在進入管線之前就丟掉。引擎也會在執行中換演算法:雜湊表塞不進記憶體的 hash join 會改成 sort-merge join;用 L1 快取大小雜湊表做的廉價 prepass 聚合,一旦發現無法有效減少列數就會停掉。記憶體在編譯期依工作負載政策逐運算子編列預算,計畫被切成不能同時執行的數個 zone,好讓下游運算子回收上游的資源;每個運算子都能把緩衝區外溢到磁碟,而型別相依的運算式求值則以 JIT 編譯掉分支開銷。

三個世代的查詢最佳化器

C-Store 的最佳化器是先碰到哪個 projection 就用哪個,join 順序完全隨機。Vertica 的第一代 StarOpt 是 Kimball 式的:假設 schema 是 star 或 snowflake,先把事實表與最具選擇性的維度表 join,並且要求 projection 必須共置,也就是參與 join 的 projection 要嘛在所有節點都有複本,要嘛在 join 鍵上以相同範圍分段。第二代 StarifiedOpt 則把非 star 查詢改寫成看起來像 star,好讓 StarOpt 演算法照樣能跑——作者說這個權宜之計的效果好得超出他們合理的期待。第三代 V2Opt 具備分佈感知能力,允許執行期在節點之間搬資料,並以可擴充模組的方式從頭設計:它先分類查詢的實體屬性(欄位選擇性、projection 排序順序、資料分段、是否有 prejoin projection、是否有完整性約束),再用一個以壓縮感知 I/O、CPU 與網路傳輸成本為基礎的成本模型做剪枝,於是能在控制搜尋空間爆炸的同時,於 join 順序列舉階段納入資料分佈與 bushy plan。當有節點故障時,最佳化器會把不可用節點上的 projection 換成 buddy projection 重新估價、重新規劃,結果可能是一個完全不同的 join 順序。

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

  • 在同一台單節點 Pentium 4 硬體上,用 C-Store 論文自己的查詢與測試框架,Vertica 的查詢總時間是 9.6 秒、C-Store 是 18.7 秒;磁碟用量 949 MB 對 1,987 MB(Table 3)——單核上大約快一倍。
  • 各查詢的改善幅度非常不平均:Q7 從 2,540 毫秒降到 161 毫秒、Q4 從 2,090 毫秒降到 280 毫秒,Q3 卻幾乎沒動(4,900 毫秒對 4,833 毫秒);而這一切還是在 Vertica 額外背上 FLOAT 與 VARCHAR 型別、NULL 處理、更新與刪除、多套 ROS 與 WOS、ACID 交易、查詢最佳化與 64 位元整數等 C-Store 從來不必付的成本之後達成的。
  • 一百萬個介於 1 到 1000 萬之間的隨機整數,以文字形式佔 7.5 MB;gzip 得到 3.6 MB(2.1 倍),先排序再 gzip 得到 2.3 MB(3.3 倍),而 Vertica 只用 0.6 MB——12.5 倍,平均每列 0.6 位元組(Table 4)。
  • 在 2 億筆真實客戶電表讀數上(metric、meter、採集時間戳、浮點值),6,200 MB 的 CSV 用 gzip 壓到 1,050 MB(5.9 倍),在 Vertica 中卻只佔 418 MB(14.8 倍,每列約 2.2 位元組);依 metric、meter、時間排序後,RLE 把 metric 欄位縮到 5 KB、meter 欄位縮到 35 MB,時間戳縮到 20 MB,剩下 363 MB 全在浮點數值上。
  • 有幾項保證是結構性的而非量測出來的:position index 約為原始欄位資料的千分之一,ROS container 上限 2 TB,因此指數階層替「一筆 tuple 被重寫幾次」設下上界;而參與一次 mergeout 的 tuple 就是從磁碟讀一次、寫一次。
  • 叢集規模的證據取代了叢集基準測試:論文寫作當時已有超過 500 個正式環境部署,其中至少三個明顯超過 1 PB;作者也明講不拿現代多核叢集去跟 C-Store 比,因為原型是單執行緒程式,這種比較並不公平。

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

  • 論文自承:強制 super projection 意味著每個欄位在每張表至少要完整存一份,而且每個 buddy projection 還要再存一份,於是 K-safety 會把基礎儲存量成倍放大——論文接受這個代價,理由是壓縮讓它很便宜,並明言沒有打算放寬這項要求。
  • 論文自承:有兩項已出貨的功能實務上幾乎是死的。prejoin projection 的使用遠低於預期,因為引擎的 hash 與 merge join 處理小維度表已經很好,而客戶也不願意為了預先算好 join 而拖慢載入;把多欄位塞進同一檔案的混合列欄模式,則因為效能與壓縮都要罰分而極少被使用。
  • 論文自承:特例式的最佳化(只對 inner join 而不對 outer join 建傳遞述詞、只對 hash join 而不對 merge join 做特殊過濾)帶來的麻煩幾乎和它解決的一樣多,因為使用者不接受難以預測的效能;資源管理也被承認在早期被低估,並被稱為學術研究中仍被忽視的課題。
  • 論文自承:可用性有硬邊界——掉了 K+1 個節點就可能導致資料庫關閉,掉了 N/2 個節點則會為了避免 split brain 而強制安全關機;此外 AHM 在有節點故障時通常不會前進,因此整段故障期間,本可清掉的歷史會不斷累積。
  • 後見之明:全篇唯一的正面比較是單核 Pentium 4 上跑 2005 年份的查詢,完全沒有叢集擴充、並行度或載入速率的數字;而這種節點本機磁碟、緊耦合的 shared-nothing 架構,後來被儲存與運算分離的設計取代(Snowflake、Redshift RA3,以及 Vertica 自己的 Eon 模式)。

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

Vertica 是 C-Store 原型與現代分析技術堆疊之間的橋樑,而它拍板的許多做法,如今已經變成看不見的預設值。以區塊為單位的最小值/最大值中繼資料用來裁切 container,變成了 Amazon Redshift 的 zone map,也變成 Apache Parquet 與 ORC 中每個 row group、每個 stripe 的 min/max 統計;不可修改的資料檔案加上以位置為基礎的 delete vector,正是 Apache Iceberg v2 與 Delta Lake 後來採用的機制,連名字都一樣;mergeout 的指數階層,則與 ClickHouse 的 MergeTree 乃至整個 LSM 壓縮合併家族共用同一套「重寫次數有上界」的論證。直接在編碼資料上運作的向量化執行,加上壓縮感知的成本模型,又出現在 DuckDB、ClickHouse、StarRocks 與 Apache DataFusion 之中,而 DataFusion 正是本文第一作者 Andrew Lamb 後來投入帶領的專案。Snowflake 與 BigQuery 繼承了「欄式、重度編碼、沒有索引只有排序儲存」的世界觀,只是把節點本機磁碟換成物件儲存,而 Vertica 自己後來也以 Eon 模式走上同一條路。它最耐久的貢獻或許是反面的:留下一份有理有據的紀錄,說明哪些研究構想——join index、讀取儲存區裡的 B-tree、時間窗 epoch、WOS 與 ROS 混合合併、隨機 join 順序——在碰到真實客戶之後沒能活下來,以及為什麼。

論文原文 — 逐字引用

“In practice and experiments with early prototypes, we found that the costs of using join indices far outweighed their benefits.(中譯:在實務與早期原型的實驗中,我們發現使用 join index 的代價遠遠超過它帶來的好處。)”

§3.2

“Vertica has no need of traditional transaction logs because the data+epoch itself serves as a log of past system activity.(中譯:Vertica 不需要傳統的交易日誌,因為「資料加上 epoch」本身就是一份過去系統活動的日誌。)”

§5.2

“To our surprise, such special case optimizations caused almost as many problems as they solved because certain user queries would go super fast and some would not in hard to predict ways, often due to some incredibly low level implementation detail.(中譯:令我們意外的是,這類特例最佳化製造的問題幾乎和它解決的一樣多——某些使用者查詢會變得飛快、某些卻不會,而且快慢難以預測,往往取決於某個極其底層的實作細節。)”

§7

術語 — 依本篇論文的用法

Projection(投影)
一張表部分欄位的排序物化結果,也是 Vertica 唯一的實體資料結構。表的資料只存在於它的各個 projection 之中,每個 projection 有自己的排序順序、編碼方式與分段方式。
Super projection
包含 anchor table 全部欄位的 projection。Vertica 要求每張表至少要有一個,原因正是它拿掉了 C-Store 的 join index,必須能在單一 projection 內重組出完整的 tuple。
Segmentation(分段)
把 tuple 指派到叢集中各節點的機制,以 projection 為單位用 SEGMENTED BY 宣告一個整數運算式,其 2^64 值域被切成 ring 上的連續範圍分給各節點。它提供決定性的「值到節點」對應,這正是本機完成的 join 與分散式 distinct 聚合能成立的基礎。
Partitioning(分割)
節點之內的實體隔離,以表為單位用 PARTITION BY 宣告,保證同一個 ROS container 中所有 tuple 的分割運算式值相同。用途有二:刪檔案就等於大量刪除資料,以及讓 min/max 的 container 裁切更銳利。
ROS container
磁碟上不可修改的儲存單位,依 projection 的排序順序存放完整 tuple,每個欄位兩個檔案:編碼後的資料,以及記錄每個區塊起始 position、最小值與最大值的 position index。
WOS(Write Optimised Store)
完全位於記憶體、未編碼也未壓縮的緩衝區,吸收零星的插入、刪除與更新,好讓真正寫到磁碟時的批量夠大、足以攤平成本。它會依所屬 projection 的分段規則分段,但在 moveout 完成之前,其內容會在節點故障時消失。
Tuple mover
背景服務,負責 moveout(非同步把 WOS 排入 ROS container)與 mergeout(在指數階層的規則下把小 container 併成大的),並在合併時順手清掉在 Ancient History Mark 之前就被刪除的列。
Epoch、LGE 與 AHM
epoch 是每筆 tuple 與每個刪除標記都帶著的邏輯提交時間戳,因此 epoch 邊界就是一個全叢集一致的快照。LGE(Last Good Epoch)是某個 projection 的資料確實已寫入 ROS 為止的 epoch;AHM(Ancient History Mark)則是「任何查詢都看不到更早歷史」的界線,越過它的已刪除列就能被清除。
K-safety 與 buddy projection
K-safety 是「掉 K 個以內的節點,叢集仍保證可用」的保證,做法是把每個 segment 的 K+1 份副本放在不同節點上。buddy projection 則是欄位相同的複本 projection,其分段方式保證同一列不會被兩者存在同一個節點上。

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

在時間軸上查看