跳转至

MPP 事务模型与 MVCC 实现 —— Epoch-based 事务模型的设计哲学:为什么分布式 OLAP 选择逻辑时钟而非行级锁

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

适用场景: 当你需要理解为什么 MPP 数据库的默认隔离级别是 READ COMMITTED 而非 SERIALIZABLE、为什么 epoch 推进卡住会导致集群不可写入、为什么 Vertica 不需要 WAL 日志也能做增量恢复——也就是当你需要理解分布式分析型数据库的「时间」如何定义、「一致性」如何保证时。

开篇声明: 本文以 Vertica 为主要剖析对象,但在所有环节对比其他 MPP 系统的不同实现。「MPP 共性」与「Vertica 专属」将在文中明确区分。

关联文章:

理解全文脉络: 第 1 节从「一个读请求和一个写请求同时到达 MPP 集群」出发,定义分布式事务隔离的核心挑战。第 2 节是全文重心,按五层拆解 epoch 事务模型的全部机制——从逻辑时钟到 Snapshot Isolation 到 GC 边界到提交协议,每层用对比表呈现不同系统的选择。第 3 节剖析为什么 Vertica 选择 epoch 而非传统方案——四种可选方案的完整 trade-off 对比。如果你只关心设计原则,直接跳到第 6 节。


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

1.1 分布式事务隔离:MPP 的核心挑战

假设你有一个 32 节点的 MPP 集群,节点 1 正在执行一个扫描 500 亿行交易表的报表查询(预计运行 5 分钟),节点 3 同时提交了一个 DELETE FROM trades WHERE trade_date < '2025-01-01'。下面三个问题,任何 MPP 事务模型都必须回答:

问题一:读请求应该看到什么? 报表查询已经运行了 2 分钟,它已经扫描了节点 1 和节点 2 上的数据。此时节点 3 上的 DELETE 提交了。那么当报表查询扫描到节点 3 时——它应该看到被删除的行还是不应该?如果不同节点在不同时刻对「哪些数据存在」有不一致的认知,查询结果就是错的。

问题二:写请求如何保证原子性? DELETE 需要在所有 32 个节点上生效。如果在第 29 个节点上执行时,那个节点宕机了——已经生效的 28 个节点应该回滚吗?谁来协调这个回滚?如果协调者本身也宕机了呢?

问题三:同时到达的读写如何互不阻塞? 如果 DELETE 需要阻止报表查询读到不一致的数据,最直接的方式是加一个全局写锁——但这意味着 5 分钟的报表查询会阻塞所有 DELETE 长达 5 分钟,或者反过来,DELETE 会阻塞所有读操作。在 OLTP 中这也许可以接受(事务毫秒级),在 OLAP 中这是不可接受的(查询可能运行数十分钟)。

这三个问题构成了分布式 MPP 事务模型的核心挑战:在 N 个独立节点上,不用全局锁,如何让所有节点对「此刻哪些数据存在」达成一致认知?

1.2 单机数据库的标准答案及其在 MPP 中的失效

单机数据库(如 PostgreSQL)用一套成熟的方案解决了这个问题:

  • 行级锁 + MVCC:MVCC(Multi-Version Concurrency Control,多版本并发控制)的核心思想是——数据被修改时不原地覆盖,而是创建新版本,保留旧版本。每行携带 xmin(创建事务ID)和 xmax(删除事务ID),读操作根据自己的快照(Snapshot)判断每行的哪个版本可见,永远不需要等待写锁。
  • WAL(Write-Ahead Log):所有变更先写入日志,崩溃后从日志恢复。
  • Snapshot Isolation:每个事务在开始时获取一个快照——一个「此刻所有已提交事务的 ID 列表」——据此判断每行的可见性。

这套方案在单机上运行了三十年,非常成熟。但直接搬到 MPP 中会面临三个结构性障碍:

  1. 事务 ID 分配是瓶颈:单机用自增计数器分配 XID。32 个节点各自分配 XID 会导致 XID 冲突,而集中分配需要一个全局计数器——每个事务都要跨网络获取编号,这在每秒钟数千次加载的 OLAP 场景中完全不可行。
  2. 快照协调是瓶颈:单机的 Snapshot 是内存中的一个数据结构。在 MPP 中,如果节点 1 的快照包含事务 {1,2,3} 而节点 2 的快照包含事务 {1,2,4},它们对同一行数据的可见性判断就会不一致。要保证所有节点的快照一致,需要在每次快照创建时做全局协调。
  3. WAL 的应用是瓶颈:如果每个节点都写自己的 WAL,恢复时需要合并所有节点的 WAL 来确定全局一致状态——这在 32 个节点上是 NP-hard 的。

每个 MPP 系统都必须选择:在一致性、性能和复杂度之间如何取舍。 下表总结了它们的答案:

系统 逻辑时钟 默认隔离级别 快照协调方式 恢复日志
Vertica Epoch(全局统一) READ COMMITTED 不需要——epoch 全局一致 不需要——epoch + 数据文件代替 WAL
Greenplum 事务 ID(继承 PG) READ COMMITTED GTM 集中协调 WAL(每个 segment 独立)
ClickHouse 无全局时钟 无 ACID 事务 不适用 不适用
Doris/StarRocks Tablet 版本号 无跨表 ACID 不适用 不适用
Snowflake 文件时间戳 READ COMMITTED 不需要——文件原子替换 不需要——云存储自带持久性
Redshift 事务 ID(继承 PG) SERIALIZABLE(默认!) 集中式快照管理 WAL

本文选取 Vertica(epoch 派)、Greenplum(XID 派)、Snowflake(文件替换派)作为深度对比对象——它们代表了三种截然不同的分布式事务哲学。

1.3 具体场景:一次 DELETE 在各系统中发生了什么

回到开篇的场景——500 亿行交易表,DELETE 30 天前的 16 亿行数据:

  • 传统单机 OLTP:逐行加 X 锁 → 标记 xmax → 写 WAL → 提交。物理删除在 VACUUM 时完成。读操作通过 Snapshot 自动跳过 xmax 已标记的行。
  • Vertica:创建 Delete Vector 文件(外置标记)→ 广播 COMMIT → epoch 推进。读操作在 Latest Epoch 读取,自动跳过有 DV 标记的位置。物理清除在 Tuple Mover Mergeout 时完成。
  • Greenplum:与单机 PG 类似,但在每个 segment 上独立执行。DELETE 产生的死元组需要跨所有 segment 的 VACUUM 来清理。
  • Snowflake:重写受影响的 micro-partition 文件 → 原子替换 → 旧文件进入 Time Travel 窗口 → 过期后云存储自动 GC。

核心洞察: DELETE 本身不是重点——重点是这四种系统用完全不同的「时间」概念来实现 MVCC。Vertica 用 epoch(全局逻辑时钟),Greenplum 用 XID(事务标识符),Snowflake 用文件时间戳(物理时间),ClickHouse 干脆不要 MVCC。理解「为什么 Vertica 选择了 epoch」是理解 Vertica 整个事务模型的关键。


2. 核心概念与机制

本节按「MPP 共性模式 → 各系统实现差异」的五层结构组织。层次一到三是 MVCC 的经典三层(标记删除 / 可见性控制 / 物理回收),层次四讨论分布式环境下 Snapshot Isolation 的挑战与实现,层次五讨论分布式提交协议。

2.1 层次一:标记删除的方式

共性模式: 所有列存 MPP 都不会原地删除数据。当用户执行 DELETE 时,系统不是移除数据,而是创建某种「删除标记」,查询引擎在看到这些标记时跳过对应的行。

系统 标记存储位置 标记粒度 标记对查询的开销
Vertica Delete Vector(独立文件,外置) 行位置(ROS 容器内 position) 需额外读取 DV 文件并做位置过滤
Greenplum 页内 xmax 标记(tuple header) 行级 每次扫描检查 xmax 可见性
ClickHouse mutation 文件或 _row_exists 行级 类似额外 WHERE 条件
Doris/StarRocks Delete Bitmap 行位置 位图过滤,开销小
Snowflake 不存在——直接重写 micro-partition 文件级 查询计划自动排除旧文件

以 Vertica 为例:

在 Vertica 中,当执行一条 DELETE FROM trades WHERE trade_date < '2025-01-01' 时:

  1. Vertica 不在数据文件中修改任何内容。
  2. 它创建一个 Delete Vector (DV)——一个记录「在 ROS 容器 X 中,位置 Y 的行已被删除」的独立文件。
  3. DV 直接写入磁盘上的 DVROS(Delete Vector ROS)文件。早期版本(10.0 之前)DV 先写内存 DVWOS 再经 Tuple Mover moveout 落盘,WOS 移除后 DV 直接写入 ROS。
  4. 每次查询扫描 ROS 容器时,查询引擎会同时读取该容器对应的所有 DV,过滤掉被标记的位置。

Delete Vector 是 Vertica 独有的外置设计。Greenplum 的 xmax 标记嵌入在 tuple header 中(页内),Snowflake 根本不需要 Delete Vector(文件替换隐去旧数据),ClickHouse 的 mutation 是异步重写。

比喻: Delete Vector 就像图书馆里的一本「已下架书目」。书本身还在书架上(数据文件未变),但查询系统会先查这本书目,如果某本书被标记为下架,就不会把它拿给读者。对比 Greenplum——它相当于在每本书的封面上盖一个「已下架」的章,好处是不用额外查书目,坏处是每次翻到这本书都要看一眼封面上的章。


2.2 层次二:逻辑时钟与可见性控制

共性模式: 删除标记需要「时间戳」来回答一个问题——在某个时刻,这行数据是被删除了还是仍然存在?MVCC 的本质就是为每一行数据和每一个删除标记赋予时间戳,查询根据自己的时间戳判断哪些行可见。

不同系统选择了不同的时间戳方案:

系统 时间戳类型 粒度 全局统一? GC 边界
Vertica Epoch(64位逻辑时钟) 一组事务共享一个 epoch ✅ 全局统一 AHM
Greenplum 事务 ID (XID,32位) 每个事务独立 ❌ 逐 segment 分配 xmin horizon
Snowflake 文件时间戳(物理时间) 文件级 ✅ S3 元数据天然一致 Time Travel 窗口
ClickHouse/Doris 无全局时间戳 不适用 不适用 不适用

2.2.1 Vertica 的五层 Epoch 体系

Vertica 的核心创新是用一个全局统一的逻辑时钟(epoch)代替了事务 ID。这不仅回答了「可见性」问题,还顺带解决了 GC 边界和故障恢复两个难题。理解 epoch 体系需要区分五个概念:

Epoch Map:  [AHM] ... [LGE] ... [CPE_min] ... [LE] [CE(open)]
              ↑        ↑          ↑            ↑      ↑
           最旧可查  全节点一致  节点内最小CPE    最新   当前写入中

具体定义(按时间从新到旧排列):

① Current Epoch (CE) — 当前正在写入的 epoch。每次 DML COMMIT 自动推进。它是未关闭的——正在向其中写入数据的事务尚未完成。

② Latest Epoch (LE) — 最新已关闭的 epoch。默认 READ COMMITTED 隔离级别下,每条 SELECT 语句读取执行时刻的 LE 数据。这意味着:每条语句读到的是一个一致的快照(该时刻所有已提交的数据)——但同一事务或同一会话内,后一条 SELECT 可能读到前一条 SELECT 之后其他事务提交的数据(因为 epoch 已推进),这就是不可重复读(详见 §2.3.4 示例)。

-- INSERT 前 CE=277, 插入后 COMMIT 使 CE 推进到 278
SELECT CURRENT_EPOCH, AHM_EPOCH, LAST_GOOD_EPOCH FROM SYSTEM;
CURRENT_EPOCH | AHM_EPOCH | LAST_GOOD_EPOCH
--------------+-----------+----------------
          278 |       240 |             277

③ Checkpoint Epoch — 每个节点的 checkpoint epoch 是该节点上所有 projection 数据一致的最新 epoch(Enterprise Mode 专属概念)。官方文档未使用「CPE」缩写——本文为行文便利使用 CPE 作为简称。Checkpoint epoch 的存在是因为早期 Vertica(9.x 及之前)有 WOS(内存写入缓冲)——节点宕机时 WOS 中未 moveout 的数据会丢失。 WOS 已于 10.0 移除,但 Checkpoint epoch / LGE 体系保留至今。Tuple Mover mergeout 推进 Checkpoint epoch,它标记了每个节点内部 projection 数据一致的安全线。

④ Last Good Epoch (LGE) — 官方定义:「所有节点中最小的 checkpoint epoch,在该 epoch 数据在所有节点之间一致」(The minimum checkpoint epoch in which data is consistent across all nodes)。即:

LGE = min(CPE of projection_i on node_j), for all i, j

这是故障恢复的安全线——如果集群崩溃,只能恢复到 LGE。LGE 的定义是最小 CPE:所有节点上所有 projection 的磁盘数据都完整覆盖到 LGE,之后的数据可能尚未在所有节点上完成 ROS 写入(来源:C-Store 7 Years §5.1)。早期版本中这主要因为 WOS 中的数据未落盘;WOS 移除后,LGE 推进速度取决于 Tuple Mover mergeout 完成速度。

⑤ Ancient History Mark (AHM) — MVCC 的垃圾回收边界。AHM 之前的 epoch 中的被删除数据可以物理清除。AHM 永远不会超过 LGE——因为只有 LGE 保证全节点完整覆盖的数据,才可能安全地丢弃更早的历史。

SELECT GET_CURRENT_EPOCH(), GET_AHM_EPOCH(), GET_LAST_GOOD_EPOCH();
  GET_CURRENT_EPOCH | GET_AHM_EPOCH | GET_LAST_GOOD_EPOCH
 -------------------+---------------+---------------------
                278 |           240 |                 277

-- CPE 按 projection 查看
SELECT node_name, projection_name, checkpoint_epoch, is_up_to_date
FROM projection_checkpoint_epochs LIMIT 3;
        node_name       |        projection_name         | checkpoint_epoch | is_up_to_date
 -----------------------+--------------------------------+------------------+---------------
  v_vmart3_node0001     | date_dimension_DBD_6_rep_first |              277 | t

五层 epoch 的分工总结:

层次 作用 类比
CE 写入中的时间戳 收银台的当前流水号
LE 查询可见的数据版本 已打印的小票流水号
CPE 单个 projection 的磁盘安全线 某个货架的盘点截止号
LGE 全集群一致的安全恢复点 全店所有货架最低的盘点号
AHM 历史数据可丢弃的边界 超过保存期的票据可销毁的截止号

2.2.2 Epoch 推进的两种模式

来源(C-Store 7 Years §5.1)描述了 epoch 推进机制的演进:

早期模式(C-Store 原型): epoch 按时间窗口推进——例如每 30 秒关闭当前 epoch 并开启新 epoch。在这个窗口内提交的所有事务共享同一个 epoch。问题:用户困惑于「为什么我的 COMMIT 已经执行了,数据却要等 30 秒才能被其他查询看到」。

当前模式(Vertica 生产版): 自动推进——每次 DML COMMIT 时自动关闭当前 epoch 并开启新 epoch。用户执行 INSERT ... COMMIT 后,数据在下一个查询中就能看到(因为新 epoch 已成为 LE)。这不仅消除了用户困惑,还简化了 Tuple Mover 等内部管理流程。

ADVANCE_EPOCH(n) 函数是历史遗留——向后兼容的手动 epoch 推进方式,在生产环境中不应常规使用。

2.2.3 LGE = min(CPE) 的深层含义

这个公式看似简单,但有几个重要的推论:

推论一:一个 projection 的 CPE 被拖低,就能拖低整个节点的 LGE。 某运营商案例(§5.2)中,一个 projection 的 ROS 文件损坏导致 CPE 降至 18,382,其余所有 projection 的 CPE 在 5,470,370,LGE 直接跌至最小值。

推论二:Tuple Mover 的 mergeout 停滞会导致 LGE 停止推进。 如果 Tuple Mover 无法完成 mergeout(例如因为 ROS Pushback),新数据无法整合进 ROS 容器,对应 projection 的 CPE 就不会更新,进而拖住 LGE。WOS 移除后,moveout 概念已消失,LGE 推进现在依赖 mergeout。

推论三:在 Eon 模式中 LGE 不存在。 官方文档注明 Checkpoint epoch 为 Enterprise Mode 专属。LGE 的定义是「所有节点中最小的 Checkpoint epoch」——既然 Checkpoint epoch 在 Eon Mode 中不存在,LGE 自然也不存在。Vault 已有文章 Vertica Eon 模式数据库的备份 明确写道「在 EON 模式下,没有 LGE」,Eon 数据库恢复基于 catalog 同步点(sync_catalog() 产生的版本号)而非 epoch。


2.3 层次三:Snapshot Isolation 在分布式环境下的实现挑战

这是全文最重要的理论层次。前两层(标记删除、逻辑时钟)是 MVCC 的基础设施,Snapshot Isolation 才是 MVCC 的「正确性合约」——它定义了事务应该看到什么、不应该看到什么。

2.3.1 什么是 Snapshot Isolation

Snapshot Isolation (SI) 是 ANSI SQL 标准之外、但在现代数据库中广泛实现的隔离级别。它的核心保证是:

  • 一个事务看到的数据,是事务开始时已提交的所有数据的一个一致性快照。
  • 如果两个并发事务修改了同一行,后提交的会被 abort(First-Committer-Wins)。

SI 比 READ COMMITTED 强(不会出现不可重复读),比 SERIALIZABLE 弱(可能出现 Write Skew 异常)。PostgreSQL 的默认隔离级别实际上是 SI(虽然叫 READ COMMITTED 的那个级别实际上是 SI 的子集,而它叫 SERIALIZABLE 的级别实现了真正的可串行化)。

2.3.2 SI 在单机上的实现(PostgreSQL 的做法)

在 PostgreSQL 中,SI 依赖两个数据结构:

  1. 事务 ID (XID):32 位自增,每个事务分配一个。每行数据携带 xmin(创建它的 XID)和 xmax(删除它的 XID)。
  2. 快照 (Snapshot):一个事务开始时,记录「此刻所有活跃事务的 XID 列表」。判断一行是否可见:检查 xmin 是否在快照的活跃列表中——如果是,说明创建者尚未提交,不可见。

关键点:单机上快照的获取是 O(1) 的——读取当前全局 XID 计数器和活跃事务列表即可。不需要跨网络协调。

2.3.3 为什么单机 SI 无法直接搬到 MPP

把单机 SI 搬到 32 节点的 MPP 中,会遇到以下问题:

问题一:XID 分配的全局瓶颈。 如果所有节点从同一个计数器获取 XID,每次事务开始都需要跨网络获取一个编号——这就是一个全局串行化点。Greenplum 的选择是每个 segment 本地分配 XID,然后用 GTM (Global Transaction Manager) 做全局协调。GTM 本身就是一个集中的瓶颈——在高并发加载场景下,GTM 可能成为性能天花板。

问题二:快照一致性的全局协调。 节点 1 的事务 A 在时刻 t1 获取快照,节点 2 的事务 B 在时刻 t2 获取快照。如果 t1 和 t2 之间有事务 C 在节点 3 提交了——A 和 B 看到的快照就不一致。要保证所有节点在同一逻辑时刻获取的快照完全一致,需要全局协调。Greenplum 用 GTM 来解决这个问题(它是单点的),但代价就是 GTM 的吞吐决定了整个集群的事务吞吐。

问题三:分布式死锁检测。 事务 A 在节点 1 锁了行 R1 请求行 R2,事务 B 在节点 2 锁了行 R2 请求行 R1——跨节点的死锁检测需要全局等锁图,这在 32 个节点上是 O(N²) 的。

2.3.4 Vertica 的答案:用 Epoch 绕过 Snapshot 协调

Vertica 对上述问题的回答是激进的——它选择了一个不需要快照的可见性模型。

核心逻辑只有两条(来源:C-Store 7 Years §5):

  1. 所有数据行携带 epoch(不是 XID)。 一个 epoch 可以包含多个事务的提交。epoch 是全局统一的——不存在节点 1 和节点 2 对 epoch 278 有不同理解。
  2. SELECT 查询始终读取 Latest Epoch。 在 READ COMMITTED 隔离级别下,查询不需要快照——它只需要读到 LE 的数据。LE 是全局确定的,不需要查询开始时做任何跨节点协调。

这带来了什么?

  • 零快照协调开销。 因为 epoch 是全局统一的,所有节点对「LE 是 277」有完全一致的认知——不需要在查询开始时做快照同步。
  • 读取不需要锁。 数据文件是「读取引用计数」的——查询开始时 pin 住它需要的 ROS 容器文件,确保这些文件在查询期间不会被 Tuple Mover 删除。不需要检查行级锁状态。
  • 代价是隔离级别降级。 READ COMMITTED 不是一个快照隔离级别——同一个事务内的两条 SELECT 可能看到不同的数据(如果在两次 SELECT 之间有其他事务提交并推进了 epoch)。
-- Vertica READ COMMITTED 的行为示意:
 事务 A: SELECT COUNT(*) FROM big_table;  -- 读到 epoch 277 的数据
 事务 B: INSERT INTO big_table ...; COMMIT; -- CE 推进到 278
 事务 A: SELECT COUNT(*) FROM big_table;  -- 读到 epoch 278 的数据(包含事务 B 的插入!)
 解释:事务 A 的两次 SELECT 之间,epoch  277 推进到 278
 第二次 SELECT 自动读取 LE=278,所以看到了事务 B 的插入
 这不是 bug——这是 READ COMMITTED 的预期行为

2.3.5 Vertica 如何实现 SERIALIZABLE

如果需要更强的隔离保证,Vertica 支持 SERIALIZABLE 隔离级别。与 READ COMMITTED 的「零锁读」不同,SERIALIZABLE 下的 SELECT 会获取表级 Shared (S) 锁——v26.2 官方文档原文:「Use a shared (S) lock for SELECT queries that run at the serialized transaction isolation level」,「Select operations in READ COMMITTED transaction mode do not require S table locks」。C-Store 7 Years 论文 §5 进一步说明该锁「prevents concurrent modification of the table」。根据官方锁兼容矩阵,S 锁不仅阻塞 UPDATE/DELETE(X 锁),也阻塞 INSERT(I 锁)——任何写入操作都被阻止。

-- SERIALIZABLE 需要显式声明(设置后该会话的所有后续语句均以此级别运行)
SET SESSION CHARACTERISTICS AS TRANSACTION ISOLATION LEVEL SERIALIZABLE;
SELECT COUNT(*) FROM big_table WHERE trade_date = '2025-01-01';
-- 此事务期间,其他事务不能修改 big_table(表级 S 锁,来源:C-Store 7 Years §5)

这意味着:如果需要可重复读或防止幻读,Vertica 回到传统的表级锁模型——与 epoch 的「无锁读」设计背道而驰。官方文档也明确建议 SERIALIZABLE「is not recommended for normal query operations」。

2.3.6 跨系统对比:四种 SI 实现策略

系统 默认隔离级别 实现方式 读取是否需要锁 分布式快照协调
Vertica READ COMMITTED Epoch(全局逻辑时钟),无快照 不需要 不需要——epoch 全局统一
Greenplum READ COMMITTED XID + Snapshot + GTM 不需要(SI下) GTM 集中协调
PostgreSQL READ COMMITTED(≈SI) XID + Snapshot 不需要 不适用(单机)
Snowflake READ COMMITTED 文件时间戳 不需要 不需要——S3 元数据
Redshift SERIALIZABLE** XID + 集中快照管理 需要(S锁) 集中式

Redshift 默认 SERIALIZABLE 是一个值得注意的反例——它选择了更严格的一致性保证,代价是更高的锁争用风险。Redshift 的 XID 模型继承自 PostgreSQL,但 SERIALIZABLE 默认值是 Redshift 自身的设计选择(PostgreSQL 默认为 READ COMMITTED),而非为 OLAP 原生优化。


2.4 层次四:物理回收的触发方式

共性模式: 删除标记积累到一定程度就必须物理回收,否则查询每次都要过滤大量已被删除的行。所有列存 MPP 都在后台重组数据文件时清理已删除的数据。

系统 回收机制 触发方式 是否复用合并I/O 运维负担
Vertica Tuple Mover Mergeout 自动(按 strata 策略) 是(合并与清除同时完成) 中(需关注 AHM 推进和 ROS Pushback)
Greenplum VACUUM 需手动或定时调度 否(独立全表扫描) 高(VACUUM 耗时且阻塞写入)
ClickHouse 后台 Compaction 全自动
Doris/StarRocks 后台 Compaction 全自动
Snowflake 云存储 GC 全自动(文件过期后清理) 不需要(文件直接替换)

以 Vertica 为例: Tuple Mover 的 Mergeout 在合并多个小 ROS 容器时,过滤掉 AHM 之前被删除的行——数据和删除标记的清除共享同一次 I/O。这避免了 Greenplum 的 VACUUM 那种独立的垃圾回收扫描。代价是:如果 ROS 容器一直不触发合并(因为已经很大),即使里面有很多已删除数据也不会被清除——需要手动 PURGE_TABLE()


2.5 层次五:分布式提交协议

共性模式: MPP 的 COMMIT 必须在 N 个节点上同时生效。所有系统都需要在「提交协议的开销」和「节点故障时的一致性保证」之间做取舍。

系统 提交协议 协调机制 CommitTimeout 行为 2PC?
Vertica 广播提交 + 法定人数 Spread 广播 1800s 后 PANIC 集群
Greenplum 2PC (PREPARE + COMMIT PREPARED) GTM / Coordinator 事务 abort,不 PANIC
Snowflake 文件原子替换 S3 PUT 原子性 不适用 否(存储层提供原子性)

以 Vertica 为例(C-Store 7 Years §5):

  1. 事务在本地节点构建 ROS 容器。
  2. COMMIT 时,发起节点通过 Spread 广播协议向全集群发送提交消息。
  3. 每个节点收到后,将新数据附加到本地 catalog。
  4. 没有 ACK 回复——这是广播而非协调。 法定人数(>N/2)成功即为提交。
  5. 未收到消息的节点被踢出集群,之后通过 epoch 增量恢复追上。

关键设计权衡: 2PC(Two-Phase Commit,两阶段提交)是分布式事务的经典协议——协调者先向所有参与者发送「准备」请求(Phase 1),全部回复「准备好」后再发送「提交」请求(Phase 2),任何参与者失败则全体回滚。Vertica 不用 2PC,而是用「提交成功或节点被踢出」的二元逻辑替代了 2PC 的「全部确认或全部回滚」。好处是提交的延迟就是一次广播的延迟(不是多轮往返),代价是节点故障的容忍度更低——单个节点的硬件故障可能导致集群级 PANIC(见 §5.2 某运营商案例)。

比喻: Vertica 的广播提交像会议通知——主持人同时通知所有人,如果有人没收到就当他已离场。Greenplum 的 2PC 像逐个确认——主持人先问每人「准备好了吗」,得到所有肯定答复后才宣布开始——更安全但更慢。


3. 设计决策与 Trade-off

3.1 核心选择:为什么是 Epoch-based MVCC?

这是全文最核心的设计决策分析。面对分布式事务隔离的三大挑战(XID 分配瓶颈、快照协调开销、WAL 恢复复杂性),有四种可选方案:

方案 A:传统行级锁 + 2PC(OLTP 方案)

每个 UPDATE/DELETE 在目标行上加排他锁,读取需检查锁状态。分布式用 2PC 保证原子性。

  • 收益:最严格的一致性、SERIALIZABLE 自然支持
  • 代价:分析型查询扫描数十亿行,逐行检查锁状态不可接受。2PC 每次 COMMIT 至少 3 轮网络往返——加载吞吐被网络延迟吃掉
  • 适用场景:传统 OLTP 数据库(如早期 MySQL/InnoDB 的行锁模式)。Greenplum 虽然也用 2PC 做分布式提交,但其并发控制是方案 B 的 MVCC 模型

方案 B:MVCC + 事务 ID(Greenplum / PostgreSQL 方案)

每行携带 xmin/xmax 事务 ID,查询根据快照判断可见性。不需要读锁,但写入仍需排他锁。

  • 收益:读不阻塞写,写不阻塞读
  • 代价:XID 需要全局分配(GTM 是瓶颈),VACUUM 需要独立全表扫描回收死元组,分布式快照一致性需要 GTM 集中协调
  • 适用场景:需要强 ACID 保证的 MPP 场景

方案 C:Epoch-based MVCC(Vertica 的选择)

全局逻辑时钟代替事务 ID。每个 epoch 包含多个事务。行只记录 epoch 而非 XID。

  • 收益
    • 读操作不需要任何锁(epoch 全局统一,所有节点一致认知)
    • 不需要分布式快照协调——epoch 本身就是全局快照
    • 不需要 WAL——epoch + 数据文件本身就是恢复日志
    • GC 简洁——AHM 一条线,之前的全清
  • 代价
    • 隔离级别降级——默认 READ COMMITTED,SI 需要表级锁
    • epoch 推进本身需要 GCL X 锁(全局串行化点)
    • 有 DOWN 节点时 AHM 不推进(空间回收受阻)
    • CommitTimeout 机制意味着极端情况下集群 PANIC
  • 适用场景:分析型 MPP(Vertica 的目标场景)

方案 D:文件级 MVCC(Snowflake 方案)

不标记单行变更。用新 micro-partition 文件整体替换旧文件。

  • 收益:极简——没有 Delete Vector、没有 VACUUM、没有 epoch。S3 PUT 就是提交
  • 代价:一次 UPDATE 哪怕只改一行也要重写整个 micro-partition。高频小更新场景不适合
  • 适用场景:云原生 OLAP(Snowflake 的定位)

3.2 Vertica 选择的收益与代价值表

维度 收益 代价
读性能 SELECT 不需要任何锁,pin 文件即可保证一致快照 两条 SELECT 之间若有其他事务提交推进了 epoch,后一条读到不同结果(见 §2.3.4 示例)
写性能 多个 INSERT 可并行(I 锁互相兼容) DML COMMIT 需要 GCL X 锁,高并发小事务时是瓶颈
一致性与隔离 epoch 全局统一,所有节点一致认知 仅 READ COMMITTED 零开销;SERIALIZABLE 需要表级 S 锁,阻塞所有写入含 INSERT(来源:v26.2 lock-modes + C-Store 7 Years §5)
垃圾回收 AHM 一条线;Mergeout 复用合并 I/O AHM 不推进时delete的数据空间永不释放
故障恢复 epoch 增量恢复,不需要 WAL 有 DOWN 节点时 AHM 不推进;CPE 局部损坏可拖低全节点 LGE
历史查询 epoch 天然支持时间点查询 AHM 之前被删除的数据不可查(Delete Vector 已被物理清除,AT EPOCH 无法还原删除前的状态)

3.3 各系统 DELETE 全流程对比

阶段 Vertica Greenplum Snowflake
DELETE 执行 快(只写 DV) 较快(页内标记 xmax) 中(重写 micro-partition)
对查询的影响 额外读取 DV 文件 检查 xmax 可见性 零(查询计划排除旧文件)
空间回收触发 Tuple Mover Mergeout VACUUM(需调度) Time Travel 窗口过后自动 GC
空间回收 I/O 复用合并 I/O 独立全表扫描 零(S3 对象删除)
历史查询 AHM 之后支持 不支持 Time Travel 窗口内支持
DROP_PARTITION 速度 <1 秒(删文件) <1 秒(删文件) <1 秒(删 S3 对象)

揭示选择背后的架构哲学: Vertica 把代价后移到 Mergeout(共享合并 I/O),Greenplum 把代价集中在 VACUUM(独立扫描),Snowflake 把代价前移到 DELETE 本身(重写文件)但免除 GC 运维。不存在「最优方案」,只有「适合你的负载的方案」。


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

4.1 查询层面

自动生效的收益:

  • READ COMMITTED 下 SELECT 不需要锁。10 分钟的报表查询不会阻塞任何 DML,也不会被任何 DML 阻塞。
  • epoch 全局统一 → 任意节点对同一 epoch 的数据快照完全相同。

需要关注的限制:

  • 同一事务内的两次 SELECT 可能看到不同数据——如果中间有其他事务提交推进了 epoch。这是 READ COMMITTED 的预期行为,但经常让从 PostgreSQL 迁移过来的用户困惑。
  • DELETE 后查询可能变慢:如果表积累了超过 20% 的已删除行,全表扫描需要过滤大量 DV。

4.2 加载层面

自动生效的收益:

  • 多个 COPY/INSERT 可以并行——I 锁互相兼容。
  • 每次加载构建自己的 ROS 容器,提交时原子附加到 catalog——加载事务之间不需要等待。

需要关注的限制:

  • CommitTimeout 风险:如果 COMMIT 无法在 1800 秒内获取 GCL X 锁,集群 PANIC(见 §5.2)。
  • 大量小事务的性能:每次 DML COMMIT 都推进 epoch。如果每秒数千次小 INSERT,epoch 推进和 GCL X 锁成为瓶颈。

4.3 运维层面

必须监控的四个指标:

  1. AHM 是否在推进SELECT GET_AHM_EPOCH();——每天检查一次。卡住超过一天 → Delete Vector 堆积 → 查询变慢 → 恢复变慢。
  2. LGE 与 CE 的差距SELECT CURRENT_EPOCH - LAST_GOOD_EPOCH FROM SYSTEM;——差值持续增大 → Tuple Mover mergeout 跟不上 → 数据未整合进 ROS → 故障时回滚范围扩大。
  3. ROS PushbackSELECT * FROM tuple_mover_operations WHERE is_executing;——长时间有 mergeout 在执行 → ROS 容器数可能达上限。
  4. GCL X 锁等待SELECT * FROM locks WHERE object_name = 'Global Catalog';——如果有长时间未授予的 X 锁请求,说明某个节点可能正在出现问题。

常见误解与澄清:

误解 事实
DELETE 后磁盘空间应该立即释放 所有不可变存储的列存 MPP 都如此。DELETE 只是标记,空间在 Mergeout/VACUUM/Compaction 时释放。只有 DROP_PARTITION 和 Snowflake 的文件替换能立即释放
epoch 推进得越快越好 默认每次 DML COMMIT 自动推进 epoch。如果业务有大量小事务,频繁推进 epoch 导致 EPOCHS 系统表膨胀、GCL X 锁争用加剧。应批量提交
LGE 不推进没关系,反正我不关心恢复 AHM ≤ LGE。LGE 不推进 → AHM 最终也不推进 → Delete Vector 永远不清除 → 雪崩
Vertica 和 PostgreSQL 一样,默认隔离级别都是 READ COMMITTED,所以行为也一样 PostgreSQL 的 READ COMMITTED 实际上有 Snapshot 保证(每个语句看到一个一致的快照)。Vertica 的 READ COMMITTED 是「每条语句看到最新已提交数据」,连续两条 SELECT 可能读到不同 epoch 的数据。要获得类似 PG 的行为,需要用 SERIALIZABLE(表级锁)

5. 案例验证

5.1 虚构案例

📝 虚构案例:事务可见性

场景: 某电商的日终报表流程——事务 A 执行 SELECT COUNT(*), SUM(amount) FROM orders WHERE order_date = CURRENT_DATE,同时事务 B 正在加载当天的最后一批订单(COPY 200 万行)。

VERTICA READ COMMITTED 行为:

-- 事务 A 开始
-- 当前 epoch: LE=1000
SELECT COUNT(*), SUM(amount) FROM orders WHERE order_date = CURRENT_DATE;
-- COUNT: 8,200,000, SUM: 1,230,000,000  (epoch 1000 的数据)

-- 事务 B 提交 200 万行
INSERT INTO orders SELECT * FROM external_batch; COMMIT;
-- epoch 推进到 1001

-- 事务 A 继续
SELECT COUNT(*), SUM(amount) FROM orders WHERE order_date = CURRENT_DATE;
-- COUNT: 10,200,000, SUM: 1,530,000,000  (epoch 1001 的数据——包含了事务 B 的插入)

与 Greenplum 的对比:

Greenplum 在 READ COMMITTED 下,事务 A 的两次 SELECT 会看到不同的数据(与 Vertica 相同)。但 Greenplum 可以在 REPEATABLE READ 下保证同一事务内两次 SELECT 看到相同的快照——而 Vertica 只有 SERIALIZABLE(表级锁)才能实现这一点。

洞察: Vertica 的 epoch 模型为 READ COMMITTED 做了极限优化——这是分析型负载(报表、ETL、批量处理)最需要的隔离级别。代价是更严格的隔离需要回到传统的锁模型。

📝 虚构案例:跨系统模拟

同一场景在 4 个系统下的预估行为对比: 500 亿行表,DELETE 30 天前数据(16 亿行):

系统 DELETE 执行耗时 空间回收时间 期间查询性能影响
Vertica (DELETE) 仅写 Delete Vector,耗时取决于谓词扫描效率 数小时到数天(Mergeout后) 全表扫描显著变慢
Vertica (DROP_PARTITION) <1 秒 立即 无影响
Greenplum (DELETE+VACUUM) DELETE 较快 + VACUUM 数小时 VACUUM 完成后 VACUUM 期间 I/O 竞争
ClickHouse (mutation) 提交秒级,异步数小时 Compaction 后(数小时) mutation 完成前读多版本
Snowflake (DELETE) 重写受影响的 partition 文件 Time Travel 过后自动 查询计划排除旧文件,无额外开销

5.2 真实案例

📋 真实案例 · 来源:某运营商数据中心经分库宕库故障报告

场景: 某运营商 138 节点集群,企业模式,Vertica 11.1.1-20。下午 13:10,节点 2 因 RAID 卡 + 磁盘故障导致主机夯死。

故障现象: 30 分钟后(13:40),全部 138 节点 PANIC 宕机——2 小时完全不可用。

根因链:

  1. 节点 2 夯死时,有一个 TM_DIRECTLOAD 模式的 INSERT 正在等待 COMMIT。
  2. COMMIT 需要获取 Global Catalog X 锁,但节点 2 已无响应——锁无法释放。
  3. 其他节点的所有 DML 事务也卡在「等待 GCL X 锁」状态。
  4. 经过 1800 秒(CommitTimeout 默认值),触发 PANIC:「Failure to acquire global catalog X lock for commit」。

设计原理剖析: CommitTimeout 是 Vertica 广播提交协议的安全阀——如果 30 分钟内锁不能释放,说明某节点已不可恢复,与其让集群「部分可用但 DML 全部阻塞」,不如 PANIC 后通过 epoch 恢复机制回到一致状态(C-Store 7 Years §5.3)。

跨系统启示: Greenplum 的 2PC 超时会 abort 事务而非 PANIC 集群。Snowflake 不存在此问题——提交只涉及 S3 文件操作。此风险是 Vertica 专属的,源于其广播提交 + 法定人数模型。

📋 真实案例 · 来源:某运营商 Vertica 数据库宕机恢复处理报告

场景: 某运营商 70+1 节点集群,Vertica 9.3.1-18。控制节点 v044 硬件故障导致全部数据丢失,触发 UNSAFE 宕机。

故障现象: 恢复时,节点 v013 和 v050 的 LGE 仅 18,403,其余节点 LGE 为 5,470,370——差距 540 万 epoch。回滚到 18,403 将丢失几乎所有数据。

根因: 节点 v013/v050 上某 projection 的 ROS 文件损坏(缺少 .gt 文件),导致 CPE 降至 18,382。LGE = min(CPE) 将这个值放大到整个节点的恢复点。

设计原理剖析: LGE 公式在正常运行时保证了全集群恢复的一致性,但代价是一个 projection 的局部文件损坏能拖低整个节点的恢复点。修复用了 12.5 小时(UNSAFE 模式标记该 projection 不恢复)。

跨系统启示: Greenplum 的恢复依赖 WAL 而非 epoch,单个 segment 的文件损坏不影响其他 segment 的恢复点。此问题是 Vertica 专属的——epoch 把恢复状态绑定到了每个 projection 的物理完整性上。


6. 设计原则总结

【通用】原则 1:选择事务模型时,先定义你的「主要负载」是什么

为什么: Vertica 选择 epoch + READ COMMITTED 是为分析型负载优化的——读多写少、批量加载、查询运行数分钟。OLTP 的「读已提交」和 OLAP 的「读已提交」实现完全不同。不要假定两个系统用同一个隔离级别名称就意味着行为一致。

反例: 把 Vertica 用于 OLTP 式的小事务高频提交场景 → 每次 COMMIT 推进 epoch + GCL X 锁争用 → 事务吞吐被锁瓶颈压制。

【通用】原则 2:理解你的系统把「事务一致性」的代价付在了哪里

为什么: Vertica 付在 epoch 推进的 GCL X 锁 + CommitTimeout 的 PANIC 风险;Greenplum 付在 GTM 的集中协调 + VACUUM 的 I/O;Snowflake 付在文件重写的延迟。

反例: 用 Vertica 做 SERIALIZABLE 高频事务而不知道表级 S 锁 → 事务排队 → 用户抱怨「数据库卡死」。

【通用】原则 3:DELETE 大量数据时优先用分区裁剪而非逐行删除

为什么: DROP_PARTITION 在物理层面删文件——零 Delete Vector、零 Dead Tuple、零 GC 开销。所有 MPP 系统的共识。

反例:DELETE 清理一个月分区数据 → 数亿 Delete Vector → 查询变慢 → Mergeout 压力增加 → ROS Pushback。

【通用】原则 4:在 SI 语义与性能之间做有意识的选择,而非被动接受默认值

为什么: Vertica 默认 READ COMMITTED(零开销,无快照一致读),Greenplum 默认 READ COMMITTED(SI 语义,有快照),Redshift 默认 SERIALIZABLE(最严格,有锁争用风险)。默认值反映设计哲学,但不一定适合你的负载。

反例: 从 PostgreSQL 迁移到 Vertica 的用户,期望同一事务内两次 SELECT 看到相同快照 → 发现第二次 SELECT 读到了别人刚提交的数据 → 以为数据库坏了 → 其实是 READ COMMITTED 的正常行为。

【Vertica】原则 5:监控 LGE 与 CE 的差距,它是你数据丢失风险的直接度量

为什么: CURRENT_EPOCH - LAST_GOOD_EPOCH 的差值代表「有多少已提交数据尚未被 Tuple Mover 整合进 ROS 容器,节点宕机时需要回滚」。WOS 已于 10.0 移除(官方文档 v26.2 确认),此后 LGE 推进依赖 mergeout 而非 moveout,但监控差值的方法不变。

反例: LGE 持续不推进 → Tuple Mover 故障未被发现 → 集群宕机时回滚到几小时前的 LGE → 丢失数小时的业务数据。

【Vertica】原则 6:有节点 DOWN 时,主动关注 AHM 是否被阻塞

为什么: 默认行为是有 DOWN 节点时 AHM 不推进。如果节点长时间无法恢复,AHM 长期卡住 → Delete Vector 堆积 → Mergeout 越来越慢 → 恢复越来越慢。

反例: 某节点宕机一周 → AHM 一周未推进 → Delete Vector 膨胀到数百 GB → 恢复该节点时需要 Replay 一周的 Delete → 恢复耗时从 2 小时变成 2 天。

【Vertica】原则 7:理解 Global Catalog X 锁是集群的单点串行化瓶颈

为什么: DML COMMIT、epoch 推进、节点恢复、Tuple Mover 某些操作都需要 GCL X 锁。任何节点持有该锁时夯死 → 集群级 PANIC。

反例: 某运营商案例——一个节点 RAID 卡故障 → GCL X 锁无法释放 → 1800 秒后 138 节点全部宕机。


7. 延伸阅读

文章 与本文关系 适用性
MPP 容错与高可用设计 K-Safety、epoch 增量恢复是本文 §2.5 的延伸 MPP 通用
Analytic Database Design Choices: Vertica's Experience and Perspectives §2.7 事务与恢复的早期设计思考,append-only 如何简化提交 MPP 通用
MPP 数据删除与存储回收机制 本文 §2.1 / §2.4 的 DELETE 链路深化 Vertica 专属
Vertica Epoch 机制详解 CE/LE/CPE/LGE/AHM 的完整操作手册 Vertica 专属
Vertica 锁和锁冲突 9 种锁类型的兼容矩阵与监控 SQL Vertica 专属
The Vertica Analytic Database: C-Store 7 Years Later §5 Epoch、锁矩阵、提交协议的第一手设计文档 Vertica 专属