• 從 I/O、壓縮、CPU 三個角度解釋為何分析型查詢在行式(column-oriented)儲存上快一到兩個數量級。

  • 熟悉主要的輕量級壓縮編碼(字典、RLE、bit-packing、delta、bitmap)並知道何時適用、如何「直接在壓縮資料上運算」。

  • 說明向量化執行、SIMD、延遲物化(late materialization)、zone map 跳塊如何協同運作。

  • 讀懂 Parquet 檔案結構,理解 PAX/row group 的設計理由,以及 Arrow 的角色。

  • 了解行式系統如何處理寫入(delta store + merge)與 HTAP 的常見架構,並知道 PostgreSQL 在分析負載上的選項與限制。

OLTP 與 OLAP 的分歧

第 1 章區分過兩種負載:OLTP 以主鍵存取少數列的全部欄位並頻繁更新;OLAP 掃描上億列但只碰少數欄位,做聚合,幾乎只讀。 用列式(row-oriented,NSM)儲存跑 OLAP 有三個根本問題:

  1. I/O 浪費: 只需要一欄,卻得讀入整筆 tuple(可能 50 欄)。

  2. 壓縮不佳:一頁內混雜各種型別與值域,通用壓縮器找不到規律。

  3. CPU 低效:tuple-at-a-time 的 Volcano 每筆都要解讀欄位位移、虛擬函數呼叫,分支多、快取差(第 5 章)。

列式與行式

DSM(Decomposition Storage Model)把每個欄位獨立存放;同一列的各欄位值靠相同的位置(position / row id)關聯, 或者(較少見)每個值帶一個明確的 tuple id。好處對應上面三個問題:只讀需要的欄、同型別連續資料壓縮率高、 整欄緊湊的陣列正適合向量化與 SIMD。代價是:tuple 重建(查詢需要多欄時要把各欄依位置拼回)與單列更新昂貴(要改 \(n\) 個區段)。

Abadi 等人(SIGMOD 2008)的實驗證明:把行式系統「假裝」成列式(垂直切表、每欄一個索引)仍慢一個數量級—— 關鍵不只是儲存格式,而是整個執行引擎為欄位設計:壓縮、延遲物化、block iteration(向量化)與 invisible join。

壓縮編碼

行式系統偏好輕量級(lightweight)編碼:解碼極快、甚至不必解碼就能運算,再視情況疊一層 LZ4/Zstd。

lP4.6cmP3.2cmP3.6cm 編碼 & 做法 & 適用 & 直接運算
字典(dictionary) & 相異值編號,欄位只存整數碼;可保序(order-preserving)以支援範圍比較 & 低基數字串(國家、狀態) & 等值/範圍比較在碼上進行; 直接以碼為鍵
RLE & 連續相同值存成 (值, 起點, 長度) & 排序欄、低基數欄 & 聚合直接乘長度;過濾以 run 為單位
Bit-packing / FOR & 以實際值域所需位元數存(frame of reference:存最小值 + 偏移) & 整數、小範圍數值 & SIMD 一次解多個
Delta & 存相鄰差值(再 bit-packing) & 時間戳、遞增 id & 需前綴和重建;常配區塊內的 base
Bitmap & 每個相異值一個位元向量(Roaring 等壓縮位元圖) & 極低基數(性別、布林) & AND/OR 直接合併位元圖;計數 = popcount
Null suppression & 省略 null/前導零 & 稀疏欄 & —

典型壓縮比:事實表 5–10 倍(Parquet + Zstd 常見),且因為解碼便宜、讀取 I/O 大減,壓縮同時提升速度而非拖慢。 選擇編碼通常由系統自動依取樣統計決定(DuckDB、Parquet writer 都會為每個 column chunk 選擇)。

排序、zone map 與跳塊

把資料依常用過濾欄位排序(ClickHouse 的 、Redshift 的 sort key、C-Store 的 projection)有雙重效果: 排序欄的 RLE 效果極佳,且每個區塊的最小/最大值(zone map / min-max 統計)可以在不讀資料的情況下判斷整塊是否可能符合條件—— block skipping。範圍查詢 在排序欄上只需讀少數區塊。 PostgreSQL 的 BRIN 索引(第 3 章)就是 heap 上的 zone map。更進一步可以為一個表維護多種排序的副本(C-Store/Vertica 的 projections),以空間換查詢。

執行引擎

  • 向量化(第 5 章):運算子一次處理一個欄位的 1k–4k 個值;過濾結果以selection vector(通過者的位置陣列)或位元圖傳遞,不複製資料。

  • SIMD:AVX2/AVX-512 一條指令比較 8–16 個 32 位元整數;字典碼、bit-packed 整數都能直接餵給 SIMD; 編譯器對簡單緊湊迴圈能自動向量化,關鍵路徑常用 intrinsics 手寫。

  • 延遲物化(late materialization):盡量只傳遞位置與必要欄位,到最後輸出(或 join 真正需要時)才取其他欄位—— 過濾選擇率低時省下絕大多數的重建工作。對照早期物化(一開始就拼成 tuple,然後跑行式引擎)。

  • Morsel-driven 平行(Leis et al. 2014):把輸入切成小塊(morsel,約 10 萬筆)由工作執行緒動態取用,NUMA-aware,負載均衡;DuckDB、HyPer/Umbra 採用。

  • 聚合與 join:雜湊聚合在欄位陣列上以分區(radix partitioning)提高快取局部性;星型 join 用 invisible join/bloom filter 先在事實表過濾,再查維度表。

Parquet、PAX 與 Arrow

純 DSM 把每欄放成一個檔案,讀一列要開 \(n\) 個檔;Ailamaki 等人(2001)的 PAX(Partition Attributes Across)折衷: 頁內(或區塊內)按欄位分組存放,一個區塊仍包含同一批列的所有欄位。Apache Parquet 正是這個思路:

讀取器先讀 footer,依查詢需要的欄位與統計只取相關的 column chunk 與 page(projection + predicate pushdown), 這是雲端物件儲存(S3)上以 HTTP range request 讀資料湖的基礎。ORC 結構類似(stripe / stream)。 Apache Arrow 則是記憶體中的標準欄位格式(含 validity bitmap、offset buffers),讓 Pandas、DuckDB、Spark、Polars 之間零複製交換資料; Parquet 是磁碟格式、Arrow 是記憶體格式,兩者常搭配。Iceberg、Delta Lake、Hudi 等「表格式」在 Parquet 檔之上加入 manifest、快照與 ACID 提交協定,構成 lakehouse。

寫入與 HTAP

行式儲存對單列插入/更新不友善(要改每個欄位區段、且區塊通常不可變)。通用解法是 delta store + merge:

  • 新寫入先進一個小的列式或記憶體結構(Vertica 的 WOS、SAP HANA 的 delta、ClickHouse 的新 part),查詢同時掃描主儲存與 delta 並合併。

  • 背景定期把 delta 排序、壓縮、合併進主儲存(本質上就是 LSM 的 compaction——ClickHouse MergeTree 的名稱即由此而來)。

  • 刪除與更新以刪除位元圖(deletion vector)或 tombstone 標記,merge 時實際移除(Iceberg/Delta 的 deletion vectors 亦同)。

HTAP(混合負載)系統通常維持雙格式:列式主副本服務 OLTP,透過複寫產生行式副本服務 OLAP(TiDB 的 TiFlash、Oracle Database In-Memory、SingleStore、AlloyDB 的 columnar engine)。

PostgreSQL 是純列式 heap 儲存,但有一組工具可以把中等規模(數百 GB 至數 TB)的分析做得不錯:BRIN 索引與依時間分割表提供跳塊效果、 平行查詢(Parallel Seq Scan / Hash Join / Aggregate)、物化視圖、JIT 表達式編譯、 調大避免溢出。 超過這個範圍,常見的選擇是:Citus columnar( 存取方法,壓縮與欄位投影,但不支援更新與索引)、 把資料輸出成 Parquet 由 DuckDB// 查詢,或以邏輯複寫/CDC 同步到 ClickHouse、Snowflake、BigQuery 等專用系統。

系統速覽

lP10.2cm 系統 & 特色
MonetDB / X100 & 學術先驅:BAT(binary association table)、bulk 處理、X100 向量化(後成為 VectorWise/Actian Vector)
C-Store / Vertica & projections(多種排序副本)、WOS/ROS 讀寫分離、壓縮上直接運算
DuckDB & 嵌入式(「分析界的 SQLite」)、向量 2048、morsel 平行、直接查 Parquet/CSV/Arrow、PostgreSQL 相容語法
ClickHouse & MergeTree:按主鍵排序的 parts、稀疏主索引(每 8192 列一個標記 granule)、極端的壓縮與向量化、適合即時分析與日誌
Snowflake & 計算與儲存分離、micro-partition(50–500 MB、含 min/max)、自動叢集、多叢集虛擬倉儲
BigQuery / Dremel & 巢狀資料的行式表示(repetition/definition level,Parquet 繼承)、無伺服器、數千節點平行
Redshift & 基於 ParAccel(PostgreSQL 分支)的 MPP 行式倉儲,sort key / dist key
Druid / Pinot & 即時攝取 + 倒排索引 + 行式 segment,面向低延遲的事件分析

在 v6 的執行器加入一個 格式(每欄一個陣列 + null bitmap,區塊 2048 列)與兩個向量化運算子:(輸出 selection vector)與 。 對同一張 1000 萬列的表比較 在列式 Volcano 與行式向量化下的執行時間,再加上字典編碼與 zone map 跳塊,觀察每一步的收益。 這個小實驗會比任何文字更能說明本章的論點。

  1. 一張 100 欄、每欄平均 8 bytes、10 億列的表。查詢只涉及 3 欄。估算列式與行式儲存各需讀取多少資料(未壓縮);若行式平均壓縮 6 倍呢?

  2. 欄位 有 4 個相異值、1 億列、未排序。比較字典 + bit-packing 與 bitmap 編碼的空間;若資料依 status 排序後再用 RLE 呢?

  3. 解釋為什麼保序字典能支援 ,但新值插入時可能需要重編碼;系統通常如何緩解?

  4. 畫出 在延遲物化下各運算子傳遞的資料(位置?欄位?)。

  5. Parquet 的 row group 取 128 MB 而非 8 KB 的理由是什麼?太大或太小各有什麼問題?

  6. 為什麼 ClickHouse 的 MergeTree 與 LSM-Tree 有相同的名字成分?列出兩者的相似與相異處(至少各三點)。

  7. (動手)用 DuckDB 把一個 CSV 轉成 Parquet(),以 觀察每個 column chunk 的編碼、壓縮大小與統計; 再比較 中帶與不帶過濾條件時實際讀取的 row group 數。