跳转至

MPP 列存引擎的架构设计哲学 —— 从一行 trade 数据出发理解为什么分析型数据库选择列存

作者:JiangChong | 撰写时间:2026年01月

适用场景框:当你需要理解为什么几乎所有分析型 MPP 数据库(Vertica / Redshift / Doris / StarRocks / ClickHouse 等)都选择列存作为核心存储格式,以及这些设计决策如何影响查询性能、存储成本和运维方式时,这篇文章适合你。

关联文章

理解全文脉络

这篇文章从「一行交易数据应该怎么存」这个具体问题出发,逐层展示存储格式的五级演进(插入序 → 排序 → 列存 → 编码 → 压缩),然后在三个关键维度上解释为什么这套设计对分析型负载是高效的:I/O 带宽节省、压缩效率提升、向量化执行加速。如果你对列存已有基本了解,可以直接跳到第 3 节看设计决策与 trade-off 分析;如果你需要一个从零开始的完整叙事,建议从第 1 节读起。

声明:本文以 Vertica 为主要剖析对象,但在所有环节对比其他 MPP 系统的不同实现——ClickHouse、Doris/StarRocks、Greenplum、Snowflake。「MPP 共性」与「Vertica 专属」将在文中明确区分:通用机制标注为跨系统结论,Vertica 特有机制标注其独特性并说明其他系统为何不需要。


1. 问题背景 — 这个架构要解决什么问题

1.1 两个世界的冲突

数据库领域存在一个根本性的「人格分裂」:事务型负载(OLTP)和分析型负载(OLAP)对存储格式的需求截然相反

维度 OLTP 负载 OLAP 负载
典型操作 单行 INSERT / UPDATE / DELETE 大规模扫描 + 聚合
每次操作涉及行数 几行到几十行 百万到数十亿行
并发请求量 数千 TPS 几十 QPS
查询模式 通过主键精确定位 对少数列做聚合筛选
对存储的核心诉求 单行写入快 列级扫描快

C-Store 7 Years §2.1.1 对这组对比有精确定义——事务型负载的特征是「large number of transactions per second where each transaction involves a handful of tuples」,分析型负载是「smaller transaction volume but each transaction examines a significant fraction of the tuples in a table」。

传统行存数据库(如 PostgreSQL、MySQL InnoDB、Oracle 默认存储)的设计哲学是为 OLTP 优化的:一行数据的所有列连续存储,一次磁盘 I/O 即可读出或写入一整行。这在 40 年前是合理的——那时数据库主要用于银行交易、订单录入等事务场景。但四十年后,数据量暴涨了多个数量级,分析型查询成为刚需,这套架构的局限性就暴露了。

1.2 从一个具体场景看问题

假设你有一张股票交易表 trades,每天产生 10 亿行,包含以下列:symbol(股票代码)、date(日期)、time(时间)、price(价格)、volume(成交量)、bid(买方报价)等 20+ 列。一个典型分析型查询如下:

SELECT symbol, SUM(volume)
FROM trades
WHERE symbol = 'HPQ'
  AND date = '2026-05-13'
GROUP BY symbol;

这个查询只需要 3 列(symboldatevolume),只涉及约 0.01% 的行(一行股票在某一天的交易记录)。但如果数据按插入顺序(即行存)存储——每一行的所有列连续排列在磁盘上——那么回答这个查询需要把整张表(或者至少是包含 symboldate 列的索引所指向的所有磁盘页)全部读入内存。

核心矛盾:查询只需要 3/20 列和 0.01% 的行,但行存强迫你读完 100% 的列和远比需要多得多的行。这正是分析型数据库中「I/O 浪费」的根源。

(Design Choices §2.2 使用相同的股票交易表示例进行叙述,本文在此基础上做了展开。)

对于这个 I/O 浪费问题,不同的 MPP 列存系统选择了不同的技术路线来解决——但都指向同一个方向:放弃行存,拥抱列存 + 排序

系统 列存是唯一存储方式? 排序机制 写入模型 关键差异
Vertica ✅ 是(projection 即表本身) 用户显式定义 ORDER BY,每个 projection 独立排序 Append-only + Delete Vector 同一张表可有多个不同排序的物理副本
ClickHouse ✅ 是(MergeTree 表引擎) ORDER BY 定义稀疏主键索引(每 8192 行一个标记) 后台 Merge(Merge-on-Read,或 v23+ Merge-on-Write 可选) 单表仅一种排序;排序键也是主键索引
Doris / StarRocks ✅ 是(Segment 文件列存) DUPLICATE/UNIQUE/AGGREGATE KEY 定义排序+聚合键 Compaction 合并(Merge-on-Write 可配) 排序键可选前缀索引加速点查
Snowflake ✅ 是(micro-partition,自动) 自动聚类(Automatic Clustering),用户不控制排序键 完全重写 micro-partition 零运维但排序粒度粗(micro-partition 级)
Greenplum ⚠ 可选(AO Columnar,非默认) 默认无序,可选 CLUSTER BY MVCC dead tuple + VACUUM 行存为默认存储格式,列存是附加选项

本文选取 Vertica 作为主线深度剖析(因为其多 projection 设计在排序灵活性上提供了最丰富的 trade-off 案例),ClickHouse 和 Greenplum 作为主要对比参照,Snowflake 和 Doris 在关键决策点上穿插对比。

1.3 三个维度的突破

解决分析型负载的性能问题,需要从三个维度同时发力:

  1. I/O 带宽:只读需要的列,不读无关列
  2. 压缩效率:排序后的同列数据具有极高的数据局部性,压缩比远超行存
  3. 向量化执行:列存格式使得 CPU 可以在同一列的一组值上执行相同操作,减少控制流开销

这三个维度并非各自独立——它们是层层递进、互相放大的关系。下面第 2 节将从一行数据出发,逐层展示这个递进过程。


2. 核心概念与机制 — 从插入序到极致压缩的五级演进

本节的核心叙事来自 Design Choices §2.2,是理解列存引擎最直观的路线。我们用同一张 trades 表贯穿全程。

2.1 第一级:插入序(行存基线)

SYMBOL  | DATE       | TIME         | PRICE  | VOLUME | ...
--------|------------|--------------|--------|--------|----
HPQ     | 2026-05-13 | 01:02:02 PM  | 40.01  | 100    | ...
IBM     | 2026-05-13 | 01:02:03 PM  | 171.22 | 10     | ...
AAPL    | 2026-05-13 | 01:02:03 PM  | 338.02 | 5      | ...
GOOG    | 2026-05-13 | 01:02:04 PM  | 524.03 | 150    | ...
HPQ     | 2026-05-13 | 01:02:05 PM  | 39.97  | 40     | ...
AAPL    | 2026-05-13 | 01:02:07 PM  | 338.02 | 20     | ...
GOOG    | 2026-05-13 | 01:02:07 PM  | 524.02 | 40     | ...

这是最自然的存储方式——数据按写入顺序追加,一行连着一行。优点是插入快(append-only),缺点是查询慢。当执行 WHERE symbol = 'HPQ' 时,匹配的行散落在磁盘各处,无法利用顺序 I/O。

比喻:就像一本日记,每天发生的事情按时间顺序记录。如果你想找到所有提到「张三」的条目,必须从头翻到尾。

2.2 第二级:排序

SYMBOL  | DATE       | TIME         | PRICE  | VOLUME | ...
--------|------------|--------------|--------|--------|----
AAPL    | 2026-05-13 | 01:02:03 PM  | 338.02 | 5      | ...
AAPL    | 2026-05-13 | 01:02:07 PM  | 338.02 | 20     | ...
GOOG    | 2026-05-13 | 01:02:04 PM  | 524.03 | 150    | ...
GOOG    | 2026-05-13 | 01:02:07 PM  | 524.02 | 40     | ...
HPQ     | 2026-05-13 | 01:02:02 PM  | 40.01  | 100    | ...
HPQ     | 2026-05-13 | 01:02:05 PM  | 39.97  | 40     | ...
IBM     | 2026-05-13 | 01:02:03 PM  | 171.22 | 10     | ...

现在把数据按 symbol, date, time 排序。查询 WHERE symbol = 'HPQ' 现在只需要一次顺序扫描——所有 HPQ 的行连续存储,数据库可以通过 min/max 索引快速定位到 HPQ 的起始位置,跳过 AAPL 和 IBM。

比喻:把日记重写为按人名分组——张三的事、李四的事各放一起。找张三只需要翻到张三那部分。

但问题还没完全解决:查询 SELECT symbol, SUM(volume) 只需要 2 列,而排序后的行存仍然要把整行的所有 20+ 列都读进来。这里就需要下一步——列存

2.3 第三级:列存(Columnar Storage)

SYMBOL                DATE                    VOLUME
--------              ------------            ------
AAPL                  2026-05-13              5
AAPL                  2026-05-13              20
GOOG                  2026-05-13              150
GOOG                  2026-05-13              40
HPQ                   2026-05-13              100
HPQ                   2026-05-13              40
IBM                   2026-05-13              10

每列单独存储。执行 SELECT symbol, SUM(volume) 时,只需读取 symbolvolume 两列的文件,完全跳过了 pricetimebid 等无关列

在 Vertica 中,这是通过 projection(投影)实现的。一个 projection 定义了哪些列按什么顺序存储、如何分段到集群节点。同一个表可以有多个 projection,每个可能包含不同的列子集、不同的排序顺序。

重要澄清:一个常见误解是「列存先把数据拆成列,再对每列分别排序」。实际上数据是先整体排序,再拆成列。这样第 n 行的所有列值在各列文件中处于相同的位置 n,通过「位置索引」(position index)可以高效重建整行——不需要存储显式的 row id(Design Choices §2.3)。

比喻:把日记中所有人名、日期、事件分别抄到不同的本子里,每个人名在第几行,对应的事件也在第几行。查张三的某件事只需要翻两本本子,不需要翻全部。

列存的 I/O 收益量化:如果表有 20 列,查询只需要 2 列,列存理论上比行存节省约 90% 的 I/O。实际收益还取决于数据分布和压缩,但数量级如此。

Put into practice: 这个收益在所有列存 MPP 中通用——ClickHouse 的 .bin 文件、Doris 的 Segment 文件、Snowflake 的 micro-partition 都遵循每列独立存储的原则。差异在于粒度:Vertica 的列文件以 ROS container 为单位管理,ClickHouse 以 granule(8192 行)为最小扫描单元,Snowflake 的 micro-partition 通常覆盖 50-500MB 未压缩数据。粒度越细,列裁剪的 I/O 节省越精确,但元数据管理开销越大。

2.4 第四级:编码(Encoding)

排序后的列存带来了一个新的、巨大的优化机会:同一列中相邻值高度相似。编码就是利用这种数据局部性,用更紧凑的方式表示数据。

Vertica 实现了多种编码类型(来源:C-Store 7 Years §3.4.1):

编码类型 原理 适合场景 当前版本对应
RLE(Run Length Encoding) 连续相同值替换为 (值, 出现次数) 低基数、已排序的列(如 symbol RLE
Delta Value 每个值记录与块内最小值的差值 多值、未排序的整数列 DELTAVAL
Block Dictionary 块内建字典,值替换为字典引用 少值、未排序的列 BLOCKDICT_COMP
Compressed Delta Range 每个值记录与前一个值的差值 多值、已排序或范围受限的浮点列 DELTARANGE_COMP
Compressed Common Delta 块内所有差值建字典,用熵编码存储引用 已排序、有规律间隔的数据(如时间戳) COMMONDELTA_COMP
GCD Delta 基于最大公约数的差值编码 整数等差序列 GCDDELTA(v26 新增)

上表只列出了 C-Store 论文中讨论的编码类型及其当前版本对应。v26.2 实际支持的编码/压缩类型共有 13 种,论文未涉及的包括通用压缩类(GZIP_COMPBZIP_COMPZSTD_COMPZSTD_FAST_COMPZSTD_HIGH_COMP)和简化字典编码 BLOCK_DICT

RLE 的效果最为直观symbol 列排序后,2000 万行 HPQ 连续存储,RLE 将其压缩为 (值: HPQ, 运行长度: 2000万)——2000 万行变成了一个三元组。在 C-Store 7 Years §8.2.2 的真实客户案例中,一个有 200M 行和 4 列的计量数据表,Metric 列经过 RLE 后压缩到只有 5 KB;整个表从原始 6200 MB(CSV)压缩到 418 MB,压缩比约 14.8:1

比喻:编码就像速记——对于反复出现的模式,用更短符号代替。你已经按人名整理好了,自然不会在每一行重复写「张三,张三,张三...」,而是写「张三 × 2000万」。

对比:编码类型是列存系统差异化最明显的领域。Vertica 提供 13 种编码/压缩类型,默认编码 AUTO 仅在 LZO(字符串/二进制/浮点/布尔列)和 delta(整数/日期/时间列)之间自动选择;其余类型需 Database Designer 根据字段类型及长度推荐或用户手动指定。ClickHouse 提供 Delta/DoubleDelta/Gorilla/T64 等 codec 由用户在 CODEC() 中显式指定,Doris 默认使用字典编码+BitPacking+RLE 的三层组合。Vertica 的自动编码选择减少了运维负担但可能不是绝对最优,ClickHouse 的手动指定更灵活但对 DBA 要求更高。

2.5 第五级:物理存储层面的极致压缩

编码之后,Vertica 还会对编码后的数据进行进一步压缩。完整的存储管线的效果,在 C-Store 7 Years §8.2.1 中有一个对比实验:

方案 100 万随机整数 (1-10M) 压缩比
原始文本 7.5 MB 1:1
gzip(未排序) 3.6 MB 2.1:1
gzip(排序后) 2.3 MB 3.3:1
Vertica(排序+编码+压缩) 0.6 MB 12.5:1

关键洞察:排序对压缩效果的提升是跨层级的——排序后的数据不仅让 RLE 这类编码更有效,也让后续的通用压缩算法(如 gzip)能发现更多重复模式。排序是列存压缩效率的放大器,不是可选优化

对比:各系统在通用压缩层的选择也有所不同。Vertica 默认使用 gzip(可配 LZO/ZSTD),ClickHouse 默认 LZ4(速度优先,可配 ZSTD 获得更高压缩比),Doris/StarRocks 默认 ZSTD(压缩比与速度的平衡点),Snowflake 自动选择压缩算法(用户不可控)。压缩算法的选择本质上是 CPU 时间换 I/O 时间的 trade-off——LZ4 解压最快但压缩比最低,ZSTD 压缩比高但解压稍慢,gzip 压缩比最高但解压最慢。

2.6 五级演进总览

层级 存储方式 核心优化 I/O 量(相对行存)
① 插入序 行存,按写入顺序 100%
② 排序 行存,按查询列排序 范围扫描,跳过无关行 10-30%
③ 列存 列存,每列独立文件 只读需要的列 1-10%
④ 编码 列存 + 编码(RLE / Delta 等) 列内数据局部性压缩 0.1-5%
⑤ 压缩 列存 + 编码 + 通用压缩 多层压缩叠加 0.05-3%

这五级并非彼此替代,而是层层叠加。走到第五级时,原始 7.5 MB 的数据可能只需要 ~0.6 MB 的存储和相应的 I/O。这就是为什么分析型 MPP 数据库几乎都选择了列存作为核心存储格式


3. 设计决策与 Trade-off

3.1 列存 vs 行存:不是谁更好,而是谁更适合

列存并非在所有场景下都优于行存。理解它们的 trade-off 边界是正确使用列存数据库的前提。

维度 行存 列存
单行读取(SELECT * WHERE pk = ? ✅ 1 次 I/O 拿到整行 ❌ 需要读 N 个列文件,随机 I/O 多
列聚合(SELECT SUM(col) FROM t ❌ 读整表,忽略不了非必要列 ✅ 只读 1 列,顺序扫描
单行写入 ✅ 就地更新或追加一行 ❌ 需要更新 N 个列文件 + 维护排序
批量加载 ✅ 顺序追加 ✅ 可先写入 WOS/内存缓冲区,后台排序+压缩
压缩效率 ⚠ 同一行不同列数据类型各异,难以统一压缩 ✅ 同列值数据类型一致,排序后高度可压缩
宽表查询(选少数列) ❌ I/O 浪费严重 ✅ 精确读取所需列

在 Vertica 的实际使用中,这个 trade-off 体现在 projection 的超集要求上:每个表必须至少有一个 super projection——包含表中所有列的 projection。这意味着即使只需要 3 列的查询,表中也存在一份完整的数据副本。但得益于列存+压缩,这份「完整」副本的存储成本远低于行存。C-Store 论文的实验显示,C-Store 用 1.987 GB 存储了行存需要 4.48 GB 才能装下的数据——在更少的空间里实现了冗余 + 更快的查询。

行存/列存双模支持是 MPP 系统的常见策略,但各系统实现方式不同:

系统 行存支持 列存支持 设计哲学
Vertica ❌ 仅列存 ✅ 原生列存(projection) 纯列存,用多 projection 冗余换行存等价功能
ClickHouse ❌ 仅列存 ✅ 原生列存(MergeTree) 纯列存,通过物化列(MATERIALIZED COLUMN)优化高频查询
Greenplum ✅ Heap Table(默认) ⚠ AO Columnar(可选) 行存为主,列存为附加——适合 HTAP 混合负载
Doris / StarRocks ✅ 部分(主键模型用行存加速点查) ✅ 原生列存(Segment) 列存为主,主键索引层用行存格式——列存中的行存微优化
TiDB / TiFlash ✅ TiKV(行存,OLTP) ✅ TiFlash(列存,OLAP) 行存列存物理分离——Raft 同步,HTAP 双引擎

关键差异:纯列存系统(Vertica/ClickHouse)的核心挑战是让 SELECT * 不成为灾难——Vertica 用 super projection、ClickHouse 的 PREWHERE 优化来缓解。而行列双模系统(Greenplum/TiDB)的核心挑战是两套存储的同步一致性和查询优化器如何选择路由。

3.2 排序顺序的选择:一个投影,一个排序序

列存的关键约束是:每个 projection 只能有一种排序顺序。数据在物理上必须按照某个排序键全局有序。这就产生了一个核心设计问题:如果有多种查询模式,每种需要不同的排序顺序怎么办?

方案 A:只建一个 projection,按最常见的查询模式排序。代价是其他查询模式无法利用排序的加速。

方案 B:建多个 projection,每个用不同的排序顺序。这使得不同查询都可以受益于排序,但代价是存储空间翻倍数据加载时多次写入

Vertica 的选择:方案 B——允许(甚至鼓励)同一张表有多个 projection。这是 Vertica 区别于传统物化视图的核心设计点:projection 不是辅助索引,而是唯一的物理存储结构。它不索引表,它本身就是表(C-Store 7 Years §3.1)。

这一选择的关键支撑是压缩——没有压缩,多个 projection 的存储成本将是不可接受的。例如 C-Store 论文中的实验,用 2 个投影方案在 1.987 GB 内完成了行存(单一表示)需要 4.48 GB 的任务。

3.3 分段(Segmentation):MPP 的核心

数据在集群中的分布方式直接影响 JOIN 和聚合的性能。核心方案对比:

方案 原理 优势 劣势
复制(Replication) 每节点存完整数据 JOIN 永不需要网络传输 存储 N 倍,大表不现实
Hash 分段 按 HASH(key) 分布到不同节点 同 key 的数据在同一节点,支持本地 JOIN 倾斜 key 导致数据不均衡
轮询(Round Robin) 随机均匀分布 绝对均衡 JOIN 永远需要网络 Shuffle

Vertica 在 projection 级别指定分段方式(SEGMENTED BY HASH(cols)),这意味着同一个表的不同 projection 可以用不同键分段。这是 Vertica 区别于传统 table-level 分布的重要差异——Greenplum 等在表级定义分布键,而 Vertica 可以在 projection 级定义(C-Store 7 Years §3.6)。

收益:优化器可以针对不同查询选择不同 projection。例如一个按 user_id 分段的 projection 用于用户维度的聚合查询,一个按 date 分段的 projection 用于时间维度的分析——JOIN 都能本地完成。

代价:数据加载时需要写入所有 projection,多个分段的 projection 意味着加载时要多次分发数据。此外,当集群节点数变化时,需要 rebalance 所有 projection。

3.4 Append-Only 存储与删除向量

列存的排序和压缩是昂贵的操作。如果每次 UPDATE 或 DELETE 都重建排序+压缩,写入性能将是灾难性的。

Vertica 的选择数据从不原地修改(Design Choices §2.4)。INSERT 写入新的 ROS container——一组排序、压缩后的列文件,类似于 ClickHouse 的 data part 或 Doris 的 Segment,一旦写入就永不修改。DELETE 不在数据文件上操作,而是创建一个 delete vector——一个记录「哪些位置的行已被删除」的位图列表——这与 Greenplum 的 MVCC dead tuple(页内标记删除行)和 Snowflake 的 micro-partition 重写(删除行直接不写入新文件)形成三种不同的策略。UPDATE 被拆解为 DELETE + INSERT。

这个设计带来了深远的影响:

收益 代价
读取不需要锁(MVCC 通过 epoch 实现) 文件数随时间增长
排序和压缩可以批量执行 需要 tuple mover 后台合并文件
时间旅行查询天然支持(通过 AHM) DELETE 不会立即释放空间
节点恢复只需重放 DML delete vector 多了也影响查询性能

比喻:就像记账不用橡皮擦——改账不涂改,而是在新的一行记一笔更正。旧账保留,随时可以回溯。

跨系统对比:每个列存 MPP 都必须解决「不可变数据单元中如何标记删除」这个问题,但它们的选择差异巨大:

维度 Vertica ClickHouse Greenplum Snowflake
标记方式 Delete Vector(外置独立文件) _row_exists 伪列 / mutation 文件 MVCC dead tuple(页内标记) 重写 micro-partition(删除行不写入)
保留期 / 可见性 AHM + Epoch — 全局水位线,慢节点拖累全集群 Mutation 异步完成,无全局水位线 事务 ID horizon — 类似 Epoch 但粒度更细 Time Travel 窗口(0-90 天可配),自动过期
物理回收 Tuple Mover mergeout — 复用合并 I/O,需配置资源池 后台 Merge/Compaction — 全自动 VACUUM — 独立全表扫描,I/O 开销大 云存储 GC — 零运维,但不可控时机
代价 AHM 被卡住 = 空间永不回收 + delete vector 堆积 Mutation 是异步重写,大范围删除会产生大量 I/O VACUUM 可能持续数小时,期间表仍可读写 无运维负担,但删除成本前置(DELETE 即重写)

Vertica 的 Delete Vector + AHM + Tuple Mover 三件套是一个紧密耦合的设计——任一环节出问题都会级联影响另两个。Snowflake 的「删除即重写 micro-partition」方案将这组复杂性压缩为一个操作,但代价是大范围 DELETE 的执行延迟更高。

3.5 Early vs Late Materialization 与 SIPS

在列存中,tuple reconstruction(将分散在各列文件中的值组合成完整行)是一个核心操作。Early materialization(EM) 是在查询早期就把需要的列组合成行,然后像行存一样处理;Late materialization(LM) 是延迟到最后一刻才组合——先在各列上独立操作,只传递行 ID,最后才物化需要的行。

Design Choices §3.1 和 sips 论文给出了这组 trade-off 的详细分析:

策略 优势 劣势 何时最优
Early Materialization 简单,实现成本低 需要扫描的列多,减少不了 I/O JOIN 选择性高(大部分行参与 JOIN)
Late Materialization 显著减少 I/O(低选择性时) 实现复杂,高选择性 / 大键集时性能悬崖 JOIN 选择性低(只匹配少量行)
SIPS(Sideways Information Passing) 结合两者优势,从不显著有害 实现最复杂 几乎所有场景

SIPS 的核心思想:JOIN 先加载小表,构建内表键的哈希表 → 将这个哈希表传递给外表扫描 → 外表扫描在读取时就过滤掉不匹配的行。SIPS 在 0% 选择性时比纯 EM 快 52 倍(1206 ms → 23 ms),且从不显著有害——因为 SIPS 检测到无效时会自动关闭。

Vertica 工程团队在实现中逐渐意识到:SIPS 单独使用通常就够了,Late Materialization 带来的额外复杂性往往不值得。正如 Design Choices §3.1 所写:「while we have not thrown them out, we wouldn't bother to implement them if we didn't have them already」。


4. 设计对实际使用的影响

4.1 查询维度

自动生效的收益

  • 列裁剪:优化器自动判断查询需要哪些列,只读取对应的列文件。这是列存最基础也最通用的收益,不需要任何手动干预
  • 谓词下推与 min/max 过滤:每个 ROS container 存储每列的 min/max 值,查询规划时可以直接跳过不符合谓词的 container。分区(PARTITION BY)让这种过滤更有效
  • 排序加速GROUP BY / ORDER BY / MERGE JOIN 如果与 projection 的排序键匹配,可以消除排序步骤

需要手动干预的场景

  • JOIN 性能严重依赖 projection 设计:如果两个表的分段方式不兼容(例如一个表按 key1 分段,另一个未分段),优化器会添加 RESEGMENT 算子——在节点间重新分发数据,消耗大量网络带宽
  • 统计信息过期:如果统计信息缺失(显示 NO STATISTICS),优化器无法准确估算代价,可能选择次优的执行计划。详见 Vertica 统计信息管理与查询性能

4.2 加载维度

收益

  • 批量加载(COPY)是列存最自然的加载方式,数据可以并行排序、编码、压缩后写入多个 ROS container
  • Vertica ≥9.2 支持直接写入 ROS(无 WOS),简化了加载路径

代价与约束

  • 每个 projection 都参与加载——如果表有 3 个 projection 且每个都分段,每次加载需要写入 3 份数据。但得益于压缩,这个代价比行存要小
  • 小批量高频率加载(trickle load)会创建大量小 ROS container,需要 tuple mover 频繁合并
  • ROS container 上限:每个节点每个 projection 最多 1024 个 ROS container。超过此阈值会触发 ROS pushback。来源:ROS Pushback 故障排查

4.3 运维维度

自动化的部分

  • Tuple Mover 自动执行 mergeout,合并小 ROS container 为大的、清理已删除的行(当 AHM 前进后)。来源:Tuple Mover 最佳实践完全指南
  • Database Designer 自动从查询负载中提取物理设计(projection 的排序、分段、编码)

需要关注的点

  • mergeout 本身消耗 I/O 和 CPU,需要合理的资源池配置
  • 大量 DELETE 后的数据不会立即从磁盘移除,要等到 AHM 前进 + tuple mover 合并后才能回收空间
  • 宽表(>250 列)+ 大量 NULL 值可能导致列文件碎片化(ROS Bundling 可缓解)。来源:ROS Bundling 最佳实践

4.4 常见误解与澄清

误解 事实
「projection 越多越好」 每个额外的 projection 都意味着加载时需要额外写入、存储空间增加、tuple mover 有更多文件要管理。Vertica 实际生产中,大多数客户只有 0-3 个窄 projection 外加 1 个 super projection。来源:C-Store 7 Years §3.1
「分段键一定会影响 JOIN 性能」 分段只在同键 JOIN 时有益。如果两个表按不同键分段,它们的 JOIN 需要 resegment,与不分段没有区别。反之,如果分段键就是 JOIN 键,JOIN 可以完全本地执行
「列存对所有查询都快」 SELECT * 或点查(WHERE pk = ?)这类查询,列存可能比行存慢——因为需要从多个列文件重建整行。Vertica 通过 position index 和 skip-scan 优化了这一点
「排序顺序不重要,编码就够了」 排序是压缩的放大器。C-Store 7 Years §8.2.1 的数据:未排序+gzip 压缩到 3.6 MB,排序+gzip 压缩到 2.3 MB,排序+Vertica 压缩到 0.6 MB。排序带来的收益接近 2 倍

5. 案例验证

5.1 虚构案例:排序顺序的选择对查询性能的影响

📝 虚构案例

场景:某电商平台有一张 50 亿行的订单表 orders,包含 order_iduser_idorder_dateamountstatus 等 30 列。最初使用数据库设计器(DBD)创建的默认 projection 按 order_date 排序,按 HASH(order_id) 分段。

查询负载:90% 的查询以 user_id 为过滤条件:

SELECT user_id, date_trunc('month', order_date), SUM(amount)
FROM orders
WHERE user_id IN (SELECT user_id FROM active_users WHERE level = 'vip')
GROUP BY 1, 2;

问题:每个查询需要扫描整个日期范围,min/max 过滤无法跳过任何 ROS container。同时 GROUP BY 的排序顺序与 projection 排序顺序(order_date)不匹配,需要额外排序。

优化:创建新 projection,按 user_id, order_date 排序,按 HASH(user_id) 分段:

CREATE PROJECTION orders_user_sort
AS SELECT * FROM orders
ORDER BY user_id, order_date
SEGMENTED BY HASH(user_id) ALL NODES;

效果

指标 优化前(order_date 排序) 优化后(user_id 排序) 改善
扫描数据量 50 亿行(全表) 约 5000 万行(vip 用户) ↓ 99%
GROUP BY 排序需求 需要额外排序 数据已排序,跳过 消除排序
查询耗时 ~120 秒 ~3 秒 ↓ 97.5%
存储增加 +1.1×

回溯到原理:排序顺序决定了哪些查询可以用上 min/max 过滤和 merge join。如果投影的排序顺序与查询的过滤/分组模式不匹配,列存的收益会被大幅削减(设计原理来自 Design Choices §2.2)。

5.2 虚构案例:ROS Container 爆炸与 Tuple Mover 策略

📝 虚构案例

场景:某金融机构的 Vertica 集群上,一张交易流水表每天通过 COPY 加载 24 次(每小时一次,每次 ~2GB)。一段时间后,DBA 发现查询越来越慢,且加载偶尔被 ROS pushback 阻塞。

根因分析:每小时 1 次加载 = 每天 24 个 ROS container,一个月 720 个。加上之前的存量,该 projection 的 ROS container 数已接近 1024 上限。大量小 container 意味着:每个查询需要打开更多文件;Mergeout 来不及合并所有小文件;Min/max 过滤效率下降。

修复

  1. 增大 Tuple Mover 资源池的 MEMORYSIZEPLANNEDCONCURRENCY,让 mergeout 更激进
  2. 合并加载批次:将每小时加载改为每 4 小时一个批次,减少 ROS container 创建速度

回溯到原理:这是 §3.4 中 append-only 设计代价的直接体现——每次加载创建新文件,如果文件小而多,维护成本会超过性能收益。(原理来自 Design Choices §2.5 和 C-Store 7 Years §4。)

5.3 跨系统模拟:500 亿行表删除 40% 数据后的空间回收行为对比

📝 虚构案例 · 跨系统模拟

场景:一张 500 亿行的用户行为表,包含 20 列,原始压缩后约 8 TB。业务需要按 GDPR 合规要求批量删除 200 亿行(40%)历史数据。下面对比 5 个 MPP 系统在完成这一操作后的空间回收行为

维度 Vertica ClickHouse Greenplum AO Doris Snowflake
DELETE 执行速度 极快(秒级) — 仅写 delete vector 中等 — mutation 提交快但后台重写慢 中等 — 标记 dead tuple 较慢 — Compaction 需要合并大量文件 慢 — 需重写所有涉及 micro-partition
DELETE 后查询开销 有 — delete vector 越多,每次扫描都要过滤 低 — mutation 完成后空间即回收 有 — dead tuple 仍在页内,需跳过 低 — Compaction 后数据物理清除 无 — 删除行不写入新文件
空间何时回收 小时~天 — 等 Tuple Mover mergeout + AHM 推进 分钟~小时 — mutation 后台完成即回收 小时~天 — 需手动 VACUUM 或等 AUTOVACUUM 分钟~小时 — Compaction 自动触发 立即 — DELETE 即重写
运维干预 可能需要 — 若 AHM 被卡需推进;mergeout 资源不足可能堆积 低 — 全自动 高 — 建议手动 VACUUM,否则 dead tuple 长期占用空间 低 — 全自动,可调整 Compaction 优先级 零 — 不可干预
对并发查询的影响 中等 — mergeout I/O 可能影响查询 中等 — mutation 与查询竞争 I/O 高 — VACUUM 是全表扫描 低 — Compaction 优先级可调 低 — 重写操作在后台,用户无感
历史查询 ✅ 天然支持(AHM 之前的数据可查) ⚠ 受 TTL 限制 ⚠ 仅在 VACUUM 前可查 ⚠ 受 Compaction 策略影响 ✅ Time Travel 窗口内可查

关键洞察:没有任何一个系统在所有维度上都胜出。Vertica 和 Greenplum 把代价付在运维侧ClickHouse 和 Doris 把代价付在 DELETE 执行本身Snowflake 把代价付在 DELETE 延迟上。选择哪个系统,本质上是选择「你把运维人员的精力花在哪里」。

5.4 真实案例:分段键与过滤键重合导致单节点瓶颈

📋 真实案例 · 来源:某运营商 Vertica 数据仓库性能问题分析报告

背景:某运营商 93 节点 Vertica 集群(Enterprise Mode),数据仓库日常 ETL 作业。

故障现象:2020 年 8 月 21 日凌晨,发现数据库接口装载及脚本运行缓慢。检查发现一条 SQL 从晚上 22:00 执行到次日上午 04:44(持续超过 6.5 小时仍未完成),网络传输数据量达到 11 GB

根因排查

  1. 执行计划显示绝大部分步骤在单节点v_node0039)上执行
  2. 两个参与 JOIN 的表的统计信息已过期(NO STATISTICS
  3. 关键发现:大表按 statis_date 分段,而查询恰好有 WHERE statis_date = '20200819'——因为分段键与过滤键重合,所有符合条件的数据落在哈希空间同一区间,全部集中在一台节点。该表在查询中被引用了 3 次,三份中间结果全部汇聚在同一台节点上
  4. 另一个小表采用 UNSEGMENTED 方式

修复

  1. 修改大表 projection 的分段键从 statis_date 改为 JOIN 关联字段 HASH(user_id_zk, user_id_fk)
  2. 将过滤条件列和 JOIN 键加入排序字段

效果

指标 优化前(正常时段) 优化后
查询耗时 11 分 48 秒 5 分 37 秒
执行节点 大部分步骤单节点 分布式并行

回溯到原理:表面看是 UNSEGMENTED 表导致单节点执行,但真正的根因是分段键与高频过滤键重合。用 statis_date 分段看似能优化按日期的查询,但一旦查询过滤到具体某一天,当天全部数据落入一台节点,并行度降为 1。优先用 JOIN 键而非过滤键分段,存储裁剪的问题留给分区(PARTITION BY)去解决。

跨系统启示:分段键与过滤键重合导致数据倾斜——这个问题并非 Vertica 独有。Greenplum 的表级分布键如果等于高频过滤列,同样会把所有数据集中到一个 segment 上;ClickHouse 的 sharding key 若设计不当,也会导致单 shard 过载。Doris 的 DISTRIBUTED BY HASH(cols) 同样面临此问题。差异在于修复成本:Greenplum 需要重建表或 ALTER DISTRIBUTION,ClickHouse 需要重新设计 sharding key 并重分布数据,都比 Vertica 新建一个 projection 后 SELECT REFRESH() 更重。


6. 设计原则总结

【通用】原则 1:排序是列存的第一推动力,不是可选的锦上添花

为什么:排序使同值数据聚拢,直接放大了 RLE 的效果;排序使 min/max 过滤有效;排序使 merge join 无需额外排序。反例:如果 projection 的排序键与任何查询都不相关,列存只保留了列裁剪的收益,丢失了排序带来的所有其他加速。这适用于所有列存 MPP——ClickHouse 的 ORDER BY 键若设计不当,稀疏索引几乎不会命中,查询退化为全表扫描;Greenplum AO 表不指定 CLUSTER BY 则完全没有排序加速。

【通用】原则 2:分段键应该是对查询最有价值的 JOIN/GROUP BY 键,而不是随便挑一个高基数列

为什么:分段决定了数据在哪台节点——如果分段键等于 JOIN 键,JOIN 可以完全本地执行;如果不等于,每次 JOIN 都需要跨网络 resegment。反例:用 order_id 分段但所有 JOIN 都用 user_id——JOIN 绕不过网络传输。Greenplum 的表级分布键、ClickHouse 的 sharding key、Doris 的 DISTRIBUTED BY HASH 都遵循相同的设计原则——分布键 = JOIN 键是最优选择。

【通用】原则 3:压缩效率 = 排序 × 编码 × 通用压缩,三层叠加远大于单一手段

为什么:排序创造数据局部性 → 编码利用局部性做结构化压缩 → 通用压缩在编码结果上再进一步。三者缺一不可。反例:如果数据不排序就直接用 RLE,RLE 几乎不会命中任何连续重复值。这个结论跨系统成立——ClickHouse 的 ORDER BY + CODEC(Delta, ZSTD) 组合、Doris 的排序键 + 前缀编码 + ZSTD 压缩,都是同样的三层叠加逻辑。

【通用】原则 4:列存 I/O 节省的幅度 = 查询需要的列数 / 表的总列数

为什么:列存的主要 I/O 优势来自列裁剪——不读无关列。如果查询需要所有列(SELECT *),列存与行存的 I/O 量相同。反例:在宽表(100+ 列)上频繁执行 SELECT *——这是把列存当行存用。在 ClickHouse 和 Doris 中同样适用——SELECT * 会触发所有列文件的读取,列存优势完全丧失。

【Vertica】原则 5:每个额外 projection 都是「查询加速」与「加载 / 存储成本」的 trade-off

为什么:多 projection 是 Vertica 独有的设计——其他列存 MPP 没有同一张表的多份物理副本概念。多一个 projection = 多一份排序+压缩后的数据 = 加载时要多写一份 = 存储多占一份。Vertica 典型配置是 1 个 super projection + 0-3 个窄 projection,而不是无限增加。反例:每个查询模式都建一个专属 projection——结果是加载时间乘以 projection 数量,存储成本翻数倍。

【通用】原则 6:Append-Only 存储的根本设计目的是批量 I/O 而不是逐行写入

为什么:列存的排序+压缩是批量操作——创建一个不可变数据单元需要排序大量数据后才写入。小批次加载意味着排序开销超过写入本身,且创建大量小文件拖累后台合并。反例:把列存数据库当 OLTP 数据库做每秒数千次单行 INSERT——Vertica 的 ROS container 数很快逼近 1024 上限,ClickHouse 产生大量小 part 拖累 merge 性能,Doris 频繁触发 Compaction。

【通用】原则 7:统计信息是列存优化器的眼睛,过期 = 盲目飞行

为什么:列存的 CBO 优化器需要做 JOIN 顺序、分段策略、projection 选择等一系列决策——全部依赖统计信息估算数据量和选择性。反例:大量数据加载后从不更新统计信息——Vertica 显示 NO STATISTICS 导致次优计划,Greenplum 的 ANALYZE 如果不定期运行同样导致 planner 误判,ClickHouse 虽有不依赖统计信息的策略(基于 sampling 的 SAMPLE 子句),但 JOIN 重排序等高级优化仍受限于统计信息精度。


扩展阅读

Vault 内笔记(按推荐阅读顺序)

  1. The Vertica Analytic Database - CStore 7 Years Later(Vertica 专属 · 架构全貌)—— 本文的主要理论来源。第 3 章(Data Model)对 projection、编码、分段、ROS/WOS 的论述最为关键;第 8 章(Performance Measurements)包含压缩实验的真实数据
  2. Analytic Database Design Choices - Vertica's Experience and Perspectives(Vertica 专属 · 决策反思)—— Design Choices 论文提供了大量的「我们在哪里做错了」反思,§3 的 Design Mistakes 部分对理解 trade-off 边界极有价值
  3. Projection 优化最佳实践(Vertica 专属 · 实操指南)—— 从原理到实操的桥梁,覆盖排序、分段、编码的最佳实践建议
  4. C-Store: A Column-oriented DBMS(MPP 通用 · 学术起源)—— 列存的学术起源,§3 的 4 种编码方案是理解 Vertica 编码演变的基础
  5. ROS Pushback 故障排查(Vertica 专属 · 运维参考)—— 附录型实操文档,8 种常见 ROS 容器问题的诊断和修复
  6. Tuple Mover 最佳实践完全指南(Vertica 专属 · 运维参考)—— 理解 append-only 存储的运维面:mergeout 策略、资源池配置、版本差异

论文章节引用

  • C-Store 7 Years §3.4 — 6 种编码类型详解
  • C-Store 7 Years §3.6 — Hash 环形分段机制
  • C-Store 7 Years §8.2 — 压缩实验真实数据
  • Design Choices §2.2 — 插入序→排序→列存→压缩的完整演进叙事
  • Design Choices §3.1 — Early/Late Materialization 与 SIPS 的 trade-off
  • sips (Materialization Strategies) — 物化策略的完整分析
  • MPP 数据删除与存储回收机制 — 列存 immutable 存储单元如何导致所有 MPP 系统的 DELETE 都不直接删除数据

列存引擎的真正威力不在列存本身,而在排序。五级演进中,排序是唯一能跨层级放大后续所有优化的杠杆——它让 min/max 过滤从无效到有效、让 RLE 从几乎无用到极致压缩、让通用压缩发现更多模式。一个学不会用好排序的列存数据库,只是把行存的低效搬到了列维度而已。至于选择 Vertica 的多 projection 冗余换取查询灵活性,还是像 ClickHouse/Doris 那样用单副本 + 更复杂的索引和物化机制——这是架构哲学的分叉点,没有标准答案,但排序的设计质量决定了你在这条路上能走多远。