5.9 可串行化快照隔离

从版本9.1开始,可串行化快照隔离(SSI)已经嵌入到快照隔离(SI)中,用以实现真正的可串行化隔离等级。SSI解释起来过于复杂,故本书仅解释其概要,详细信息请参阅文献。

下文使用了以下技术术语而未加定义。 如果读者不熟悉这些术语,请参阅[1,3]。

  • 前趋图(precedence graph) ,亦称作依赖图(dependency graph)串行化图(serialization graph)
  • 串行化异常(serialization anomalies) (例如,写偏差(Write-Skew)

5.9.1 SSI实现的基本策略

如果前趋图中存在由某些冲突构成的环,则会出现串行化异常。 这里使用一种最简单的异常来解释,即写偏差(Write-Skew)

图5.12(1)展示了一种调度方式。 这里Transaction_A读取了Tuple_BTransaction_B读取了Tuple_A。 然后Transaction_ATuple_ATransaction_BTuple_B。 在这种情况下存在两个读-写冲突(rw-conflict) ,它们在该调度的前趋图中构成了一个环,如图5.12(2)所示。 故该调度存在串行化异常,即写偏差。

Fig. 5.11. Three internal blocks in ExecUpdate.图5.12 存在写偏差的调度及其前趋图

从概念上讲,存在三种类型的冲突:写-读冲突(wr-conflicts) (脏读),写-写冲突(ww-conflicts) (丢失更新),以及读写冲突(rw-conflicts) 。 但是这里无需考虑写-读冲突与写-写冲突,因为如前所述,PostgreSQL可以防止此类冲突。 因此PostgreSQL中的SSI实现只需要考虑读-写冲突。

PostgreSQL在SSI实现中采用以下策略:

  1. 使用SIREAD锁记录事务访问的所有对象(元组,页面,关系)。
  2. 当写入任何堆元组/索引元组时,使用SIREAD锁检测读-写冲突。
  3. 如果从读-写冲突中检测出串行化异常,则中止事务。

5.9.2 PostgreSQL的SSI实现

为了实现上述策略,PostgreSQL实现了很多数据结构与函数。 但这里我们只会使用两种数据结构:SIREAD锁与读-写冲突来描述SSI机制。它们都储存在共享内存中。

为简单起见,本文省略了一些重要的数据结构,例如SERIALIZABLEXACT。 因此对CheckTargetForConflictOutCheckTargetForConflictInPreCommit_CheckForSerializationFailure等函数的解释也极为简化。比如本文虽然指出哪些函数能检测到冲突;但并没有详细解释如何检测冲突。 如果读者想了解详细信息,请参阅源代码:predicate.c

SIREAD锁

SIREAD锁,在内部又被称为谓词锁(predicate lock) ,是一个由对象与(虚拟)事务标识构成的二元组,存储着哪个事务访问了哪个对象的相关信息。注意这里省略了对虚拟事务标识的描述,使用txid而非虚拟txid能大幅简化说明。

SERIALIZABLE模式下只要执行DML命令,就会通过CheckTargetForConflictsOut函数创建出SIREAD锁。举个例子,如果txid=100的事务读取给定表的Tuple_1,则会创建一个SIREAD锁{Tuple_1,{100}}。如果是其他事务,例如txid=101读取了Tuple_1,则SIREAD锁会更新为{Tuple_1,{100,101}}。请注意,读取索引页时也会创建SIREAD锁,因为在使用了第7.2节中将描述的仅索引扫描(Index-Only Scan) 时,数据库只会读取索引页而不读取表页。

SIREAD锁有三个级别:元组,页面,以及关系。如果单个页面内所有元组的SIREAD锁都被创建,则它们会聚合为该页上的单个SIREAD锁,原有相关元组上的SIREAD锁都会被释放(删除),以减少内存空间占用。对读取的页面也是同理。

当为索引创建SIREAD锁时,一开始会创建页级别的SIREAD锁。当使用顺序扫描时,无论是否存在索引,是否存在WHERE子句,一开始都会创建关系级别的SIREAD锁。请注意在某些情况下,这种实现可能会导致串行化异常的误报(假阳性(false-positive) ),细节将在第5.9.4节中描述。

读-写冲突

读-写冲突是一个三元组,由SIREAD锁,以及两个分别读写该SIREAD锁的事务txid构成。

当在可串行化模式下执行INSERTUPDATEDELETE命令时,函数CheckTargetForConflictsIn会被调用,并检查SIREAD锁来检测是否存在冲突,如果有就创建一个读-写冲突。

举个例子,假设txid = 100的事务读取了Tuple_1,然后txid=101的事务更新了Tuple_1。在这种情况下,txid=101的事务中的UPDATE命令会调用CheckTargetForConflictsIn函数,并检测到在Tuple_1上存在txid=100,101之间的读-写冲突,并创建rw-conflict{r = 100, w = 101, {Tuple_1}}

CheckTargetForConflictOutCheckTargetForConflictIn函数,以及在可串行化模式中执行COMMIT命令会触发的PreCommit_CheckForSerializationFailure函数,都会使用创建的读写冲突来检查串行化异常。如果它们检测到异常,则只有先提交的事务会真正提交,其他事务会中止(依据以先提交者为准(first-committer-win) 策略)。

5.9.3 SSI的原理

本节将描述SSI如何解决写偏差异常,下面将使用一个简单的表tbl为例。

testdb=# CREATE TABLE tbl (id INT primary key, flag bool DEFAULT false);
testdb=# INSERT INTO tbl (id) SELECT generate_series(1,2000);
testdb=# ANALYZE tbl;

事务Tx_ATx_B执行以下命令,如图5.13所示。

写偏图5.13 写偏差场景一例

假设所有命令都使用索引扫描。 因此当执行命令时,它们会同时读取堆元组与索引页,每个索引页都包含指向相应堆元组的索引元组,如图5.14所示。

索引和表的关系图5.14 例子中索引与表的关系

  • T1Tx_A执行SELECT命令,该命令读取堆元组Tuple_2000,以及包含主键的索引页Pkey_2
  • T2Tx_B执行SELECT命令。 此命令读取堆元组Tuple_1,以及包含主键的索引页Pkey_1
  • T3Tx_A执行UPDATE命令,更新Tuple_1
  • T4Tx_B执行UPDATE命令,更新Tuple_2000
  • T5Tx_A提交。
  • T6Tx_B提交,然而由于写偏差异常而被中止。

图5.15展示了PostgreSQL如何检测和解决上述场景中描述的写偏差异常。

SIREAD锁和rw-conflict图5.15 SIREA锁与读-写冲突,图5.13场景中的调度方式

  • T1 : 执行Tx_ASELECT命令时,CheckTargetForConflictsOut会创建SIREAD锁。在本例中该函数会创建两个SIREAD锁:L1L2L1L2分别与Pkey_2Tuple_2000相关联。
  • T2 : 执行Tx_BSELECT命令时,CheckTargetForConflictsOut会创建两个SIREAD锁:L3L4L3L4分别与Pkey_1Tuple_1相关联。
  • T3 : 执行Tx_AUPDATE命令时,CheckTargetForConflictsOutCheckTargetForConflictsIN会分别在ExecUpdate执行前后被调用。在本例中,CheckTargetForConflictsOut什么都不做。而CheckTargetForConflictsIn则会创建读-写冲突C1,这是Tx_BTx_APkey_1Tuple_1上的冲突,因为Pkey_1Tuple_1都由Tx_B读取并被Tx_A写入。
  • T4 : 执行Tx_BUPDATE命令时,CheckTargetForConflictsIn会创建读-写冲突C2,这是Tx_ATx_BPkey_2Tuple_2000上的冲突。 在这种情况下,C1C2在前趋图中构成一个环;因此Tx_ATx_B处于不可串行化状态。但事务Tx_ATx_B都尚未提交,因此CheckTargetForConflictsIn不会中止Tx_B。注意这是因为PostgreSQL的SSI实现采用先提交者为准方案。
  • T5 : 当Tx_A尝试提交时,将调用PreCommit_CheckForSerializationFailure。此函数可以检测串行化异常,并在允许的情况下执行提交操作。在这里因为Tx_B仍在进行中,Tx_A成功提交。
  • T6 : 当Tx_B尝试提交时,PreCommit_CheckForSerializationFailure检测到串行化异常,且Tx_A已经提交;因此Tx_B被中止。

此外,如果在Tx_A提交之后(T5时刻),Tx_B执行了UPDATE命令,则Tx_B会立即中止。因为Tx_BUPDATE命令会调用CheckTargetForConflictsIn,并检测到串行化异常,如图5.16(1)所示。

如果Tx_B在T6时刻执行SELECT命令而不是COMMIT命令,则Tx_B也会立即中止。因为Tx_BSELECT命令调用的CheckTargetForConflictsOut会检测到串行化异常,如图5.16(2)所示。

其他写偏图5.16 其他写偏差场景

这里的Wiki解释了几种更为复杂的异常。

5.9.4 假阳性的串行化异常

在可串行化模式下,因为永远不会检测到假阴性(false-negative,发生异常但未检测到) 串行化异常,PostgreSQL能始终完全保证并发事务的可串行性。 但相应的是在某些情况下,可能会检测到假阳性异常(没有发生异常但误报发生),用户在使用SERIALIZABLE模式时应牢记这一点。 下文会描述PostgreSQL检测到假阳性异常的情况。

图5.17展示了发生假阳性串行化异常的情况。

假阳性串行化异常的场景图5.17 发生假阳性串行化异常的场景

当使用顺序扫描时,如SIREAD锁的解释中所述,PostgreSQL创建了一个关系级的SIREAD锁。 图5.18(1)展示了PostgreSQL使用顺序扫描时的SIREAD锁和读-写冲突。 在这种情况下,产生了与tbl表上SIREAD锁相关联的读-写冲突:C1C2,并且它们在前趋图中构成了一个环。 因此会检测到假阳性的写偏差异常(即,虽然实际上没有冲突,但Tx_ATx_B两者之一也将被中止)。

使用顺序扫描图 5.18 假阳性异常(1) - 使用顺序扫描

即使使用索引扫描,如果事务Tx_ATx_B都获取里相同的索引SIREAD锁,PostgreSQL也会误报假阳性异常。 图5.19展示了这种情况。 假设索引页Pkey_1包含两条索引项,其中一条指向Tuple_1,另一条指向Tuple_2。 当Tx_ATx_B执行相应的SELECTUPDATE命令时,Pkey_1同时被Tx_ATx_B读取与写入。 这时候会产生Pkey_1相关联的读-写冲突:C1C2,并在前趋图中构成一个环,因而检测到假阳性写偏差异常(如果Tx_ATx_B获取不同索引页上的SIREAD锁则不会误报,并且两个事务都可以提交)。

使用相同索引页的索引扫描图5.19 假阳性异常(2) - 使用相同索引页的索引扫描