跳转至

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

作者:JiangChong | 发布时间:2026-06-05

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

关联文章

理解全文脉络

这篇文章从「一行交易数据应该怎么存」这个具体问题出发,逐层展示存储格式的五级演进(插入序 → 排序 → 列存 → 编码 → 压缩),然后在三个关键维度上解释为什么这套设计对分析型负载是高效的: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 列(symboldatevolume),只涉及约 0.01% 的行(一行股票在某一天的交易记录)。但如果数据按插入顺序(即行存)存储——每一行的所有列连续排列在磁盘上——那么回答这个查询需要把整张表(或者至少是包含 symboldate 列的索引所指向的所有磁盘页)全部读入内存。

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

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

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。实际收益还取决于数据分布和压缩,但数量级如此。

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_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×

回溯到原理:这个案例体现了 §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 覆盖的时间范围变窄)

修复

  1. 增大 Tuple Mover 资源池的 MEMORYSIZEPLANNEDCONCURRENCY,让 mergeout 更激进
  2. 合并加载批次:将每小时加载改为每 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

根因排查

  1. 执行计划显示绝大部分步骤在单节点上执行
  2. 两个参与 JOIN 的表的统计信息已过期(NO STATISTICS
  3. 其中一个表采用 UNSEGMENTED 方式分布(NODE ONLY,所有数据在一台节点)
  4. 另一个表虽已分段,但statis_date 分段而未按 JOIN 键分段

修复

  1. 修改问题表的 projection 为按 JOIN 关联字段 HASH(user_id_zk, user_id_fk) 分段
  2. 将过滤条件列和 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 内笔记(按推荐阅读顺序)

  1. The Vertica Analytic Database - CStore 7 Years Later —— 本文的主要理论来源。第 3 章(Data Model)对 projection、编码、分段、ROS/WOS 的论述最为关键;第 8 章(Performance Measurements)包含压缩实验的真实数据
  2. Analytic Database Design Choices —— Design Choices 论文提供了大量的「我们在哪里做错了」反思,§3 的 Design Mistakes 部分对理解 trade-off 边界极有价值
  3. Projection 优化最佳实践 —— 从原理到实操的桥梁,覆盖排序、分段、编码的最佳实践建议
  4. C-Store: A Column-oriented DBMS —— 列存的学术起源,§3 的 4 种编码方案是理解 Vertica 编码演变的基础
  5. ROS Pushback 故障排查 —— 附录型实操文档,8 种常见 ROS 容器问题的诊断和修复
  6. 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 数据库,尽管具体实现机制各有不同。

扩展阅读