MPP 列存引擎的架构设计哲学 —— 从一行 trade 数据出发理解为什么分析型数据库选择列存¶
作者:JiangChong | 发布时间:2026-06-05
适用场景框:当你需要理解为什么几乎所有分析型 MPP 数据库(Vertica / Redshift / Doris / StarRocks / ClickHouse 等)都选择列存作为核心存储格式,以及这些设计决策如何影响查询性能、存储成本和运维方式时,这篇文章适合你。
关联文章:
- The Vertica Analytic Database - CStore 7 Years Later — 本文主要理论来源,Vertica 架构全貌
- Analytic Database Design Choices — 设计决策的正反两面、经验教训
- Projection 优化最佳实践 — 列存物理设计在 Vertica 中的实操指南
- C-Store: A Column-oriented DBMS — 列存的学术起源
理解全文脉络¶
这篇文章从「一行交易数据应该怎么存」这个具体问题出发,逐层展示存储格式的五级演进(插入序 → 排序 → 列存 → 编码 → 压缩),然后在三个关键维度上解释为什么这套设计对分析型负载是高效的:I/O 带宽节省、压缩效率提升、向量化执行加速。如果你对列存已有基本了解,可以直接跳到第 3 节看设计决策与 trade-off 分析;如果你需要一个从零开始的完整叙事,建议从第 1 节读起。
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 使用相同的股票交易表示例进行叙述,本文在此基础上做了展开。
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。实际收益还取决于数据分布和压缩,但数量级如此。
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 新增) |
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万」。
2.5 第五级:物理存储层面的极致压缩¶
编码之后,Vertica 还会对编码后的数据进行进一步压缩。例如 Block Dictionary 编码后的索引值会用熵编码压缩。完整的存储管线的效果,在 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)能发现更多重复模式。排序是列存压缩效率的放大器,不是可选优化。
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 才能装下的数据——在更少的空间里实现了冗余 + 更快的查询。
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(一组排序、压缩后的列文件);DELETE 不在数据文件上操作,而是创建一个 delete vector——一个记录「哪些位置的行已被删除」的位图列表;UPDATE 被拆解为 DELETE + INSERT。
这个设计带来了深远的影响:
| 收益 | 代价 |
|---|---|
| 读取不需要锁(MVCC 通过 epoch 实现) | 文件数随时间增长 |
| 排序和压缩可以批量执行 | 需要 tuple mover 后台合并文件 |
| 时间旅行查询天然支持(通过 AHM) | DELETE 不会立即释放空间 |
| 节点恢复只需重放 DML | delete vector 多了也影响查询性能 |
比喻:就像记账不用橡皮擦——改账不涂改,而是在新的一行记一笔更正。旧账保留,随时可以回溯。
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)让这种过滤更有效,因为分区确保同一 container 内不会混合不同分区的值
- 排序加速:
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× | — |
回溯到原理:这个案例体现了 §2 中描述的排序-列存链条——排序顺序决定了哪些查询可以用上 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 来不及合并所有小文件(Tuple Mover 配置不充分)
- Min/max 过滤效率下降(每个 container 覆盖的时间范围变窄)
修复:
- 增大 Tuple Mover 资源池的
MEMORYSIZE和PLANNEDCONCURRENCY,让 mergeout 更激进 - 合并加载批次:将每小时加载改为每 4 小时一个批次,减少 ROS container 创建速度
回溯到原理:这是 §3.4 中 append-only 设计代价的直接体现——每次加载创建新文件,如果文件小而多,维护成本会超过性能收益。这也是为什么 Tuple Mover 要按指数级大小分层(strata)合并文件——保证每个 tuple 在其生命周期内被重写的次数有上界(通常不超过少数几次)。来源:原理来自 Design Choices §2.5 和 C-Store 7 Years §4。
5.3 真实案例:Projection 设计不当导致单节点瓶颈¶
📋 真实案例
背景:某运营商 93 节点 Vertica 集群(Enterprise Mode),数据仓库日常 ETL 作业。
故障现象:某日凌晨发现数据库接口装载及脚本运行缓慢。检查发现一条 SQL 从晚上 22:00 执行到次日上午 04:44(持续超过 6.5 小时仍未完成),网络传输数据量达到 11 GB。
根因排查:
- 执行计划显示绝大部分步骤在单节点上执行
- 两个参与 JOIN 的表的统计信息已过期(
NO STATISTICS) - 其中一个表采用 UNSEGMENTED 方式分布(NODE ONLY,所有数据在一台节点)
- 另一个表虽已分段,但按
statis_date分段而未按 JOIN 键分段
修复:
- 修改问题表的 projection 为按 JOIN 关联字段
HASH(user_id_zk, user_id_fk)分段 - 将过滤条件列和 JOIN 键加入排序字段
效果:
| 指标 | 优化前(正常时段) | 优化后 |
|---|---|---|
| 查询耗时 | 11 分 48 秒 | 5 分 37 秒 |
| 执行节点 | 大部分步骤单节点 | 分布式并行 |
回溯到原理:这个案例的核心问题是 §3.3 中分段设计的失败——UNSEGMENTED projection 意味着所有数据都在一台节点上,JOIN 无法利用集群的并行能力。此外,排序键与查询过滤/分组模式不匹配(第 4 节的常见误解——「排序顺序不重要」),导致需要额外的 resegment 和排序操作。
6. 设计原则总结¶
原则 1:排序是列存的第一推动力,不是可选的锦上添花¶
为什么:排序使同值数据聚拢,直接放大了 RLE 的效果;排序使 min/max 过滤有效;排序使 merge join 无需额外排序。反例:如果 projection 的排序键与任何查询都不相关,列存只保留了列裁剪的收益,丢失了排序带来的所有其他加速。
原则 2:分段键应该是对查询最有价值的 JOIN/GROUP BY 键,而不是随便挑一个高基数列¶
为什么:分段决定了数据在哪台节点——如果分段键等于 JOIN 键,JOIN 可以完全本地执行;如果不等于,每次 JOIN 都需要跨网络 resegment。反例:用 order_id 分段但所有 JOIN 都用 user_id——JOIN 绕不过网络传输。
原则 3:压缩效率 = 排序 × 编码 × 通用压缩,三层叠加远大于单一手段¶
为什么:排序创造数据局部性 → 编码利用局部性做结构化压缩 → 通用压缩在编码结果上再进一步。三者缺一不可。反例:如果数据不排序就直接用 RLE(在 Vertica 中 Auto 编码会自动选择,但在设计中应主动考虑排序),RLE 几乎不会命中任何连续重复值。
原则 4:列存 I/O 节省的幅度 = 查询需要的列数 / 表的总列数¶
为什么:列存的主要 I/O 优势来自列裁剪——不读无关列。如果查询需要所有列(SELECT *),列存与行存的 I/O 量相同(甚至可能更多,因为列间需要重建)。反例:在宽表(100+ 列)上频繁执行 SELECT *——这是把列存当行存用。
原则 5:每个额外 projection 都是「查询加速」与「加载 / 存储成本」的 trade-off¶
为什么:多一个 projection = 多一份排序+压缩后的数据 = 加载时要多写一份 = 存储多占一份。Vertica 典型配置是 1 个 super projection + 0-3 个窄 projection,而不是无限增加。反例:每个查询模式都建一个专属 projection——结果是加载时间乘以 projection 数量,存储成本翻数倍。
原则 6:Append-Only 存储的根本设计目的是批量 I/O 而不是逐行写入¶
为什么:列存的排序+压缩是批量操作——创建一个 ROS container 需要排序大量数据后才写入。小批次加载(每次几百行)意味着排序开销超过写入本身,且创建大量小文件拖累 mergeout。反例:把 Vertica 当作 OLTP 数据库做每秒数千次单行 INSERT——ROS container 数很快逼近 1024 上限。
原则 7:统计信息是列存优化器的眼睛,过期 = 盲目飞行¶
为什么:列存的优化器需要做 projection 选择、JOIN 顺序、分段策略等一系列决策——这些全部依赖统计信息估算数据量和选择性。如果统计信息过期(NO STATISTICS),优化器可能选错 projection 甚至在单节点上执行分布式查询。反例:大量数据加载后从不运行 ANALYZE_STATISTICS,导致优化器用旧统计信息做决策。
7. 延伸阅读¶
Vault 内笔记(按推荐阅读顺序)¶
- The Vertica Analytic Database - CStore 7 Years Later —— 本文的主要理论来源。第 3 章(Data Model)对 projection、编码、分段、ROS/WOS 的论述最为关键;第 8 章(Performance Measurements)包含压缩实验的真实数据
- Analytic Database Design Choices —— Design Choices 论文提供了大量的「我们在哪里做错了」反思,§3 的 Design Mistakes 部分对理解 trade-off 边界极有价值
- Projection 优化最佳实践 —— 从原理到实操的桥梁,覆盖排序、分段、编码的最佳实践建议
- C-Store: A Column-oriented DBMS —— 列存的学术起源,§3 的 4 种编码方案是理解 Vertica 编码演变的基础
- ROS Pushback 故障排查 —— 附录型实操文档,8 种常见 ROS 容器问题的诊断和修复
- Tuple Mover 最佳实践完全指南 —— 理解 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) — 物化策略的完整分析
本文以 Vertica 为具体载体讨论 MPP 列存引擎的架构哲学。文中总结的设计原则(排序优先、分段对齐 JOIN 键、编码递进压缩、append-only 批量 I/O 等)适用于所有分析型列存 MPP 数据库,尽管具体实现机制各有不同。
扩展阅读¶
- Projection 优化最佳实践 — 列存物理设计实操
- Vertica 统计信息管理与查询性能 — 统计信息与优化器决策
- ROS Pushback 故障排查 — ROS container 上限管理
- Tuple Mover 最佳实践完全指南 — Mergeout 策略与运维