PostgreSQL 中的 MVCC(多版本并发控制)和 snapshot(快照),与之前介绍的锁一起,为事务提供了隔离性(I)保证。

它们算是老生常谈的话题了,相关资料非常多,可以先从 PG-JP book 看起。

简单说,MVCC 把目标数据沿时间维度拆成多个版本,而快照则决定了当前事务到底应该看到哪一个版本。如下图所示:

        ┌────────────────────────────── snapshot1
        │                                                 
        │                  ┌─────────── snapshot2
        ▼                  ▼                              
[---ver1---] [-ver2-] [----ver3----]    row versions

MVCC

MVCC 主要用来解决读写冲突。在实现上,PG 采用了:

  • 每次修改数据时,生成一个新版本的 tuple。
  • 在 tuple header 中用 xmin/xmax 记录事务 ID,从而界定该版本的“生存区间”。

除了记录事务 ID 之外,也可以像 DuckDB 一样用时间戳 / 全局时间线来实现多版本。

几种数据库系统中的实现如下:

数据库 实现方式 注释
PostgreSQL tuple: xmin/xmax,全量版本 + 带内存储,快照核心可概括为 xmin/xmax + 活跃 xid 列表 这里是简化描述;实际实现里还要考虑子事务、overflow 等细节,但核心仍然是不依赖全局时间戳
MySQL/InnoDB 聚簇数据 + undo log 版本链 与 PG 的思路有相似处,但物理组织方式明显不同,可参考InnoDB 原理
DuckDB tuple: ctime,增量 updateinfo,快照: stime 引入了全局时间戳,因此不需要活跃事务列表;可参考这篇文章

PG 在多版本实现上的最大特点是:全量版本复制 + 带内存储。这既带来了不少优点,也引出了一系列后续问题,后面的 discussion 会再提。

Snapshot

仅有多版本还不够,还需要事务快照来决定“当前应该看到哪个版本”。这正是快照隔离类机制的核心。对 PostgreSQL 来说,REPEATABLE READ 可以近似理解为基于事务级快照的一致性读;而更强的 SERIALIZABLE 则是在此基础上通过 SSI 等机制进一步避免序列化异常。

之前我写的一篇详细的介绍:gp中的快照(含gp的分布式快照)。

需要注意的是,除了常规的 MVCC 快照之外,PG 里还有不少其他快照类型,见 HeapTupleSatisfiesVisibility()

bool HeapTupleSatisfiesVisibility(HeapTuple htup, Snapshot snapshot, Buffer buffer)
{
    switch (snapshot->snapshot_type)
    {
        case SNAPSHOT_MVCC:
            return HeapTupleSatisfiesMVCC(htup, snapshot, buffer);
        case SNAPSHOT_SELF:
            return HeapTupleSatisfiesSelf(htup, snapshot, buffer);
        case SNAPSHOT_ANY:
            return HeapTupleSatisfiesAny(htup, snapshot, buffer);
        case SNAPSHOT_TOAST:
            return HeapTupleSatisfiesToast(htup, snapshot, buffer);
        case SNAPSHOT_DIRTY:
            return HeapTupleSatisfiesDirty(htup, snapshot, buffer);
        case SNAPSHOT_HISTORIC_MVCC:
            return HeapTupleSatisfiesHistoricMVCC(htup, snapshot, buffer);
        case SNAPSHOT_NON_VACUUMABLE:
            return HeapTupleSatisfiesNonVacuumable(htup, snapshot, buffer);
    }
...

在具体编码时,需要非常留意不同快照类型各自的适用场景。

Discussion

前面提到,PG 的 MVCC 实现方式很有特点,而很多针对 PG 性能的批评,根源也都在这里。相关讨论包括:

  • Andy Pavlo 的 The Part of PostgreSQL We Hate the Most

    its MVCC implementation is the worst among the other widely used relational DBMSs

  • 字节’s 那些我很希望 Postgres 有,但 MySQL 已经有的功能

    Postgres 基于堆的存储结构和元组版本的 MVCC 机制在写密集场景中会产生更多开销(写放大)。虽然 Postgres 通过 HOT 更新等特性有所改进,但在重写负载下仍会产生更多 WAL 流量,并需要更频繁的 VACUUM 操作。MySQL 则采用聚簇索引存储和撤销日志,这在写重负载下性能更好

这里再顺手提一下 PG 中事务实现的分工。事务需要满足 ACID,其中 C 更多是业务语义层面的要求;数据库系统实现时主要关注 A/I/D:

  • A,原子性:通过事务标识和事务状态来保证。
  • I,隔离性:通过 MVCC + 快照 + 锁来保证。
  • D,持久性:通过 xlog / WAL 来保证。

另外,PG 也支持二阶段事务(2PC)。GP 的分布式事务,就是在自己实现协调者逻辑之后,复用了 PG 作为参与者的一部分事务机制。

Questions:

  1. PG 中游标和 DDL 操作的隔离性有些特殊,具体特殊在哪里?

    看一下它们的快照

  2. PG 如何做高效的 online DDL 变更?

    开放性问题,目前也没有特别理想的方案