并发控制
- 了解事务ID和元组结构
- 元组增删改
- 提交日志
- 事务快照
- 可见性检查及相应的规则
参考文档:http://www.interdb.jp/pg/pgsql05.html
并发控制是一种机制,当多个事务并发运行时,用来维持一致性和隔离性(ACID的两个属性)。
有三种广泛的并发控制技术,MVCC,S2PL,OCC,每种技术有多个变种。在MVCC中,每个写操作创建一个数据项的新版本,同时保留老版本。当事务读取数据时,系统选择一个版本以确保单个事务的隔离。PostgreSQL使用MVCC的变种:快照隔离(SI)。
PostgreSQL实现SI的方式:一个新的数据项被直接插入到相关的表页中,当读取条目时,PostgreSQL通过应用可见性检查规则来选择条目的适当版本以响应单个事务。
SI不允许ANSI SQL-92标准中定义的三种异常,即脏读、不可重复读和幻读。SI不能实现真正的序列化,因为它允许序列化异常,如写倾斜和只读事务倾斜。注意,基于经典串行性定义的ANSI SQL-92标准并不等同于现代理论中的定义。为处理此问题,PostgreSQL在9.1版时添加了可序列化快照隔离(SSI)。SSI可以检测到序列化异常,并解决由序列化异常引起的冲突。因此,PostgreSQL 9.1及以后版本提供了一个真正的SERIALIZABLE隔离级别。
PostgreSQL DML使用SI,DDL使用2PL。
PostgreSQL的事务隔离级别
隔离级别 | 脏读 | 不可重复读 | 幻读 | 序列化异常 |
---|---|---|---|---|
READ COMMITTED | Not possible | Possible | Possible | Possible |
REPEATABLE READ | Not possible | Not possible | Not possible | Possible |
SERIALIZABLE | Not possible | Not possible | Not possible | Not possible |
事务ID
开始一个事务时,事务管理器分配一个唯一标识符,称为事务ID(txid)。txid是32位的无符号整型。开始一个事务后,可以使用内置函数 txid_current() 查询当前的事务ID。
cc1=# begin;
BEGIN
cc1=# select txid_current();
txid_current
--------------
14536
(1 row)
cc1=#
PostgreSQL保留了三种特殊的txid:
0:Invalid txid
1:Bootstrap txid,在初始化数据库集簇时使用
2:Frozen txid,事务ID回卷问题相关
txid可以相互比较。如,txid=100时,大于100的txid是不可见的,小于100的txid是可见的。
由于txid空间在实际系统中不足,PostgreSQL将txid空间视为一个圆。但这会引起事务ID回卷问题,在后面介绍。
元组结构
表页中的堆元组分为普通数据元组和TOAST元组,这里介绍普通数据元组。
堆元组包含HeapTupleHeaderData,NULL bitmap,User data。
HeapTupleHeaderData结构如下:
typedef struct HeapTupleFields {
ShortTransactionId t_xmin; /* inserting xact ID */
ShortTransactionId t_xmax; /* deleting or locking xact ID */
union {
CommandId t_cid; /* inserting or deleting command ID, or both */
ShortTransactionId t_xvac; /* old-style VACUUM FULL xact ID */
} t_field3;
} HeapTupleFields;
typedef struct DatumTupleFields {
int32 datum_len_; /* varlena header (do not touch directly!) */
int32 datum_typmod; /* -1, or identifier of a record type */
Oid datum_typeid; /* composite type OID, or RECORDOID */
/*
* Note: field ordering is chosen with thought that Oid might someday
* widen to 64 bits.
*/
} DatumTupleFields;
typedef struct HeapTupleHeaderData {
union {
HeapTupleFields t_heap;
DatumTupleFields t_datum;
} t_choice;
ItemPointerData t_ctid; /* current TID of this or newer tuple */
/* Fields below here must match MinimalTupleData! */
uint16 t_infomask2; /* number of attributes + various flags */
uint16 t_infomask; /* various flag bits, see below */
uint8 t_hoff; /* sizeof header incl. bitmap, padding */
/* ^ - 23 bytes - ^ */
bits8 t_bits[FLEXIBLE_ARRAY_MEMBER]; /* bitmap of NULLs -- VARIABLE LENGTH */
/* MORE DATA FOLLOWS AT END OF STRUCT */
} HeapTupleHeaderData;
t_min:insert元组的事务ID
t_max:delete,update元组的事务ID
t_cid:command ID,当前事务执行了多少SQL命令,起始值为0。如,一个事务有三个insert语句,则第一个命令insert了元组,t_cid=0,第二个命令insert元组,t_cid=1,以此类推
t_ctid:保存了一个元组标识符(tid),它指向自身或一个新元组
插入,删除,更新元组
-
插入
在insert操作中,新元组写入目标表的页。
假设txid为99的事务insert了一个元组到页面,则该元组头部信息如下:
Tuple_1:
t_xmin 设为99,该元组由txid为99的事务写入
t_xmax 设为0,该元组未被delete和update
t_cid 设为0,该元组为txid为99事务的第一个元组
t_ctid 设为(0, 1),指向其自身
cc1=# create table t(id int);
CREATE TABLE
cc1=# insert into t values(1);
INSERT 0 1
cc1=# insert into t values(2);
INSERT 0 1
cc1=# insert into t values(3);
INSERT 0 1
cc1=# SELECT * from heap_page_items(get_raw_page('t',0));
lp | lp_off | lp_flags | lp_len | t_xmin | t_xmax | t_field3 | t_ctid | t_infomask2 | t_infomask | t_hoff | t_bits | t_oid
----+--------+----------+--------+--------+--------+----------+--------+-------------+------------+--------+--------+-------
1 | 8160 | 1 | 28 | 14547 | 0 | 0 | (0,1) | 1 | 2048 | 24 | |
2 | 8128 | 1 | 28 | 14548 | 0 | 0 | (0,2) | 1 | 2048 | 24 | |
3 | 8096 | 1 | 28 | 14549 | 0 | 0 | (0,3) | 1 | 2048 | 24 | |
(3 rows)
-
删除
在删除操作中,目标元组在逻辑上删除。执行delete命令的txid被写到元组的t_xmax。
假设Tuple_1被txid为111的事务删除,则该元组头部信息如下:
Tuple_1:
t_xmax 被设为 111。
如果txid 111的事务提交了,则Tuple_1则不再被需要。这种不被需要的元组在PostgreSQL中被称为死元组。死元组最终会被从页面中删除,VACUUM清理死元组。
cc1=# delete from t where id=3;
DELETE 1
cc1=# SELECT * from heap_page_items(get_raw_page('t',0));
lp | lp_off | lp_flags | lp_len | t_xmin | t_xmax | t_field3 | t_ctid | t_infomask2 | t_infomask | t_hoff | t_bits | t_oid
----+--------+----------+--------+--------+--------+----------+--------+-------------+------------+--------+--------+-------
1 | 8160 | 1 | 28 | 14547 | 0 | 0 | (0,1) | 1 | 2304 | 24 | |
2 | 8128 | 1 | 28 | 14548 | 0 | 0 | (0,2) | 1 | 2304 | 24 | |
3 | 8096 | 1 | 28 | 14549 | 14550 | 0 | (0,3) | 1 | 256 | 24 | |
(3 rows)
-
更新
在更新操作中,PostgreSQL逻辑上删除元组并插入新元组。
假设之前由txid 99写入的行,被txid 100更新了两次。
执行第一个update后,通过设置Tuple_1的t_xmax为100,Tuple_1在逻辑上删除,并写入Tuple_2。然后Tuple_1的t_ctid重写为(0,2)指向Tuple_2。元组1,2头部信息如下:
Tuple_1:
t_xmax 设为100
t_ctid 从(0,1)被重写为(0,2)
Tuple_2:
t_xmin 设为100
t_xmax 设为0
t_cid 设为0
t_ctid 设为(0, 2)
执行第二个update后,如同第一个update,Tuple_2在逻辑上删除,并写入Tuple_3。元组2,3头部信息如下:
Tuple_2:
t_xmax 设为100
t_ctid 从(0,2)重写为(0,3)
Tuple_3:
t_xmin 设为100
t_xmax 设为0
t_cid 设为1
t_ctid 设为(0,3)
如果txid 100提交,则Tuple_1和Tuple_2为死元组。如果txid 100被中止,则Tuple_2和Tuple_3为死元组。
cc1=# insert into t values(5);
INSERT 0 1
cc1=# SELECT * from heap_page_items(get_raw_page('t',0));
lp | lp_off | lp_flags | lp_len | t_xmin | t_xmax | t_field3 | t_ctid | t_infomask2 | t_infomask | t_hoff | t_bits | t_oid
----+--------+----------+--------+--------+--------+----------+--------+-------------+------------+--------+--------+-------
1 | 8160 | 1 | 28 | 14547 | 14551 | 0 | (0,4) | 16385 | 256 | 24 | |
2 | 8128 | 1 | 28 | 14548 | 0 | 0 | (0,2) | 1 | 2304 | 24 | |
3 | 8096 | 1 | 28 | 14549 | 14550 | 0 | (0,3) | 1 | 1280 | 24 | |
4 | 8064 | 1 | 28 | 14551 | 0 | 0 | (0,4) | 32769 | 10240 | 24 | |
5 | 8032 | 1 | 28 | 14552 | 0 | 0 | (0,5) | 1 | 2048 | 24 | |
(5 rows)
cc1=# begin;
BEGIN
cc1=# update t set id=6 where id=5;
UPDATE 1
cc1=# SELECT * from heap_page_items(get_raw_page('t',0));
lp | lp_off | lp_flags | lp_len | t_xmin | t_xmax | t_field3 | t_ctid | t_infomask2 | t_infomask | t_hoff | t_bits | t_oid
----+--------+----------+--------+--------+--------+----------+--------+-------------+------------+--------+--------+-------
1 | 8160 | 1 | 28 | 14547 | 14551 | 0 | (0,4) | 16385 | 1280 | 24 | |
2 | 8128 | 1 | 28 | 14548 | 0 | 0 | (0,2) | 1 | 2304 | 24 | |
3 | 8096 | 1 | 28 | 14549 | 14550 | 0 | (0,3) | 1 | 1280 | 24 | |
4 | 8064 | 1 | 28 | 14551 | 0 | 0 | (0,4) | 32769 | 10496 | 24 | |
5 | 8032 | 1 | 28 | 14552 | 14553 | 0 | (0,6) | 16385 | 256 | 24 | |
6 | 8000 | 1 | 28 | 14553 | 0 | 0 | (0,6) | 32769 | 10240 | 24 | |
(6 rows)
cc1=# update t set id=7 where id=6;
UPDATE 1
cc1=# SELECT * from heap_page_items(get_raw_page('t',0));
lp | lp_off | lp_flags | lp_len | t_xmin | t_xmax | t_field3 | t_ctid | t_infomask2 | t_infomask | t_hoff | t_bits | t_oid
----+--------+----------+--------+--------+--------+----------+--------+-------------+------------+--------+--------+-------
1 | 8160 | 1 | 28 | 14547 | 14551 | 0 | (0,4) | 16385 | 1280 | 24 | |
2 | 8128 | 1 | 28 | 14548 | 0 | 0 | (0,2) | 1 | 2304 | 24 | |
3 | 8096 | 1 | 28 | 14549 | 14550 | 0 | (0,3) | 1 | 1280 | 24 | |
4 | 8064 | 1 | 28 | 14551 | 0 | 0 | (0,4) | 32769 | 10496 | 24 | |
5 | 8032 | 1 | 28 | 14552 | 14553 | 0 | (0,6) | 16385 | 256 | 24 | |
6 | 8000 | 1 | 28 | 14553 | 14553 | 0 | (0,7) | 49153 | 8224 | 24 | |
7 | 7968 | 1 | 28 | 14553 | 0 | 1 | (0,7) | 32769 | 10240 | 24 | |
(7 rows)
cc1=#
- 空闲空间映射(FSM)
当插入堆元组或索引元组时,PostgreSQL使用相应的表,索引FSM来选择可被写入的页。
所有表和索引有FSM,每个FSM在对应的表或索引文件中存储每个页面的可用空间信息。所有FSM都以"fsm"后缀存储,必要时将它们加载到共享内存中。
提交日志(clog)
PostgreSQL在clog中存储事务的状态。clog被分配到共享内存,并在事务处理过程中被使用。
- 事务状态
事务有四种状态,IN_PROGRESS,COMMITTED,ABORTED,SUB_COMMITTED。其中SUB_COMMITTED在子事务中存在。 -
Clog怎么执行
clog在共享内存中由1个或多个8KB的页面组成。clog在逻辑上构成一个数组。数组的索引对应各自的事务ID,数组的每一项保存事务ID相应的状态。
当当前事务ID一直增长,而clog不能再保存它时,会增加一个新页面。
当需要读取事务的状态时,内存函数会被调用。这些函数读取clog并返回请求的事务的状态。 - 维护Clog
当PostgreSQL停止运行或checkpoint进程运行时,clog数据会写到文件中,这些文件保存在pg_clog目录下。文件的最大大小为256KB,如,当clog使用了8个页面(总大小64KB),数据会被保存到一个文件,当clog使用了37个页面(总大小296KB),数据会被保存到两个文件,一个文件保存256KB,一个40KB。
当PostgreSQL启动时,保存在pg_clog目录下文件的数据会被加载,以初始化clog。
clog被写满后,会增加新页面,然后clog大小会持续增长。但是,不是所有clog数据都是必要的,VACUUM会定时删除旧数据(同时删除clog页和文件)。 - 事务快照
事务快照是一个数据集,它存储了关于单个事务在某一时间点是否所有事务都处于活动状态的信息。在这里,活动事务意味着它正在进行或尚未启动。
PostgreSQL内部定义事务快照的文本表示格式为'100:100:'。如,’100:100:‘表示txid<100的事务是不活跃的,txid>=100是活跃的。 - txid_current_snapshot内置函数
txid_current_snapshot函数显示当前事务的快照。
cc1=# select txid_current_snapshot();
txid_current_snapshot
-----------------------
14557:14559:
(1 row)
txid_current_snapshot的文本表现为'xmin:xmax:xip_list':
xmin:活跃的最小txid,所有早于该txid的事务可能被提交,从而可见,或者被回滚,不可见
xmax:还未被分配的txid,所有大于或等于这个值的txid在快照时间点还未启动,所以不可见
xip_list:在快照时间点活跃的txid,只包含在xmin,xmax之前的活跃txid
如:快照'100:104:100,102',xmin=100,xmax=104,xip_list=100,102
上图(a)中,事务快照 '100:100:' 表示:
xmin=100 则 txid <= 99 是不活跃的,xmax=100 则 txid >= 100 是活跃的。
上图(b)中,事务快照 '100:104:100,102' 表示:
xmin=100 则 txid <= 99 是不活跃的,xmax=104 则 txid >= 104 是活跃的,txid为100,102 是活跃的,txid为101,103是不活跃的。
事务管理器提供事务快照,在隔离级别READ COMMITTED,每次SQL命令执行时,事务会获取快照,在其他隔离级别(REPEATABLE READ,SERIALIZABLE),事务只在第一条SQL命令执行时获取快照。获取的快照用于元组的可见性检查。
当使用获得的快照进行可见性检查时,必须将快照的活动事务视为IN PROGRESS,即使它们已经被提交或中止。这个规则非常重要,因为这在READ COMMITTED,REPEATABLE READ(或者SERIALIZABLE)中有不同表现。
事务管理器始终保存当前正在运行的事务信息。上图中事务A,B使用隔离级别READ COMMITTED,事务C使用REPEATABLE READ。
T1时间点:
事务A启动,执行了第一个SELECT命令。当执行第一个命令时,事务A请求txid和快照,在当前场景,事务管理器分配txid 200,并返回快照 '200:200:'。
T2时间点:
事务B启动,执行了第一个SELECT命令。事务管理器分配txid 201,并返回快照 '200:200:'。事务A(txid=200)状态是IN PROGRESS,所以事务A对于事务B不可见。
T3时间点:
事务C启动,执行了第一个SELECT命令。事务管理器分配txid 202,并返回快照 '200:200:'。所以事务A,事务B对于事务C不可见。
T4时间点:
事务A提交,事务管理器删除事务A相关信息。
T5时间点:
事务B,事务C执行第二个SELECT命令。
事务B是READ COMMITTED,它请求事务快照,在这个场景,事务B获得快照 '201:201:',由于事务A(txid=200)已经提交,所以事务A对于事务B可见。
事务C是REPEATABLE READ,它不重新请求快照,还是使用原来的快照 '200:200:',所以事务A对于事务C还是不可见。
可见性检查规则
可见性检查规则是一组规则,它利用元组的t_xmin,t_xmax,clog,获取的事务快照,来确定每个元组是否可见。这些规则比较复杂,这里只介绍需要的最小规则。在下文中,我们省略了与子事务相关的规则,并忽略了关于t_ctid的讨论,即我们不考虑在事务中更新两次以上的元组。
- t_xmin状态为中止(ABORTED)
元组的t_xmin状态为中止时,该元组始终不可见,因为insert该元组的事务已经被中止。
/* t_xmin status == ABORTED */
Rule 1: IF t_xmin status is 'ABORTED' THEN
RETURN 'Invisible'
END IF
该规则的数学表达式表示为:
Rule 1: If Status(t_xmin) = ABORTED ⇒ Invisible
- t_xmin状态为进行中(IN_PROGRESS)
/* t_xmin status == IN_PROGRESS */
IF t_xmin status is 'IN_PROGRESS' THEN
IF t_xmin = current_txid THEN
Rule 2: IF t_xmax = INVALID THEN
RETURN 'Visible'
Rule 3: ELSE /* this tuple has been deleted or updated by the current transaction itself. */
RETURN 'Invisible'
END IF
Rule 4: ELSE /* t_xmin ≠ current_txid */
RETURN 'Invisible'
END IF
END IF
Rule 4: 如果该元组是其他事务插入的,且t_xmin的状态是IN_PROCESS,则该元组对当前事务不可见。
Rule 3: 如果元组的t_xmin等于当前的txid(该元组是由当前事务插入),并且t_xmax不是INVALID,则该元组对当前事务不可见,它已经被当前事务删除或者更新。
Rule 2: 如果元组是由当前事务插入并且t_xmax为INVALID,则元组对当前事务可见。
Rule 2: If Status(t_xmin) = IN_PROGRESS ∧ t_xmin = current_txid ∧ t_xmax = INVAILD ⇒ Visible
Rule 3: If Status(t_xmin) = IN_PROGRESS ∧ t_xmin = current_txid ∧ t_xmax ≠ INVAILD ⇒ Invisible
Rule 4: If Status(t_xmin) = IN_PROGRESS ∧ t_xmin ≠ current_txid ⇒ Invisible
- t_xmin状态为已提交(COMMITTED)
/* t_xmin status == COMMITTED */
IF t_xmin status is 'COMMITTED' THEN
Rule 5: IF t_xmin is active in the obtained transaction snapshot THEN
RETURN 'Invisible'
Rule 6: ELSE IF t_xmax = INVALID OR status of t_xmax is 'ABORTED' THEN
RETURN 'Visible'
ELSE IF t_xmax status is 'IN_PROGRESS' THEN
Rule 7: IF t_xmax = current_txid THEN
RETURN 'Invisible'
Rule 8: ELSE /* t_xmax ≠ current_txid */
RETURN 'Visible'
END IF
ELSE IF t_xmax status is 'COMMITTED' THEN
Rule 9: IF t_xmax is active in the obtained transaction snapshot THEN
RETURN 'Visible'
Rule 10: ELSE
RETURN 'Invisible'
END IF
END IF
END IF
Rule 6:由于t_xmin已提交,t_max为INVALID或者ABORTED,此时元组对当前事务可见。
Rule 5:t_xmin在获取的事务快照中是活跃的,这种情况下,元组是不可见的,因为被当做IN_PROGRESS。
Rule 7:t_xmax等于当前txid,在这个条件下,结合Rule 3,元组不可见,元组已经被当前事务删除或更新。
Rule 8:t_xmax状态为IN_PROGRESS且不等于当前txid时,元组可见,因为元组未被删除。
Rule 9:t_xmax状态为COMMITTED,且t_xmax在事务快照中是活跃的,元组可见。
Rule 10:t_xmax状态为COMMITTED,且t_xmax在事务快照中是不活跃的,元组不可见,已被其他事务删除。
Rule 5: If Status(t_xmin) = COMMITTED ∧ Snapshot(t_xmin) = active ⇒ Invisible
Rule 6: If Status(t_xmin) = COMMITTED ∧ (t_xmax = INVALID ∨ Status(t_xmax) = ABORTED) ⇒ Visible
Rule 7: If Status(t_xmin) = COMMITTED ∧ Status(t_xmax) = IN_PROGRESS ∧ t_xmax = current_txid ⇒ Invisible
Rule 8: If Status(t_xmin) = COMMITTED ∧ Status(t_xmax) = IN_PROGRESS ∧ t_xmax ≠ current_txid ⇒ Visible
Rule 9: If Status(t_xmin) = COMMITTED ∧ Status(t_xmax) = COMMITTED ∧ Snapshot(t_xmax) = active ⇒ Visible
Rule 10: If Status(t_xmin) = COMMITTED ∧ Status(t_xmax) = COMMITTED ∧ Snapshot(t_xmax) ≠ active ⇒ Invisible
可见性检查
-
可见性检查
上图场景中,SQL命令的执行顺序如下:
T1时间点:开始事务 txid=200
T2时间点:开始事务 txid=201
T3时间点:txid 200,201执行select命令
T4时间点:txid 200 执行update命令
T5时间点:txid 200,201执行select命令
T6时间点:txid 200 执行commit
T7时间点:txid 201执行select命令
以上开始了两个事务,txid 200,201,其中txid 200的隔离级别为READ COMMITTED,txid 201隔离级别模拟为READ COMMITTED,REPEATABLE READ。
下面我们看下SELECT语句如何对每个元组进行可见性检查的。
T3 Select命令:
此时只有Tuple_1,通过Rule 6,我们知道该元组是可见的,所以两个事务的select语句都返回'Jekyll'。
Rule6(Tuple_1) ⇒ Status(t_xmin:199) = COMMITTED ∧ t_xmax = INVALID ⇒ Visible
T5 Select命令:
txid 200的Select命令,Tuple_1不可见(Rule 7),Tuple_2可见(Rule 2),所以该事务select返回'Hyde'。
Rule7(Tuple_1): Status(t_xmin:199) = COMMITTED ∧ Status(t_xmax:200) = IN_PROGRESS ∧ t_xmax:200 = current_txid:200 ⇒ Invisible
Rule2(Tuple_2): Status(t_xmin:200) = IN_PROGRESS ∧ t_xmin:200 = current_txid:200 ∧ t_xmax = INVAILD ⇒ Visible
txid 201的Select命令,Tuple_1可见(Rule 8),Tuple_2不可见(Rule 4),所以该事务select返回'Jekyll'。
Rule8(Tuple_1): Status(t_xmin:199) = COMMITTED ∧ Status(t_xmax:200) = IN_PROGRESS ∧ t_xmax:200 ≠ current_txid:201 ⇒ Visible
Rule4(Tuple_2): Status(t_xmin:200) = IN_PROGRESS ∧ t_xmin:200 ≠ current_txid:201 ⇒ Invisible
T7 Select命令:
txid 201隔离级别为READ COMMITTED时,由于txid 200已提交,此时txid 201获取的事务快照为 '201:201:',所以,Tuple_1不可见(Rule 10),Tuple_2可见(Rule 6),此时select返回'Hyde'。
Rule10(Tuple_1): Status(t_xmin:199) = COMMITTED ∧ Status(t_xmax:200) = COMMITTED ∧ Snapshot(t_xmax:200) ≠ active ⇒ Invisible
Rule6(Tuple_2): Status(t_xmin:200) = COMMITTED ∧ t_xmax = INVALID ⇒ Visible
这个结果与之前txid 200提交前的结果是不一致的,此现象称为不可重复读。
txid 201隔离级别为REPEATABLE READ时,事务快照还是 '200:200:',所以,Tuple_1可见(Rule 9),Tuple_2不可见(Rule 5),select返回 'Jekyll'。REPEATABLE READ不会出现不可重复读。
Rule9(Tuple_1): Status(t_xmin:199) = COMMITTED ∧ Status(t_xmax:200) = COMMITTED ∧ Snapshot(t_xmax:200) = active ⇒ Visible
Rule5(Tuple_2): Status(t_xmin:200) = COMMITTED ∧ Snapshot(t_xmin:200) = active ⇒ Invisible
-
REPEATABLE READ隔离级别的幻读
ANSI SQL-92标准中定义的REPEATABLE READ允许幻读。然而,PostgreSQL的实现不允许它们。原则上,SI不允许幻读。
开启两个事务,事务A txid 14567,事务B txid 14568,隔离级别分别为READ COMMITTED,REPEATABLE READ。在事务A插入一行数据并提交,然后在事务B执行select语句查询,结合Rule 5,此时事务A插入的数据对于事务B不可见。所以幻读不会出现。
Rule5(new tuple): Status(t_xmin:14567) = COMMITTED ∧ Snapshot(t_xmin:14567) = active ⇒ Invisible
防止丢失更新
丢失更新,也称为写写冲突,是并发事务更新相同行时发生的异常,必须在REPEATABLE READ和SERIALIZABLE级别上防止。(READ COMMITTED级别不需要防止丢失更新)。
- 并发事务更新的表现
以下是更新命令执行时,调用的ExecUpdate函数的伪代码。
(1) FOR each row that will be updated by this UPDATE command // 获取UPDATE命令会更新的每一行
(2) WHILE true // 循环直到目标行被更新或者事务被中止
/* The First Block */
(3) IF the target row is being updated THEN // 目标行正在更新,进入以下代码块
// 等待更新目标行的事务终止
(4) WAIT for the termination of the transaction that updated the target row
(5) IF (the status of the terminated transaction is COMMITTED)
AND (the isolation level of this transaction is REPEATABLE READ or SERIALIZABLE) THEN
// 如果更新目标行的事务提交了,中止当前事务
(6) ABORT this transaction /* First-Updater-Win */
ELSE
// 否则重复循环
(7) GOTO step (2)
END IF
/* The Second Block */
(8) ELSE IF the target row has been updated by another concurrent transaction THEN
// 目标行已经被其他并行事务更新
(9) IF (the isolation level of this transaction is READ COMMITTED THEN
// 如果隔离级别为READ COMMITTED
(10) UPDATE the target row
ELSE
// 否则中止事务,首次更新胜利
(11) ABORT this transaction /* First-Updater-Win */
END IF
/* The Third Block */
ELSE /* The target row is not yet modified or has been updated by a terminated transaction. */
// 目标行未被终止的事务更新,则更新目标行
(12) UPDATE the target row
END IF
END WHILE
END FOR
以上图表展示了函数ExecUpdate的三个分支。
(1) 目标行正在被更新
该场景,目标行已经被其他并行事务更新,且这个事务还未终止,当前事务必须等待更新目标行的事务终止,因为PostgreSQL的SI使用first-update-win模式。
如图所示,事务A,事务B并行执行,事务B要更新行,这行数据已经被事务A更新,此时事务B等待事务A终止,当事务A提交时,事务B的更新行命令开始执行,如果当前事务的隔离级别为READ COMMITTED,则目标行会被更新,如果隔离级别为REPEATABLE READ,SERIALIZABLE,事务B会被中止,以防止丢失更新。
(2) 目标行已经被并行事务更新
当前事务要更新目标行,但是目标行已经被其他并行事务更新且已提交,在这个场景,如果当前事务隔离级别为READ COMMITTED,则当前事务可以更新目标行,否则,当前事务会被中止,以防止丢失更新。
(3) 没有冲突
没有冲突的情况下,当前事务正常更新目标行。
注:PostgreSQL基于SI的并发控制采用first-update-win方案。PostgreSQL的SSI使用的是first-committer-win的方案。
序列化快照隔离(SSI)
Serializable Snapshot Isolation (SSI)从9.1版开始就被嵌入到SI中,以实现真正的Serializable隔离级别。
-
实现SSI的基础策略
如果在优先图中出现了带有一些冲突的循环,则会出现序列化异常。这可以用最简单的异常来解释,即写倾斜。
如上图,事务A读取Tuple_B,事务B读取Tuple_A,然后,事务A写Tuple_A,事务B写Tuple_B,在这种情况下,出现了两个读写冲突。
从概念上来讲,有三种类型的冲突:写读冲突(脏读),写写冲突(丢失更新),读写冲突。脏读在PostgreSQL中不会出现,丢失更新PostgreSQL已处理,SSI只需处理读写冲突。
PostgreSQL实现SSI使用以下策略:
- 事务访问到的所有对象(元组,页面,关系)记录为SIREAD锁。
- 在写任何堆或索引元组时,使用SIREAD锁检测读写冲突。
- 如果通过检查检测到的读写冲突检测到序列化异常,则中止事务。
- PostgreSQL实现SSI
SIREAD锁:SIREAD锁在内部称为谓词锁,它是一对对象和(虚拟)txids,用于存储关于谁访问了哪个对象的信息。
例如,txid 100读取目标表的Tuple_1,SIREAD锁 {Tuple_1, {100}} 创建。如果另一个事务 txid 101,读取Tuple_1,则SIREAD锁更新为 {Tuple_1, {100,101}}。SIREAD锁也能在索引页被创建,当仅索引扫描时。
SIREAD锁有三个级别:元组,页,关系。如果创建了单个页面中所有元组的SIREAD锁,则将它们聚合为该页面的单个SIREAD锁,并释放(删除)相关元组的所有SIREAD锁,以减少内存空间。读取所有页面也是如此。
当为索引创建SIREAD锁时,开始将创建页级SIREAD锁。当使用顺序扫描时,从一开始就创建关系级SIREAD锁,而不管是否存在索引和/或WHERE子句。注意,在某些情况下,此实现可能导致对序列化异常的误报检测。
读写冲突:读写冲突是一个SIREAD锁和两个读写SIREAD锁的txid的三元组。 - SSI执行
这里介绍SSI如何解决写倾斜异常。
// 准备以下表
postgres=# CREATE TABLE tbl (id INT primary key, flag bool DEFAULT false);
CREATE TABLE
postgres=# INSERT INTO tbl (id) SELECT generate_series(1,2000);
INSERT 0 2000
postgres=# ANALYZE tbl;
ANALYZE
测试以下场景,假设所有命令使用索引扫描,则将读取堆及索引页:
上图中:
T1时间点,事务A读取Tuple_2000,内部函数创建了L1,L2 SIREAD锁,分别关联Pkey_2, Tuple_2000。
T2时间点,事务B读取Tuple_1,内部函数创建了L3,L4 SIREAD锁,分别关联Pkey_1, Tuple_1。
T3时间点,事务A更新Tuple_1,内部函数创建读写冲突 C1,它是事务B和事务A之间的Pkey_1和Tuple_1的冲突,因为Pkey_1和Tuple_1都是由事务B读取和事务A写入的。
T4时间点,事务B更新Tuple_2000,内部函数创建读写冲突 C2,它是事务B和事务A之间的Pkey_2和Tuple_2000的冲突。
在这个场景中,C1和C2在优先图中创建一个循环。因此,事务A和事务B处于非序列化状态。但是,事务A和事务B两个事务都没有提交,因此事务B不会被中止。这是因为PostgreSQL的SSI实现是基于first-committer-win方案。
T5时间点,事务A提交。
T6时间点,事务B提交,由于读写冲突和first-committer-win方案,事务B被中止。
需要维护流程
PostgreSQL并发控制机制需要以下维护流程:
- 删除死元组和指向关联死元组的索引元组
- 删除clog不需要的数据
- 冻结旧txid
- 更新FSM, VM和统计信息
其中1,2已在前面介绍,3关联事务ID回卷问题。
- 冻结处理
假设txid 100写入元组Tuple_1,则Tuple_1的t_xmin为100,服务器已经运行了很长时间,Tuple_1没有被修改。当前txid为231+100,然后执行SELECT语句。这个时间点,Tuple_1可见,因为txid 100在past部分,再将执行select语句,此时当前txid为txid为231+101。而Tuple_1不可见,因为txid 100在future部分。这个问题在PostgreSQL称为事务ID回卷问题。
为了处理这个问题,PostgreSQL引入了冻结txid的概念,并实现了FREEZE进程。
一个冻结txid,是一个特殊的保留txid 2,被定义为总是比其他所有txid更早。换句话说,冻结的txid总是不活跃和可见的。
FREEZE进程被VACUUM进程调用。FREEZE进程扫描所有表文件,如果t_xmin比当前txid - vacuum_freeze_min_age(默认为5千万)的值老时,FREEZE进程重写元组的t_xmin为冻结txid。
在版本9.4及以后的版本,XMIN_FROZEN被写到元组的t_infomask字段,而不是重写t_xmin为冻结id。