MPP 列存引擎的架构设计哲学 —— 从一行 trade 数据出发理解为什么分析型数据库选择列存¶
作者:JiangChong | 撰写时间:2026年01月
适用场景框:当你需要理解为什么几乎所有分析型 MPP 数据库(Vertica / Redshift / Doris / StarRocks / ClickHouse 等)都选择列存作为核心存储格式,以及这些设计决策如何影响查询性能、存储成本和运维方式时,这篇文章适合你。
关联文章:
- The Vertica Analytic Database - CStore 7 Years Later — 本文主要理论来源,Vertica 架构全貌
- Analytic Database Design Choices - Vertica's Experience and Perspectives — 设计决策的正反两面、经验教训
- Projection 优化最佳实践 — 列存物理设计在 Vertica 中的实操指南
- C-Store: A Column-oriented DBMS — 列存的学术起源
理解全文脉络¶
这篇文章从「一行交易数据应该怎么存」这个具体问题出发,逐层展示存储格式的五级演进(插入序 → 排序 → 列存 → 编码 → 压缩),然后在三个关键维度上解释为什么这套设计对分析型负载是高效的: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 列(symbol、date、volume),只涉及约 0.01% 的行(一行股票在某一天的交易记录)。但如果数据按插入顺序(即行存)存储——每一行的所有列连续排列在磁盘上——那么回答这个查询需要把整张表(或者至少是包含 symbol 和 date 列的索引所指向的所有磁盘页)全部读入内存。
核心矛盾:查询只需要 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 三个维度的突破¶
解决分析型负载的性能问题,需要从三个维度同时发力:
- I/O 带宽:只读需要的列,不读无关列
- 压缩效率:排序后的同列数据具有极高的数据局部性,压缩比远超行存
- 向量化执行:列存格式使得 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) 时,只需读取 symbol 和 volume 两列的文件,完全跳过了 price、time、bid 等无关列。
在 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_COMP、BZIP_COMP、ZSTD_COMP、ZSTD_FAST_COMP、ZSTD_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_id、user_id、order_date、amount、status 等 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 过滤效率下降。
修复:
- 增大 Tuple Mover 资源池的
MEMORYSIZE和PLANNEDCONCURRENCY,让 mergeout 更激进 - 合并加载批次:将每小时加载改为每 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。
根因排查:
- 执行计划显示绝大部分步骤在单节点(
v_node0039)上执行 - 两个参与 JOIN 的表的统计信息已过期(
NO STATISTICS) - 关键发现:大表按
statis_date分段,而查询恰好有WHERE statis_date = '20200819'——因为分段键与过滤键重合,所有符合条件的数据落在哈希空间同一区间,全部集中在一台节点。该表在查询中被引用了 3 次,三份中间结果全部汇聚在同一台节点上 - 另一个小表采用 UNSEGMENTED 方式
修复:
- 修改大表 projection 的分段键从
statis_date改为 JOIN 关联字段HASH(user_id_zk, user_id_fk) - 将过滤条件列和 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 内笔记(按推荐阅读顺序)¶
- The Vertica Analytic Database - CStore 7 Years Later(Vertica 专属 · 架构全貌)—— 本文的主要理论来源。第 3 章(Data Model)对 projection、编码、分段、ROS/WOS 的论述最为关键;第 8 章(Performance Measurements)包含压缩实验的真实数据
- Analytic Database Design Choices - Vertica's Experience and Perspectives(Vertica 专属 · 决策反思)—— Design Choices 论文提供了大量的「我们在哪里做错了」反思,§3 的 Design Mistakes 部分对理解 trade-off 边界极有价值
- Projection 优化最佳实践(Vertica 专属 · 实操指南)—— 从原理到实操的桥梁,覆盖排序、分段、编码的最佳实践建议
- C-Store: A Column-oriented DBMS(MPP 通用 · 学术起源)—— 列存的学术起源,§3 的 4 种编码方案是理解 Vertica 编码演变的基础
- ROS Pushback 故障排查(Vertica 专属 · 运维参考)—— 附录型实操文档,8 种常见 ROS 容器问题的诊断和修复
- 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 那样用单副本 + 更复杂的索引和物化机制——这是架构哲学的分叉点,没有标准答案,但排序的设计质量决定了你在这条路上能走多远。