从版本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_B
,Transaction_B
读取了Tuple_A
。 然后Transaction_A
写Tuple_A
,Transaction_B
写Tuple_B
。 在这种情况下存在两个读-写冲突(rw-conflict) ,它们在该调度的前趋图中构成了一个环,如图5.12(2)所示。 故该调度存在串行化异常,即写偏差。
图5.12 存在写偏差的调度及其前趋图
从概念上讲,存在三种类型的冲突:写-读冲突(wr-conflicts) (脏读),写-写冲突(ww-conflicts) (丢失更新),以及读写冲突(rw-conflicts) 。 但是这里无需考虑写-读冲突与写-写冲突,因为如前所述,PostgreSQL可以防止此类冲突。 因此PostgreSQL中的SSI实现只需要考虑读-写冲突。
PostgreSQL在SSI实现中采用以下策略:
- 使用SIREAD锁记录事务访问的所有对象(元组,页面,关系)。
- 当写入任何堆元组/索引元组时,使用SIREAD锁检测读-写冲突。
- 如果从读-写冲突中检测出串行化异常,则中止事务。
5.9.2 PostgreSQL的SSI实现
为了实现上述策略,PostgreSQL实现了很多数据结构与函数。 但这里我们只会使用两种数据结构:SIREAD锁与读-写冲突来描述SSI机制。它们都储存在共享内存中。
为简单起见,本文省略了一些重要的数据结构,例如
SERIALIZABLEXACT
。 因此对CheckTargetForConflictOut
,CheckTargetForConflictIn
和PreCommit_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
构成。
当在可串行化模式下执行INSERT
,UPDATE
或DELETE
命令时,函数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}}
。
CheckTargetForConflictOut
、CheckTargetForConflictIn
函数,以及在可串行化模式中执行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_A
和Tx_B
执行以下命令,如图5.13所示。
图5.13 写偏差场景一例
假设所有命令都使用索引扫描。 因此当执行命令时,它们会同时读取堆元组与索引页,每个索引页都包含指向相应堆元组的索引元组,如图5.14所示。
图5.14 例子中索引与表的关系
- T1 :
Tx_A
执行SELECT
命令,该命令读取堆元组Tuple_2000
,以及包含主键的索引页Pkey_2
。 - T2 :
Tx_B
执行SELECT
命令。 此命令读取堆元组Tuple_1
,以及包含主键的索引页Pkey_1
。 - T3 :
Tx_A
执行UPDATE
命令,更新Tuple_1
。 - T4 :
Tx_B
执行UPDATE
命令,更新Tuple_2000
。 - T5 :
Tx_A
提交。 - T6 :
Tx_B
提交,然而由于写偏差异常而被中止。
图5.15展示了PostgreSQL如何检测和解决上述场景中描述的写偏差异常。
图5.15 SIREA锁与读-写冲突,图5.13场景中的调度方式
- T1 : 执行
Tx_A
的SELECT
命令时,CheckTargetForConflictsOut
会创建SIREAD锁。在本例中该函数会创建两个SIREAD锁:L1
与L2
。L1
和L2
分别与Pkey_2
和Tuple_2000
相关联。 - T2 : 执行
Tx_B
的SELECT
命令时,CheckTargetForConflictsOut
会创建两个SIREAD锁:L3
和L4
。L3
和L4
分别与Pkey_1
和Tuple_1
相关联。 - T3 : 执行
Tx_A
的UPDATE
命令时,CheckTargetForConflictsOut
和CheckTargetForConflictsIN
会分别在ExecUpdate
执行前后被调用。在本例中,CheckTargetForConflictsOut
什么都不做。而CheckTargetForConflictsIn
则会创建读-写冲突C1
,这是Tx_B
和Tx_A
在Pkey_1
和Tuple_1
上的冲突,因为Pkey_1
和Tuple_1
都由Tx_B
读取并被Tx_A
写入。 - T4 : 执行
Tx_B
的UPDATE
命令时,CheckTargetForConflictsIn
会创建读-写冲突C2
,这是Tx_A
与Tx_B
在Pkey_2
和Tuple_2000
上的冲突。 在这种情况下,C1
和C2
在前趋图中构成一个环;因此Tx_A
和Tx_B
处于不可串行化状态。但事务Tx_A
和Tx_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_B
的UPDATE
命令会调用CheckTargetForConflictsIn
,并检测到串行化异常,如图5.16(1)所示。
如果Tx_B
在T6时刻执行SELECT
命令而不是COMMIT
命令,则Tx_B
也会立即中止。因为Tx_B
的SELECT
命令调用的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锁相关联的读-写冲突:C1
和C2
,并且它们在前趋图中构成了一个环。 因此会检测到假阳性的写偏差异常(即,虽然实际上没有冲突,但Tx_A
与Tx_B
两者之一也将被中止)。
图 5.18 假阳性异常(1) - 使用顺序扫描
即使使用索引扫描,如果事务Tx_A
和Tx_B
都获取里相同的索引SIREAD锁,PostgreSQL也会误报假阳性异常。 图5.19展示了这种情况。 假设索引页Pkey_1
包含两条索引项,其中一条指向Tuple_1
,另一条指向Tuple_2
。 当Tx_A
和Tx_B
执行相应的SELECT
和UPDATE
命令时,Pkey_1
同时被Tx_A
和Tx_B
读取与写入。 这时候会产生Pkey_1
相关联的读-写冲突:C1
和C2
,并在前趋图中构成一个环,因而检测到假阳性写偏差异常(如果Tx_A
和Tx_B
获取不同索引页上的SIREAD锁则不会误报,并且两个事务都可以提交)。
图5.19 假阳性异常(2) - 使用相同索引页的索引扫描