术→技巧, 研发

PostgreSQL多版本并发控制MVCC

钱魏Way · · 42 次浏览

多版本并发控制(MVCC,Multi-Version Concurrency Control)是PostgreSQL中实现并发控制的一种机制。MVCC允许多个事务同时访问数据库,而不会产生锁定冲突。这种机制通过维护数据的多个版本,提供了一种高效的方法来处理并发事务。

为什么需要MVCC?

多版本并发控制(MVCC)的引入和使用主要是为了有效解决数据库系统中的并发控制问题。以下是MVCC的几个关键需求和优点:

  • 提高并发性能
    • 在传统的锁机制中,读写操作可能会相互阻塞,特别是在高并发环境下,这会显著降低系统性能。
    • MVCC通过允许读操作不加锁地访问数据,从而提高了系统的并发性能。写操作不会阻塞读操作,反之亦然。
  • 减少锁竞争
    • 传统锁机制中,多个事务同时访问同一数据时,会出现锁竞争问题。
    • MVCC通过版本化数据,避免了大多数情况下的锁竞争,尤其是读操作之间的竞争。
  • 实现事务隔离
    • MVCC支持多种事务隔离级别(如Read Committed、Repeatable Read、Serializable),通过维护数据的多个版本,确保事务在执行过程中可以看到一致的快照。
    • 这使得事务在读取数据时不会看到其他未提交事务的中间状态,保证了数据的一致性和隔离性。
  • 减少锁等待和死锁风险
    • 由于读操作不需要锁,MVCC有效地减少了锁等待的情况。
    • 这也降低了死锁发生的风险,因为读操作不会与其他事务争夺锁。
  • 支持长时间运行的查询
    • MVCC允许长时间运行的查询在获取快照后可以一致地访问数据,即使在查询期间其他事务对数据进行了修改。
    • 这对于分析型查询和报告生成非常有用,因为这些操作通常需要长时间读取大量数据。
  • 提高系统吞吐量
    • 通过减少锁争用和提高并发能力,MVCC能够提高数据库系统的整体吞吐量。
    • 这对需要处理大量并发请求的应用程序(如在线交易系统)特别重要。

总的来说,MVCC通过提供更细粒度的并发控制,解决了传统锁机制在高并发环境下的性能瓶颈和一致性问题,使得数据库系统能够更高效地处理并发事务。

MVCC的基本概念

多版本并发控制(MVCC)的基本概念主要围绕如何在数据库中管理和访问数据的多个版本。以下是MVCC的几个核心概念:

  • 数据版本化
    • 在MVCC中,每次对数据行进行更新时,数据库不会直接覆盖原有数据,而是创建一个新的版本。这意味着同一行的数据可能会有多个版本共存。
    • 每个数据版本都有两个重要的隐含字段:xmin和xmax。
      • xmin:创建该版本的事务ID,表示哪个事务创建了这个数据版本。
      • xmax:删除该版本的事务ID,表示哪个事务删除了这个数据版本(如果行未被删除,则为未定义)。
    • 事务ID(Transaction ID, XID)
      • 每个事务在开始时都会被分配一个唯一的事务ID,用于标识该事务。
      • 事务ID用于确定数据版本的可见性和管理事务的顺序。
    • 快照(Snapshot)
      • 每个事务在开始时获取一个数据库快照,这个快照包含了当时数据库中所有可见的数据版本。
      • 快照用于保证事务在其生命周期内看到的数据是一致的,即使其他事务正在并发地修改数据。
    • 可见性规则
      • 一个数据版本对一个事务可见的条件是:
        • 该版本的xmin小于当前事务的ID,意味着该版本在当前事务开始之前已存在。
        • 该版本的xmax要么未定义,要么大于当前事务的ID,意味着该版本在当前事务期间未被删除。
      • 隔离级别
        • MVCC支持多种事务隔离级别,如Read Committed、Repeatable Read、Serializable。每种隔离级别定义了不同的可见性规则,以平衡一致性和并发性。
        • 通过快照机制,MVCC在不使用锁的情况下实现了高效的事务隔离。
      • 并发读写
        • MVCC允许读操作不需要加锁即可访问数据,避免了与写操作的锁竞争。
        • 写操作通过创建新版本实现,只有在需要防止写-写冲突时才使用锁。

通过这些基本概念,MVCC在数据库中实现了高效的并发控制和一致性管理,使得多个事务可以同时读取和修改数据,而不会相互干扰。

MVCC的实现

多版本并发控制(Multi-Version Concurrency Control, MVCC)是一种通过冗余多份历史数据来达到并发读写目的的一种技术,在写入数据时,旧版本的历史数据将不会被删除,那么此时并发的读仍然能够读取到对应的历史数据,这样就使得读和写能够并发运行,并且不会出现数据不一致的问题。

事务 ID

多版本并发控制既然会保留一份数据的多个版本,那么就需要能够区分出哪个版本是最新的,哪个版本是最旧的。一个最朴素的想法就是给每一个版本添加一个时间戳,用时间戳来比较新旧,但是时间戳不稳定,万一有人修改了服务器的配置,事情就乱套了。因此,PostgreSQL 使用了一个 32 位无符号自增整数来作为事务标识以比较新旧程度。

我们可以通过 txid_current() 函数来获取当前事务的标识:

postgres=# select txid_current();
 txid_current 
--------------
          507
(1 row)

Tuple 结构

紧接着我们需要了解堆元组的组成结构,堆元组由 HeapTupleHeaderData、NULL 值位图以及用户数据所组成,如下图所示:

与 MVCC 相关的字段只有 4 个,其含义如下:

  • t_xmin: 保存了插入该元组的事务的 txid
  • t_xmax: 保存删除或者是更新该元组的事务的 txid,若一个 tuple 既没有被更新也没有被删除的话,该字段的值为 0
  • t_cid: 即 Command ID,表示在当前事务中,执行当前命令之前共执行了多少条命令,从 0 开始计数。t_cid的主要作用就在于判断游标的数据可见性,将在后文详细描述
  • t_infomask: 位掩码,主要保存了事务执行的状态,如XMIN_COMMITTED、XMAX_COMMITTED 等。同时也保存了 COMBOCID 这一非常重要的标识位,也是和游标相关的字段。

接下来就通过一些小实验来理清这些字段的具体含义,在此之前需要引入一个小工具: pageinspect。pageinspect 是官方所编写的一个拓展工具,可用于查看数据库一个 page 的全部内容

postgres=# CREATE EXTENSION pageinspect;
CREATE EXTENSION

postgres=# CREATE TABLE t(a int);
CREATE TABLE

postgres=# insert into t values(1);
INSERT 0 1

postgres=# create view info t as select lp as tuple, t_xmin, t_xmax, t_field3 as t_cid, t_ctid from heap_page_items(get_raw_page('t', 0)) order by tuple;

插入: t_xmin

t_xmin 与创建相关,当我们 insert 一条数据时,t_xmin 就会被设置成执行事务的 txid,并且一旦设置,便不会修改:

postgres=# begin;
BEGIN
postgres=# select txid_current();               -- 获取当前事务 txid
 txid_current 
--------------
          582
(1 row)
postgres=# insert into t values (1);            -- 插入数据
INSERT 0 1
postgres=# select * from info_t;                -- 获取 t_xmin
 tuple | t_xmin | t_xmax | t_cid | t_ctid 
-------+--------+--------+-------+--------
     1 |    582 |      0 |     0 | (0,1)
(1 row)

可以看到,t_xmin 被设置成了 582,因为该元组就是被 txid 为 582 的事务所插入的。同时 t_xmax 被设置成 0,因为没有更新和删除。

删除: t_xmax

t_xmax 与删除有关,当我们删除一条数据时,t_xmax 就会被设置成执行事务的 txid:

postgres=# truncate table t;
TRUNCATE TABLE
postgres=# insert into t values (1);            -- 插入数据
INSERT 0 1

postgres=# begin;
BEGIN
postgres=# select txid_current();               -- 获取当前事务 txid
 txid_current 
--------------
          588
(1 row)
postgres=# delete from t;                       -- 删除数据
DELETE 1
postgres=# select * from info_t;
 tuple | t_xmin | t_xmax | t_cid | t_ctid 
-------+--------+--------+-------+--------
     1 |    587 |    588 |     0 | (0,1)
(1 row)

当我们使用 DELETE FROM 删除数据时,数据只是逻辑上被标记删除,实际上就是设置目标元组的 t_xmax 为执行 DELETE 命令事务的 txid,这些被标记为已删除的元组将会在 VCAUUM 过程中被物理清除。

更新: t_xmin + t_xmax

在 PostgresSQL 中,元组的更新并不是原地的,也就是说新数据不会覆盖旧有的数据,而是通过将旧数据标记为删除,新插入一条数据的方式来完成更新。也就是说,假如说我们对 100 条数据进行更新的话,最终会在文件中产生 200 条数据,其中有 100 条被标记为删除。

postgres=# truncate table t;
TRUNCATE TABLE
postgres=# insert into t values (1);            -- 插入数据
INSERT 0 1

postgres=# begin;
BEGIN
postgres=# select txid_current();               -- 获取当前事务 txid
 txid_current 
--------------
          591
(1 row)
postgres=# update t set a = 2;                  -- 更新数据
UPDATE 1
postgres=# select * from info_t;
 tuple | t_xmin | t_xmax | t_cid | t_ctid 
-------+--------+--------+-------+--------
     1 |    590 |    591 |     0 | (0,2)
     2 |    591 |      0 |     0 | (0,2)
(2 rows)

事务快照

事务快照是一个数据集合,保存了某个事务在某个特定时间点所看到的事务状态信息,包括哪些事务已经结束,哪些事务正在进行,以及哪些事务还未开始,我们可以通过 txid_current_snapshot() 函数来获取当前的事务快照:

postgres=# select txid_current_snapshot();
 txid_current_snapshot 
-----------------------
 580:584:581, 583
(1 row)

txid_current_snapshot() 的文本表示含义为 xmin:xmax:xip_list,其中 xmin 表示所有小于它的事务要么已提交,要么已经回滚,即事务结束。xmax 则表示第一个尚未分配的 txid,即所有 txid >= xmax 的事务都还没有开始。而 xip_list 则是使用逗号分割的一组 txid,表示在获取快照时还是进行的事务。

以 580:584:581, 583 该快照为例,在判断可见性时,所有 txid < 580 的并且已提交的 tuple 都是对当前快照可见的。所有 txid >= 584 的 tuple 不管其状态如何,对当前快照都是不可见的。同时,由于 581 和 583 在获取快照时仍然处于活跃状态,因此对于该快照也是不可见的。最后,对于 txid 为 580 以及 582 的元组而言,只要其事务提交了,那么对当前快照来说就是可见的。

当然,这只是一个非常粗糙的判断规则,并没有考虑到元组是否被删除、是否被当前事务所创建、是否是对游标的可见性判断等情况。

PostgreSQL 使用结构体 SnapshotData 来表示所有类型的快照,并且通过函数 GetSnapshotData() 来获取事务快照。该函数最重要的功能就是填充 xmin、xmax 以及 xip 这三个核心字段:

typedef struct SnapshotData {
    SnapshotType snapshot_type; /* type of snapshot */

    TransactionId xmin;			/* all XID < xmin are visible to me */
    TransactionId xmax;			/* all XID >= xmax are invisible to me */

    /*
     * 正在运行的事务 txid 列表
     * note: all ids in xip[] satisfy xmin <= xip[i] < xmax
     */
    TransactionId *xip;
    uint32		xcnt;			/* # of xact ids in xip[] */

    ......
}

基本的可见性判断

在 PostgreSQL 中,事务一共有 4 种状态,分别是:

  • TRANSACTION_STATUS_IN_PROGRESS: 事务正在运行中
  • TRANSACTION_STATUS_COMMITTED: 事务已提交
  • TRANSACTION_STATUS_ABORTED: 事务已回滚
  • TRANSACTION_STATUS_SUB_COMMITTED: 子事务已提交

其中子事务的情况本文暂且不做考虑,同时本小节也不讨论关于 cid 的可见性判断,也就是游标的可见性判断,与游标相关的可见性判断将于在下篇文章中描述。

在读取堆元组的时 PostgreSQL 将使用 HeapTupleSatisfiesMVCC() 函数判断是否对读取的 tuple 可见,其函数签名如下:

static bool
HeapTupleSatisfiesMVCC(Relation relation, HeapTuple htup, Snapshot snapshot,
                       Buffer buffer)

接下来的可见性规则其实就是对该函数的拆解。

xmin 的状态为 ABORTED

首先来看一个最简单的情况,但我们开启一个事务并已经获取了一个快照,并且需要对一个 tuple 进行可见性判断时,如果发现该 tuple 的 xmin 所对应的事务状态为 ABORTED,即已经回滚了,那么这一条“废数据”对当前快照当然不可见。

if (!HeapTupleHeaderXminCommitted(tuple)) {     /* 事务状态为未提交 */
    if (HeapTupleHeaderXminInvalid(tuple)) {    /* 事务已终止 */
        return false;                           /* 不可见 */
    }
}

xmin 的状态为 IN_PROGRESS

当创建元组的事务正在进行时,按理来说这部分数据对当前快照是不可见的,但是唯一的例外就是当前事务自己创建了该元组,并在后续使用 SELECT 语句进行了查看。那么此时,该元组对于当前快照来说就是可见的:

if (!HeapTupleHeaderXminCommitted(tuple)) {
    if (TransactionIdIsCurrentTransactionId(HeapTupleHeaderGetRawXmin(tuple))) {
        if (tuple->t_infomask & HEAP_XMAX_INVALID)      /* 未被当前事务删除 */
            return true;
    }
    
    /* 该元组在进行中,并且插入语句不由当前事务执行,则不可见 */
    return false;
}

xmin 的状态为 COMMITTED

当创建元组的事务已提交,如果该元组没有被删除,以及不在当前快照的活跃事务列表中的话,那么是可见的。

/* xmin is committed, but maybe not according to our snapshot */
if (!HeapTupleHeaderXminFrozen(tuple) &&
    XidInMVCCSnapshot(HeapTupleHeaderGetRawXmin(tuple), snapshot))
    return false;  /* 创建元组的事务在获取快照时还处理活跃状态,故快照不应看到此条元组 */

/* by here, the inserting transaction has committed */

if (tuple->t_infomask & HEAP_XMAX_INVALID)	/* 元组未被删除,即 xmax 无效 */
        return true;

/* 元组被删除,但删除元组的事务正在进行中,尚未提交 */
if (!(tuple->t_infomask & HEAP_XMAX_COMMITTED)) {

    /* 若删除行为是当前事务自己进行的,则删除有效,但是仍然需要进行游标的判断 */
    if (TransactionIdIsCurrentTransactionId(HeapTupleHeaderGetRawXmax(tuple))) {
        if (HeapTupleHeaderGetCmax(tuple) >= snapshot->curcid)
            return true;	/* deleted after scan started */
        else
            return false;	/* deleted before scan started */
    }

    /* 删除行为不是本事务执行的,并且在删除元组的事务在获取快照时还处理活跃状态,故删除无效 */
    if (XidInMVCCSnapshot(HeapTupleHeaderGetRawXmax(tuple), snapshot))
            return true;
} else {
    /* 删除元组事务已提交,但是在删除元组的事务在获取快照时还处理活跃状态,故删除无效 */
    if (XidInMVCCSnapshot(HeapTupleHeaderGetRawXmax(tuple), snapshot))
            return true;		/* treat as still in progress */
}

/* 删除元组事务已提交且不在快照的活跃事务中,即删除有效,不可见 */
return false;

xmin 的状态为 COMMITTED 的情况要稍微复杂一些,需要综合考虑 xmax、xip 以及 cid 之间的关系。

可见性判断函数与获取快照的时机

最后,我们来看一下可见性判断函数,在不同的场景下,我们观察一个堆元组的视角也不尽相同,因此就需要调用不同的可见性判断函数来判断其可见性:

可见性判断函数 作用
HeapTupleSatisfiesMVCC 读取堆元组时所使用的可见性函数,是使用最为频繁的函数
HeapTupleSatisfiesUpdate 更新堆元组时所使用的可见性函数
HeapTupleSatisfiesSelf 不考虑事务之间的“相对时间因素”(即xip)
HeapTupleSatisfiesAny 全部堆数据元组都可见,常见的使用场景是建立索引时(观察HOT链)
HeapTupleSatisfiesVacuum 运行 vacuum 命令时所使用的可见性函数

同时,我们可能通过在不同的时机获取快照来实现不同的事务隔离级别。例如对于可重复读(RR)来说,只有事务的第一条语句才生成快照数据,随后的语句只是复用这个快照数据,以保证在整个事务期间,所有的语句对不同的堆元组具有相同的可见性判断依据。而对于读已提交(RC)来说,事务中的每条语句都会生成一个新的快照,以保证能够对其他事务已经提交的元组可见。

MVCC的使用场景和限制

多版本并发控制(MVCC)在数据库系统中提供了强大的并发控制和事务隔离能力,但它也有特定的使用场景和一些限制。以下是MVCC的主要使用场景和限制:

使用场景

  • 高并发环境
    • MVCC非常适合需要处理大量并发读写操作的应用程序,如在线交易系统、社交网络平台和电子商务网站。
    • 由于读操作不需要加锁,系统可以在高并发情况下高效地处理读请求。
  • 需要强一致性的应用
    • 应用程序需要在事务中保证数据的一致性时,MVCC可以提供良好的事务隔离,避免读到未提交的数据。
    • 适用于金融系统等对数据一致性要求严格的场景。
  • 长时间运行的查询
    • 在需要长时间运行的分析型查询中,MVCC允许这些查询在不被中断的情况下读取一致的快照数据。
    • 这对于需要进行复杂报告和数据分析的应用程序特别有用。
  • 减少锁竞争的场合
    • 在存在大量读操作的场景中,MVCC可以有效减少锁竞争,提高系统的整体吞吐量。

限制

  • 存储空间消耗
    • 由于MVCC会为每次更新创建新的数据版本,可能会导致数据库存储空间的增加。
    • 需要定期运行VACUUM操作来清理过时的行版本,以释放存储空间。
  • 管理复杂性
    • MVCC的实现需要维护多个版本的数据,这增加了数据库管理的复杂性。
    • 数据库管理员需要更频繁地进行性能调优和空间管理。
  • 性能开销
    • 虽然MVCC减少了锁的使用,但在某些情况下,尤其是高写入负载下,管理多个版本的数据可能会带来额外的性能开销。
    • 长时间运行的事务可能会阻止旧版本的清理,导致性能下降。
  • 事务ID耗尽
    • 在极端情况下,事务ID可能会耗尽(事务ID是一个有限的数值),这需要数据库采取措施(如冻结和重置事务ID)来避免问题。
  • 写-写冲突
    • 虽然MVCC减少了读-写冲突,但写-写冲突仍然需要通过锁机制来解决。

MVCC通过提供细粒度的并发控制,解决了许多传统数据库锁机制中的瓶颈问题。然而,它也带来了管理上的挑战,需要在使用时综合考虑应用的特定需求和系统的性能特点。

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注