任何数据库的主要要求之一就是实现可伸缩性。只有将争用(锁定)最小化(如果不能一起删除),才可以实现。由于读/写/更新/删除是数据库中发生的一些主要的频繁操作,因此对于这些操作并发进行而不被阻塞非常重要。为了实现这一目标,大多数主要数据库都采用了一种称为多版本并发控制的并发模型,该模型将争用降低到最低限度。
什么是MVCC
多版本并发控制(以下简称MVCC)是一种算法,可通过维护同一对象的多个版本来提供精细的并发控制,以使READ和WRITE操作不会冲突。这里的WRITE表示UPDATE和DELETE,因为无论如何新插入的记录都将按照隔离级别受到保护。每个WRITE操作都会生成对象的新版本,并且每个并发读取操作都会根据隔离级别读取对象的不同版本。由于读取和写入操作均在同一对象的不同版本上进行,因此这些操作都不需要完全锁定,因此两者都可以并发操作。争用仍然存在的唯一情况是当两个并发事务尝试写入同一记录时。
当前大多数主要数据库都支持MVCC。该算法的目的是维护同一对象的多个版本,因此MVCC的实现因数据库而异,仅在创建和维护多个版本方面有所不同。因此,相应的数据库操作和数据存储发生了变化。
公认的实现MVCC的方法是PostgreSQL和Firebird / Interbase使用的一种,而InnoDB和Oracle使用的另一种。在随后的章节中,我们将详细讨论如何在PostgreSQL和InnoDB中实现它。
PostgreSQL中的MVCC
为了支持多个版本,PostgreSQL维护每个对象的其他字段(PostgreSQL术语为Tuple),如下所述:
- xmin –插入或更新元组的交易的交易ID。如果是UPDATE,则将使用此事务ID分配更新版本的元组。
- xmax –删除或更新元组的交易的交易ID。如果是UPDATE,则为元组的当前现有版本分配此事务ID。在新创建的元组上,此字段的默认值为null。
PostgreSQL将所有数据存储在称为HEAP的主存储中(页面的默认大小为8KB)。所有新元组都将xmin作为创建它的事务进行处理,而旧版本元组(已更新或删除)将分配给xmax。从旧版本元组到新版本始终存在链接。在隔离的情况下,较旧版本的元组可用于在回滚的情况下重新创建元组,并可通过READ语句读取较旧版本的元组。
考虑到表有两个元组,T1(值1)和T2(值2),可以在下面的3个步骤中演示新行的创建:
MVCC:在PostgreSQL中存储多个版本
如图所示,数据库中最初有两个元组,其值分别为1和2。
然后,在第二步中,将值2的行T2更新为值3。这时,将使用新值创建一个新版本,并将其存储为与现有元组相邻并将其存储在同一存储区域中。在此之前,较旧的版本将分配给xmax并指向最新版本的元组。
类似地,在第三步中,当删除具有值1的行T1时,将在同一位置虚拟删除现有行(即,它为当前事务分配了xmax)。没有为此创建新版本。
接下来,让我们看一下每个操作如何创建多个版本,以及如何保持事务隔离级别而不用一些实际示例进行锁定。在下面的所有示例中,默认情况下使用“ READ COMMITTED”隔离。
插入
每次插入记录时,都会创建一个新的元组,并将其添加到属于相应表的页面之一中。
PostgreSQL并发INSERT操作
正如我们在这里看到的逐步:
- 会话A启动事务并获取事务ID 495。
- 会话B启动事务并获取事务ID 496。
- 会话A插入一个新的元组(获取存储在HEAP中)
- 现在,添加了将xmin设置为当前事务ID 495的新元组。
- 但是从会话B中看不到相同的内容,因为xmin(即495)仍未提交。
- 一旦提交。
- 数据对两个会话均可见。
更新
PostgreSQL UPDATE不是“ IN-PLACE”更新,即它不会使用所需的新值来修改现有对象。相反,它将创建该对象的新版本。因此,UPDATE大致涉及以下步骤:
- 它将当前对象标记为已删除。
- 然后,它添加该对象的新版本。
- 将对象的旧版本重定向到新版本。
因此,即使许多记录保持不变,HEAP也会占用空间,就好像插入了多条记录一样。
PostgreSQL并发INSERT操作
正如我们在这里看到的逐步:
- 会话A启动事务并获取事务ID 497。
- 会话B启动事务并获取事务ID 498。
- 会话A更新现有记录。
- 在这里,会话A看到一个版本的元组(更新的元组),而会话B看到另一个版本(旧元组,但xmax设置为497)。两个元组版本都存储在HEAP存储中(甚至同一页面也取决于空间可用性)
- 一旦会话A提交了事务,则由于提交了旧元组的xmax,旧元组将过期。
- 现在,两个会话都看到相同版本的记录。
删除
删除几乎与UPDATE操作类似,只是它不必添加新版本。只是将当前对象标记为DELETED,如UPDATE情况中所述。
PostgreSQL并发DELETE操作
- 会话A启动事务并获取事务ID 499。
- 会话B启动事务并获取事务ID 500。
- 会话A删除现有记录。
- 在这里,会话A没有看到任何从当前事务中删除的元组。而Session-B看到该元组的较旧版本(xmax为499;删除该记录的事务)。
- 一旦会话A提交了事务,则由于提交了旧元组的xmax,旧元组将过期。
- 现在,两个会话都看不到已删除的元组。
如我们所见,没有任何操作可以直接删除对象的现有版本,并且在需要的地方都可以添加对象的其他版本。
现在,让我们看一下如何在具有多个版本的元组上执行SELECT查询:SELECT需要读取所有版本的元组,直到根据隔离级别找到合适的元组为止。假设有元组T1,它已更新并创建了新版本T1',并在更新时又创建了T1'':
- SELECT操作将通过此表的堆存储并首先检查T1。如果提交了T1 xmax事务,则它将移至该元组的下一个版本。
- 假设现在也提交了T1'元组xmax,然后再次移动到该元组的下一个版本。
- 最后,它找到T1''并看到xmax未提交(或为null),并且根据隔离级别,T1''xmin对于当前事务可见。最后,它将读取T1''元组。
如我们所见,它需要遍历元组的所有3个版本,以便找到合适的可见元组,直到过期的元组被垃圾收集器(VACUUM)删除为止。
InnoDB中的MVCC
为了支持多个版本,InnoDB为每行维护其他字段,如下所述:
- DB_TRX_ID:插入或更新该行的事务的事务ID。
- DB_ROLL_PTR:它也称为回滚指针,它指向写入回滚段的撤消日志记录(在下一个更多内容中)。
与PostgreSQL一样,InnoDB在所有操作中也会创建该行的多个版本,但是旧版本的存储有所不同。
对于InnoDB,更改后的行的旧版本保存在单独的表空间/存储中(称为undo段)。因此,与PostgreSQL不同,InnoDB在主存储区中仅保留行的最新版本,而在undo段中保留较旧的行。还原段中的行版本用于回滚时的撤消操作,并根据隔离级别通过READ语句读取旧版本的行。
考虑到表有两行,T1(值1)和T2(值2),可以通过以下3个步骤来演示新行的创建:
MVCC:在InnoDB中存储多个版本
从图中可以看出,数据库中最初有两行,其值分别为1和2。
然后在第二阶段中,将值2的行T2更新为值3。这时,将使用新值创建新版本,并替换旧版本。在此之前,较旧的版本将存储在撤消段中(请注意,UNDO段版本仅具有增量值)。另外,请注意,回滚段中有一个从新版本到旧版本的指针。因此,与PostgreSQL不同,InnoDB更新是“ IN-PLACE”。
类似地,在第三步中,当删除具有值1的行T1时,则在主存储区域中虚拟删除了现有行(即,它只是在行中标记了一个特殊位),并在其中添加了与之对应的新版本。撤消段。同样,从主存储器到撤消段只有一个滚动指针。
从外部看,所有操作的行为均与PostgreSQL相同。只是多个版本的内部存储有所不同。
MVCC:PostgreSQL与InnoDB
现在,让我们分析PostgreSQL和InnoDB在MVCC实现方面的主要区别是什么:
- 旧版本的大小
PostgreSQL只是在元组的较旧版本上更新xmax,因此较旧版本的大小与相应的插入记录相同。这意味着,如果您有3个版本的旧元组,则所有版本都将具有相同的大小(除非每次更新时实际数据大小有所不同)。
而对于InnoDB,存储在Undo段中的对象版本通常小于相应的插入记录。这是因为仅将更改的值(即差分)写入UNDO日志。 - INSERT操作
即使对于INSERT,InnoDB也需要在UNDO段中写入一条额外的记录,而PostgreSQL仅在UPDATE的情况下才创建新版本。 - 在回滚的情况下还原旧版本
PostgreSQL不需要任何特定的东西就可以在回滚的情况下恢复旧版本。请记住,旧版本的xmax等于更新此元组的事务。因此,在提交该事务ID之前,对于并发快照,它被视为活动元组。事务回滚后,所有事务都会自动将相应的事务视为活动事务,因为这将是中止的事务。
而对于InnoDB,则明确要求在回滚发生后重建对象的旧版本。 - 回收旧版本占用的空间
对于PostgreSQL,仅当没有并行快照读取该版本时,才可以将较早版本占用的空间视为已耗尽。旧版本失效后,VACUUM操作可以回收它们所占用的空间。VACUUM可以手动触发,也可以作为后台任务触发,具体取决于配置。
InnoDB UNDO日志主要分为INSERT UNDO和UPDATE UNDO。相应的事务提交后,第一个将被丢弃。第二个需要保留,直到与任何其他快照平行为止。InnoDB没有显式的VACUUM操作,但是在类似的行上,它具有异步的PURGE来丢弃作为后台任务运行的UNDO日志。 - 延迟真空的影响
如前所述,在PostgreSQL的情况下延迟真空会产生巨大的影响。即使不断删除记录,它也会导致表开始膨胀并导致存储空间增加。它也可能达到需要完全抽真空的地步,这是非常昂贵的操作。 - 如果表格过大,则顺序扫描
PostgreSQL顺序扫描必须遍历对象的所有旧版本,即使所有旧版本都已失效(直到使用真空将其删除为止)。这是PostgreSQL中最典型且讨论最多的问题。请记住,PostgreSQL将所有版本的元组存储在同一存储中。
而对于InnoDB,除非需要,否则不需要读取撤消记录。如果所有撤消记录都已失效,则仅足以读取对象的所有最新版本。 - 指数
PostgreSQL将索引存储在单独的存储中,该存储与HEAP中的实际数据保持一个链接。因此,即使INDEX不变,PostgreSQL也必须更新INDEX部分。尽管稍后通过实现HOT(仅堆元组)更新解决了此问题,但仍然存在一个局限,即如果无法在同一页面中容纳新的堆元组,则它将回退到常规UPDATE。
InnoDB没有问题,因为它们使用聚集索引。
结论
PostgreSQL MVCC没有什么缺点,特别是在工作负载频繁更新/删除的情况下,存储空间过大。因此,如果您决定使用PostgreSQL,则应该非常小心地配置VACUUM。
PostgreSQL社区也承认这是一个主要问题,他们已经开始研究基于UNDO的MVCC方法(临时名称为ZHEAP),我们可能会在以后的版本中看到同样的情况。