《postgresql指南--内幕探索》第五章并发控制(二)

可见性检查规则

可见性检查规则是一组规则,用于确定一条元组是否对于一个事务可见。该规则会用到:

1.tuple中的t_xmin和t_xmax字段;
2.clog
3.当前的snapshot

为了简便起见,在此回避了子事务和有关t_ctid的问题,只讨论最简单的情形。

我们选取十条规则并将他们分为三类进行说明。

t_xmin的状态为ABORTED

我们知道t_xmin是一个tuple被INSERT时的事务txid。如果该事务的状态为ABORTED,说明该事务被取消,那么理所当然该事务所INSERT的tuple自然是无效并且是不可见的。所以Rule1即为:

Rule 1: If Status(Tuple.t_xmin) = ABORTED ⇒ Tuple is Invisible

t_xmin的状态为IN_PROGRESS

如果一个tuple的t_xmin的状态为IN_PROGRESS,那么很大可能它是不可见的。

因为:

如果这个tuple是其它事务(非当前事务)所插入的,那么这个tuple显然是不可见的,因为这个tuple还未提交(postgreSQL不支持读未提交)。

Rule 2: If Status(t_xmin) = IN_PROGRESS && t_xmin ≠ current_txid ⇒ Tuple is Invisible

如果这个tuple是当前事务提交的,并tuple的t_xmax值不是0,即该tuple是由当前事务插入,但是被当前事务UPDATE或者DELETE过了,因此,显然是不可见的。

Rule 3: If Status(t_xmin) = IN_PROGRESS && t_xmin = current_txid && t_xmax ≠ INVAILD ⇒ Tuple is Invisible

反之,如果这个tuple是当前事务提交的,并tuple的t_xmax值是0,说明这个tuple是由当前事务插入并且并没有被修改过,所以,它是可见的。

Rule 4: If Status(t_xmin) = IN_PROGRESS && t_xmin = current_txid && t_xmax = INVAILD ⇒ Tuple is Visible

t_xmin的状态为COMMITTED

和上面的相反,如果一个tuple的t_xmin的状态为COMMITTED,那么很大可能它是可见的。

先把规则列出来,后面再解释。

Rule 5: If Status(t_xmin) = COMMITTED && Snapshot(t_xmin) = active ⇒ Tuple is Invisible

Rule 6: If Status(t_xmin) = COMMITTED && Snapshot(t_xmin) ≠ active && (t_xmax = INVALID || Status(t_xmax) = ABORTED) ⇒ Tuple is Visible

Rule 7: If Status(t_xmin) = COMMITTED && Status(t_xmax) = IN_PROGRESS && t_xmax = current_txid ⇒ Tuple is Invisible

Rule 8: If Status(t_xmin) = COMMITTED && Status(t_xmax) = IN_PROGRESS && t_xmax ≠ current_txid ⇒ Tuple is Visible

Rule 9: If Status(t_xmin) = COMMITTED && Status(t_xmax) = COMMITTED && Snapshot(t_xmax) = active ⇒ Tuple is Visible

Rule 10: If Status(t_xmin) = COMMITTED && Status(t_xmax) = COMMITTED && Snapshot(t_xmax) ≠ active ⇒ Tuple is Invisible

Rule5是比较显然的,对于一个tuple,插入它的事务已经提交(COMMITED),并且该事务在当前的snapshot下是active的,说明该事务对当前事务中的命令来说是 in progress 或者 not yet started,故该事务插入的tuple对在当前为不可见;

Rule6,显然,该tuple没有被修改或者修改它的事务被abort了 = > 该tuple没有被修改;插入该tuple的事务x在当前snapshot中是inactive(inactive说明事务x对于当前要执行的SQL命令来说要么被提交了,要么被abort了),所以可见;

Rule7,如果tuple被当前事务UPDATE或者DELETE了,自然这个tuple对于我们来说是旧版本了,不可见;

Rule8,和Rule7对比,这个tuple是被别的事务x修改(UPDATE或者DELETE)了,而且事务x没有被提交(postgreSQL不支持读未提交),所以修改后的元组对我们不可见,我们能看到的还是当前这个元组,所以当前tuple可见;

Rule9,虽然修改这个tuple的事务x已经提交了,但是事务x在当前snapshot中是active的,即对当前事务中的命令来说是 in progress 或者 not yet started(第二次用到这个假设了),所以事务x的修改对当前命令不可见,所以我们看到了还是这个tuple;

Rule10,和上一条相反,修改这个tuple的事务x已经提交了,并且事务x在当前snapshot中是inactive(inactive说明事务x对于当前要执行的SQL命令来说要么被提交了,要么被abort了)的,所以对当前事务中的命令来说,这个事务x已经提交,所以这个tuple对当前事务中的命令而言,已经是被修改过了,即是旧版本的了,所以即为不可见。

可见性检查
可见性检查的规则

在这里插入图片描述
简化起见,txid=200的事务的隔离级别为READ COMMITTED,txid=201的事务的隔离级别我们分READ COMMITTED或者REPEATABLE READ两种情况讨论。

上图中命令的执行顺序如下:

T1 :txid=200的事务开始

T2 :txid=201的事务开始

T3 :txid=200和txid=201的事务分别执行SELECT命令

T4 :txid=200的事务执行UPDATE命令

T5 :txid=200和txid=201的事务分别执行SELECT命令

T6 :txid=200的事务commit

T7 :txid=201的事务执行SELECT命令

下面我们就来看看PostgreSQL是如何执行"元组可见性"检测的。

T3 :
在T3时刻,当前表中只有Tuple_1,根据Rule6该tuple对所有事务可见;

T5 :
在T5时刻的情况有所不同,我们对两个事务分开讨论。

对于txid = 200的事务,此刻,我们可知Tuple_1是不可见的(根据Rule7),Tuple_2可见(根据Rule4);
因此,此时SELECT语句的返回结果为:

postgres=# -- txid 200
postgres=# SELECT * FROM tbl;
 name 
------
 Hyde
(1 row)

对于txid = 201的事务,此刻,我们可知Tuple_1是不可见的(根据Rule8),Tuple_2同样不可见(根据Rule2);
因此,此时SELECT语句的返回结果为:

postgres=# -- txid 201
postgres=# SELECT * FROM tbl;
  name 
--------
 Jekyll
(1 row)

我们可以看到,这里txid = 201的事务不会读取txid = 200的事务的未提交的更新,也就是回避了脏读问题。在PostgreSQL所有的事务隔离级别中都不会造成脏读。

T7 :
在T7时刻,只有txid = 201的事务还在运行,txid = 200的事务已经提交。现在我们分两种情况来讨论txid = 201的事务的行为。

1)txid = 201的事务的隔离级别为READ COMMITTED

此时由于txid = 200的事务已经提交,因此,此时重新获取的snapshot为 201:201: 。因此,我们可以知道Tuple_1是不可见的(根据Rule10),Tuple_2是可见的(根据Rule6),

因此,在READ COMMITTED级别下,SELECT语句的返回结果为:

postgres=# -- txid 201 (READ COMMITTED)
postgres=# SELECT * FROM tbl;
 name 
------
 Hyde
(1 row)

我们可以看到,该事务在隔离级别为READ COMMITTED时,前后两次相同的SELECT获取的结果不同,也就是不可重复读。

2)txid = 201的事务的隔离级别为REPEATABLE READ

此时虽然txid = 200的事务已经提交,但是我们知道在REPEATABLE READ/SERIALIZABLE时,事务只在执行第一条命令时获取一次snapshot,因此,此时snapshot仍保持不变为 200:200: 。因此,我们可以知道Tuple_1是不可见的(根据Rule9),Tuple_2是可见的(根据Rule5),

因此,在READ COMMITTED级别下,SELECT语句的返回结果为:

postgres=# -- txid 201 (READ COMMITTED)
postgres=# SELECT * FROM tbl;
  name 
--------
 Jekyll
(1 row)

我们可以看到,事务在隔离级别为REPEATABLE READ时,前后两次相同的SELECT获取的结果不变,即回避了不可重复读。

到这里,我们已经解决了解决了脏读和不可重复读的问题,那么还有一个幻读。然而幻读在PostgreSQL的事务在隔离级别为REPEATABLE READ时存在么?

我们知道,幻读的定义是:一个事务重新执行一个返回符合一个搜索条件的行集合的查询, 发现满足条件的行集合因为另一个最近提交的事务而发生了改变。

显然,在PostgreSQL中,由于快照隔离机制,我们继续上面的分析就能发现:在REPEATABLE READ/SERIALIZABLE隔离级别时消除了幻读,即在从REPEATABLE READ开始就回避了幻读的问题,这和其它数据库上不太一样,PostgreSQL中提供的隔离级别更加严格。

防止更新丢失

所谓"Lost Update"就是写写冲突。当两个并发事务同时更新同一条数据时发生。“Lost Update"必须在REPEATABLE READ 和 SERIALIZABLE 隔离级别上被避免,即拒绝并发地更新同一条数据。下面我们看看在PostgreSQL上如何处理"Lost Update”

有关PostgreSQL的UPDATE操作,我们可以看看ExecUpdate()这个函数。然而今天我们不讲具体的函数,我们形而上一点。只从理论出发。我们只讨论下UPDATE执行时的情形,这意味着,我们不讨论什么触发器啊,查询重写这些杂七杂八的,只看最"干净"的UPDATE操作。而且,我们讨论的是两个并发事务的UPDATE操作。

请看下图,下图显示了两个并发事务中UPDATE同一个tuple时的处理。

在这里插入图片描述
[1]目标tuple处于正在更新的状态
我们看到Tx_A和Tx_B在并发执行,Tx_A先更新了tuple,这时Tx_B准备去更新tuple,发现Tx_A更新了tuple,但是还没有提交。于是,Tx_B处于等待状态,等待Tx_A结束(commit或者abort)。

当Tx_A提交时,Tx_B解除等待状态,准备更新tuple,这时分两个情况:如果Tx_B的隔离级别是READ COMMITTED,那么OK,Tx_B进行UPDATE(可以看出,此时发生了Lost Update)。如果Tx_B的隔离级别是REPEATABLE READ或者是SERIALIZABLE,那么Tx_B会立即被abort,放弃更新。从而避免了Lost Update的发生。

当Tx_A和Tx_B的隔离级别都为READ COMMITTED时的例子:
在这里插入图片描述
当Tx_A的隔离级别为READ COMMITTED,Tx_B的隔离级别为REPEATABLE READ时的例子:
在这里插入图片描述
[2]目标tuple已经被并发的事务更新
我们看到Tx_A和Tx_B在并发执行,Tx_A先更新了tuple并且已经commit,Tx_B再去更新tuple时发现它已经被更新过了并且已经提交。如果Tx_B的隔离级别是READ COMMITTED,根据我们前面说的,,Tx_B在执行UPDATE前会重新获取snapshot,发现Tx_A的这次更新对于Tx_B是可见的,因此Tx_B继续更新Tx_A更新过得元组(Lost Update)。而如果Tx_B的隔离级别是REPEATABLE READ或者是SERIALIZABLE,那么显然我们会终止当前事务来避免Lost Update。

当Tx_A的隔离级别为READ COMMITTED,Tx_B的隔离级别为REPEATABLE READ时的例子:
在这里插入图片描述
[3]更新无冲突
这个很显然,没有冲突就没有伤害。Tx_A和Tx_B照常更新,不会有Lost Update。

从上面我们也可以看出,在使用SI(Snapshot Isolation)机制时,两个并发事务同时更新一条记录时,先更新的那一方获得更新的优先权。但是在下面提到的SSI机制中会有所不同,先提交的事务获得更新的优先权。

SSI(Serializable Snapshot Isolation)

SSI,可序列化快照隔离,是PostgreSQL在9.1之后,为了实现真正的SERIALIZABLE(可序列化)隔离级别而引入的。

对于SERIALIZABLE隔离级别,官方介绍如下:

可序列化隔离级别提供了最严格的事务隔离。这个级别为所有已提交事务模拟序列事务执行;就好像事务被按照序列一个接着另一个被执行,而不是并行地被执行。但是,和可重复读级别相似,使用这个级别的应用必须准备好因为序列化失败而重试事务。事实上,这个隔离级别完全像可重复读一样地工作,除了它会监视一些条件,这些条件可能导致一个可序列化事务的并发集合的执行产生的行为与这些事务所有可能的序列化(一次一个)执行不一致。这种监控不会引入超出可重复读之外的阻塞,但是监控会产生一些负荷,并且对那些可能导致序列化异常的条件的检测将触发一次序列化失败。

讲的比较繁琐,我的理解是:

1.只针对隔离级别为SERIALIZABLE的事务;
2.并发的SERIALIZABLE事务与按某一个顺序单独的一个一个执行的结果相同。

条件1很好理解,系统只判断并发的SERIALIZABLE的事务之间的冲突;
条件2我的理解就是并发的SERIALIZABLE的事务不能同时修改和读取同一个数据,否则由并发执行和先后按序列执行就会不一致。

但是这个不能同时修改和读取同一个数据要限制在多大的粒度呢?
我们分情况讨论下。

[1] 读写同一条数据
似乎没啥问题嘛,根据前面的论述,这里的一致性在REPEATABLE READ阶段就保证了,不会有问题。

以此类推,我们同时读写2,3,4…n条数据,没问题。
[2]读写闭环
啥是读写闭环?即事务Tx_A读tuple1,更新tuple2,而Tx_B恰恰相反,读tuple2, 更新tuple1.

我们假设事务开始前的tuple1,tuple2为tuple1_1,tuple2_1,Tx_A和Tx_B更新后的tuple1,tuple2为tuple1_2,tuple2_2。

这样在并发下:

Tx_A读到的tuple1是tuple1_1,tuple2是tuple2_1。
同理,Tx_B读到的tuple1是tuple1_1,tuple2是tuple2_1。

而如果我们以Tx_A,Tx_B的顺序串行执行时,结果为:

Tx_A读到的tuple1是tuple1_1,tuple2是tuple2_1。
Tx_B读到的tuple1是tuple1_2(被Tx_A更新了),tuple2是tuple2_1。

反之,而如果我们以Tx_B,Tx_A的顺序串行执行时,结果为:

Tx_B读到的tuple1是tuple1_1,tuple2是tuple2_1。
Tx_A读到的tuple1是tuple1_1,tuple2是tuple2_2(被Tx_B更新了)。

可以看出,这三个结果都不一样,不满足条件2,即并发的Tx_A和Tx_B不能被模拟为Tx_A和Tx_B的任意一个序列执行,导致序列化失败。

其实我上面提到的读写闭环,更正式的说法是:序列化异常。上面说的那么多,其实下面两张图即可解释。
在这里插入图片描述
关于这些冲突我们遇到好几个了。我们先总结下:

wr-conflicts (Dirty Reads)
ww-conflicts (Lost Updates)
rw-conflicts (serialization anomaly)

下面说的SSI机制,就是用来解决rw-conflicts的。

好的,下面就开始说怎么检测这个序列化异常问题,也就是说,我们要开始了解下SSI机制了。

在PostgreSQL中,使用以下方法来实现SSI:

1.利用SIREAD LOCK(谓词锁)记录每一个事务访问的对象(tuple、page和relation);

2.在事务写堆表或者索引元组时利用SIREAD LOCK监测是否存在冲突;

3.如果发现到冲突(即序列化异常),abort该事务。

从上面可以看出,SIREAD LOCK是一个很重要的概念。解释了这个SIREAD LOCK,我们也就基本上理解了SSI。

所谓的SIREAD LOCK,在PostgreSQL内部被称为谓词锁。他的形式如下:

SIREAD LOCK := { tuple|page|relation, {txid [, ...]} }

也就是说,一个谓词锁分为两个部分:前一部分记录被"锁定"的对象(tuple、page和relation),后一部分记录同时访问了该对象的事务的virtual txid(有关它和txid的区别,这里就不做多介绍了)。

SIREAD LOCK的实现在函数CheckForSerializableConflictOut中。该函数在隔离级别为SERIALIZABLE的事务中发生作用,记录该事务中所有DML语句所造成的影响。

例如,如果txid为100的事务读取了tuple_1,则创建一个SIREAD LOCK为{tuple_1, {100}}。此时,如果另一个txid为101的事务也读取了tuple_1,则该SIREAD LOCK升级为{tuple_1, {100,101}}。需要注意的是如果在DML语句中访问了索引,那么索引中的元组也会被检测,创建对应的SIREAD LOCK。

SIREAD LOCK的粒度分为三级:tuple|page|relation。如果同一个page中的所有tuple都被创建了SIREAD LOCK,那么直接创建page级别的SIREAD LOCK,同时释放该page下的所有tuple级别的SIREAD LOCK。同理,如果一个relation的所有page都被创建了SIREAD LOCK,那么直接创建relation级别的SIREAD LOCK,同时释放该relation下的所有page级别的SIREAD LOCK。

当我们执行SQL语句使用的是sequential scan时,会直接创建一个relation 级别的SIREAD LOCK,而使用的是index scan时,只会对heap tuple和index page创建SIREAD LOCK。

同时,我还是要说明的是,对于index的处理时,SIREAD LOCK的最小粒度是page,也就是说你即使只访问了index中的一个index tuple,该index tuple所在的整个page都被加上了SIREAD LOCK。这个特性常常会导致意想不到的序列化异常,我们可以在后面的例子中看到。

有了SIREAD LOCK的概念,我们现在使用它来检测rw-conflicts。

所谓rw-conflicts,简单地说,就是有一个SIREAD LOCK,还有分别read和write这个SIREAD LOCK中的对象的两个并发的Serializable事务。

这个时候,另外一个函数闪亮登场:CheckForSerializableConflictIn()。每当隔离级别为Serializable事务中执行INSERT/UPDATE/DELETE语句时,则调用该函数判断是否存在rw-conflicts。

例如,当txid为100的事务读取了tuple_1,创建了SIREAD LOCK : {tuple_1, {100}}。此时,txid为101的事务更新tuple_1。此时调用CheckForSerializableConflictIn()发现存在这样一个状态: {r=100, w=101, {Tuple_1}}。显然,检测出这是一个rw-conflicts。

下面是举例时间。
首先,我们有这样一个表:

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;

并发执行的Serializable事务像下面那样执行:
在这里插入图片描述
假设所有的SQL语句都走的index scan。这样,当SQL语句执行时,不仅要读取对应的heap tuple,还要读取heap tuple 对应的index tuple。如下图:
在这里插入图片描述
执行状态如下:
T1: Tx_A执行SELECT语句,该语句读取了heap tuple(Tuple_2000)和index page(Pkey2);

T2: Tx_B执行SELECT语句,该语句读取了heap tuple(Tuple_1)和index page(Pkey1);

T3: Tx_A执行UPDATE语句,该语句更新了Tuple_1;

T4: Tx_B执行UPDATE语句,该语句更新了Tuple_2000;

T5: Tx_A commit;

T6: Tx_B commit; 由于序列化异常,commit失败,状态为abort。

这时我们来看一下SIREAD LOCK的情况。
在这里插入图片描述
在这里插入图片描述
T1: Tx_A执行SELECT语句,调用CheckForSerializableConflictOut()创建了SIREAD LOCK:L1={Pkey_2,{Tx_A}} 和 L2={Tuple_2000,{Tx_A}};

T2: Tx_B执行SELECT语句,调用CheckForSerializableConflictOut创建了SIREAD LOCK:L3={Pkey_1,{Tx_B}} 和 L4={Tuple_1,{Tx_B}};

T3: Tx_A执行UPDATE语句,调用CheckForSerializableConflictIn(),发现并创建了rw-conflict :C1={r=Tx_B, w=Tx_A,{Pkey_1,Tuple_1}}。这很显然,因为Tx_B和TX_A分别read和write这两个object。

T4: Tx_A执行UPDATE语句,调用CheckForSerializableConflictIn(),发现并创建了rw-conflict :C2={r=Tx_A, w=Tx_B,{Pkey_2,Tuple_2000}}。到这里,我们发现C1和C2构成了precedence graph中的一个环。因此,Tx_A和Tx_B这两个事务都进入了non-serializable状态。但是由于Tx_A和Tx_B都未commit,因此CheckForSerializableConflictIn()并不会abort Tx_B(为什么不abort Tx_A?因此PostgreSQL的SSI机制中采用的是first-committer-win,即发生冲突后,先提交的事务保留,后提交的事务abort。)

T5: Tx_A commit;调用PreCommit_CheckForSerializationFailure()函数。该函数也会检测是否存在序列化异常。显然此时Tx_A和Tx_B处于序列化冲突之中,而由于发现Tx_B仍然在执行中,所以,允许Tx_A commit。

T6: Tx_B commit; 由于序列化异常,且和Tx_B存在序列化冲突的Tx_A已经被提交。因此commit失败,状态为abort。

更多更复杂的例子,可以参考这里.

前面在讨论SIREAD LOCK时,我们谈到对于index的处理时,SIREAD LOCK的最小粒度是page。这个特性会导致意想不到的序列化异常。更专业的说法是"False-Positive Serialization Anomalies"。简而言之实际上并没有发生序列化异常,但是我们的SSI机制不完善,产生了误报。

下面我们来举例说明。
在这里插入图片描述
对于上图,如果SQL语句走的是sequential scan,情形如下:
在这里插入图片描述
如果是index scan呢?还是有可能出现误报:
在这里插入图片描述

需要维护的进程

PostgreSQL的并发控制机制需要以下维护过程:

  • 删除死亡元组并索引指向相应死亡元组的元组

  • 清除clog中不必要的部分

  • 冻结老的txids

  • 更新FSM、虚拟机和统计信息

第三个流程与事务id的回卷问题有关,下面的小节将简要描述这个问题。

冻结处理

这里,我们将描述txid的回卷问题。

假设Tuple_1的txid为100,即Tuple_1的t_xmin值为100。服务器运行了很长时间,Tuple_1没有被修改。当前txid为21亿+ 100,执行SELECT命令。此时,Tuple_1是可见的,因为txid 100是过去的。然后,执行相同的SELECT命令;因此,当前的txid为21亿+ 101亿。然而,Tuple_1不再可见,因为txid 100将在未来出现。这就是PostgreSQL中所谓的事务回卷问题。
在这里插入图片描述
为了解决这个问题,PostgreSQL引入了冻结txid的概念,并实现了冻结进程。

在PostgreSQL中,定义了一个冻结的txid,它是一个特殊的保留txid 2,它总是比所有其他txid老。换句话说,冻结的txid始终是不活动的和可见的。

冻结过程由VACUUM进程调用。如果t_xmin比当前txid减去vacuum_freeze_min_age(默认值是5000万)更旧,那么冻结进程会扫描所有表文件,并将元组的t_xmin重写为冻结的txid。这将在第6章中更详细地解释。

例如,如图a)所示,当前的txid为5000万,冻结过程是由VACUUM命令调用的。在本例中,Tuple_1和Tuple_2的t_xmin都被重写为2。

在9.4或更高版本中,XMIN_FROZEN位被设置为元组的t_infomask字段,而不是将元组的t_xmin重写为冻结的txid(图 b)。
在这里插入图片描述
本博客主要参考:
浅析Postgres中的并发控制(Concurrency Control)与事务特性(下)

  • 2
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值