《PostgreSQL技术内幕——原理探索》第五章 并发控制
Tags: PG并发控制PostgreSQL《PostgreSQL技术内幕——原理探索》翻译
- PostgreSQL中的事务隔离等级
- 5.1 事务标识
- 5.2 元组结构
- 5.3 元组的增删改
- 5.3.1 插入
- pageinspect
- 5.3.2 删除
- 5.3.3 更新
- 5.3.4 空闲空间映射
- pg_freespacemap
- 5.4 提交日志(clog)
- 5.4.1 事务状态
- 5.4.2 提交日志如何工作
- 5.4.3 提交日志的维护
- 5.5 事务快照
- 内置函数txid_current_snapshot及其文本表示
- 5.6 可见性检查规则
- 5.6.1 t_xmin的状态为ABORTED
- 5.6.2 t_xmin的状态为IN_PROGRESS
- 5.6.3 t_xmin的状态为COMMITTED
- 5.7 可见性检查
- 5.7.1 可见性检查
- 提示位(Hint Bits)
- 5.7.2 PostgreSQL可重复读等级中的幻读
- 5.8 防止丢失更新
- 5.8.1 并发UPDATE命令的行为
- 伪代码:ExecUpdate
- 以先更新者为准 / 以先提交者为准
- 5.8.2 例子
- 例1
- 例2
- 例3
- 5.9 可串行化快照隔离
- 5.9.1 SSI实现的基本策略
- 5.9.2 PostgreSQL的SSI实现
- SIREAD锁
- 读-写冲突
- 5.9.3 SSI的原理
- 5.9.4 假阳性的串行化异常
- 5.10 所需的维护进程
- 5.10.1 冻结处理
- 参考文献
当多个事务同时在数据库中运行时,并发控制是一种用于维持一致性与隔离性的技术,一致性与隔离性是ACID的两个属性。
从宽泛的意义上来讲,有三种并发控制技术:多版本并发控制(Multi-version Concurrency Control, MVCC),严格两阶段锁定(Strict Two-Phase Locking, S2PL)和乐观并发控制(Optimistic Concurrency Control, OCC),每种技术都有多种变体。在MVCC中,每个写操作都会创建一个新版本的数据项,并保留其旧版本。当事务读取数据对象时,系统会选择其中的一个版本,通过这种方式来确保各个事务间相互隔离。 MVCC的主要优势在于“读不会阻塞写,而写也不会阻塞读”,相反的例子是,基于S2PL的系统在写操作发生时会阻塞相应对象上的读操作,因为写入者获取了对象上的排他锁。 PostgreSQL和一些RDBMS使用一种MVCC的变体,名曰快照隔离(Snapshot Isolation,SI)。
一些RDBMS(例如Oracle)使用回滚段来实现快照隔离SI。当写入新数据对象时,旧版本对象先被写入回滚段,随后用新对象覆写至数据区域。 PostgreSQL使用更简单的方法:新数据对象被直接插入到相关表页中。读取对象时,PostgreSQL根据可见性检查规则(visibility check rules),为每个事务选择合适的对象版本作为响应。
SI中不会出现在ANSI SQL-92标准中定义的三种异常:脏读,不可重复读和幻读。但SI无法实现真正的可串行化,因为在SI中可能会出现串行化异常:例如写偏差(write skew)和只读事务偏差(Read-only Transaction Skew)。需要注意的是:ANSI SQL-92标准中可串行化的定义与现代理论中的定义并不相同。为了解决这个问题,PostgreSQL从9.1版本之后添加了可串行化快照隔离(SSI,Serializable Snapshot Isolation),SSI可以检测串行化异常,并解决这种异常导致的冲突。因此,9.1版本之后的PostgreSQL提供了真正的SERIALIZABLE
隔离等级(此外SQL Server也使用SSI,而Oracle仍然使用SI)。
本章包括以下四个部分:
第1部分:第5.1~5.3节。
这一部分介绍了理解后续部分所需的基本信息。
第5.1和5.2节分别描述了事务标识和元组结构。第5.3节展示了如何插入,删除和更新元组。
第2部分:第5.4~5.6节。
这一部分说明了实现并发控制机制所需的关键功能。
第5.4,5.5和5.6节描述了提交日志(clog),分别介绍了事务状态,事务快照和可见性检查规则。
第3部分:第5.7~5.9节。
这一部分使用具体的例子来介绍PostgreSQL中的并发控制。
这一部分说明了如何防止ANSI SQL标准中定义的三种异常。第5.7节描述了可见性检查,第5.8节介绍了如何防止丢失更新,第5.9节简要描述了SSI。
第4部分:第5.10节。
这一部分描述了并发控制机制持久运行所需的几个维护过程。维护过程主要通过清理过程(vacuum processing)进行,清理过程将在第6章详细阐述。
并发控制包含着很多主题,本章重点介绍PostgreSQL独有的内容。故这里省略了锁模式与死锁处理的内容(相关信息请参阅官方文档)。
PostgreSQL中的事务隔离等级
PostgreSQL实现的事务隔离等级如下表所示:
隔离等级 脏读 不可重复读 幻读 串行化异常 读已提交 不可能 可能 可能 可能 可重复读[1] 不可能 不可能 PG中不可能,见5.7.2小节 但ANSI SQL中可能 可能 可串行化 不可能 不可能 不可能 不可能 [1]:在9.0及更早版本中,该级别被当做
SERIALIZABLE
,因为它不会出现ANSI SQL-92标准中定义的三种异常。 但9.1版中SSI的实现引入了真正的SERIALIZABLE
级别,该级别已被改称为REPEATABLE READ
。PostgreSQL对DML(
SELECT, UPDATE, INSERT, DELETE
等命令)使用SSI,对DDL(CREATE TABLE
等命令)使用2PL。
5.1 事务标识
每当事务开始时,事务管理器就会为其分配一个称为事务标识(transaction id, txid)的唯一标识符。 PostgreSQL的txid
是一个32位无符号整数,总取值约42亿。在事务启动后执行内置的txid_current()
函数,即可获取当前事务的txid
,如下所示。
1 2 3 4 5 6 7 | testdb=# BEGIN; BEGIN testdb=# SELECT txid_current(); txid_current -------------- 100 (1 row) |
PostgreSQL保留以下三个特殊txid
:
- 0表示无效(Invalid)的
txid
。 - 1表示初始启动(Bootstrap)的
txid
,仅用于数据库集群的初始化过程。 - 2表示冻结(Frozen)的
txid
,详情参考第5.10.1节。
txid
可以相互比较大小。例如对于txid=100
的事务,大于100的txid
属于“未来”,且对于txid=100
的事务而言都是不可见(invisible)的;小于100的txid
属于“过去”,且对该事务可见,如图5.1(a)所示。
图5.1 PostgreSQL中的事务标识
因为txid
在逻辑上是无限的,而实际系统中的txid
空间不足(4字节取值空间约42亿),因此PostgreSQL将txid
空间视为一个环。对于某个特定的txid
,其前约21亿个txid
属于过去,而其后约21亿个txid
属于未来。如图5.1(b)所示。
所谓的txid
回卷问题将在5.10.1节中介绍。
请注意,
txid
并非是在BEGIN
命令执行时分配的。在PostgreSQL中,当执行BEGIN
命令后的第一条命令时,事务管理器才会分配txid
,并真正启动其事务。
5.2 元组结构
可以将表页中的堆元组分为两类:普通数据元组与TOAST元组。本节只会介绍普通元组。
堆元组由三个部分组成,即HeapTupleHeaderData
结构,空值位图,以及用户数据,如图5.2所示。
图5.2 元组结构
HeapTupleHeaderData
结构在src/include/access/htup_details.h
中定义。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 | typedef struct HeapTupleFields { TransactionId t_xmin; /* 插入事务的ID */ TransactionId t_xmax; /*删除或锁定事务的ID*/ union { CommandId t_cid; /* 插入或删除的命令ID */ TransactionId t_xvac; /* 老式VACUUM FULL的事务ID */ } t_field3; } HeapTupleFields; typedef struct DatumTupleFields { int32 datum_len_; /* 变长头部长度*/ int32 datum_typmod; /* -1或者是记录类型的标识 */ Oid datum_typeid; /* 复杂类型的OID或记录ID */ } DatumTupleFields; typedef struct HeapTupleHeaderData { union { HeapTupleFields t_heap; DatumTupleFields t_datum; } t_choice; ItemPointerData t_ctid; /* 当前元组,或更新元组的TID */ /* 下面的字段必需与结构MinimalTupleData相匹配! */ uint16 t_infomask2; /* 属性与标记位 */ uint16 t_infomask; /* 很多标记位 */ uint8 t_hoff; /* 首部+位图+填充的长度 */ /* ^ - 23 bytes - ^ */ bits8 t_bits[1]; /* NULL值的位图 —— 变长的 */ /* 本结构后面还有更多数据 */ } HeapTupleHeaderData; typedef HeapTupleHeaderData *HeapTupleHeader; |
虽然HeapTupleHeaderData
结构包含七个字段,但后续部分中只需要了解四个字段即可。
t_xmin
保存插入此元组的事务的txid
。t_xmax
保存删除或更新此元组的事务的txid
。如果尚未删除或更新此元组,则t_xmax
设置为0,即无效。t_cid
保存命令标识(command id, cid),cid
意思是在当前事务中,执行当前命令之前执行了多少SQL命令,从零开始计数。例如,假设我们在单个事务中执行了三条INSERT
命令BEGIN;INSERT;INSERT;INSERT;COMMIT;
。如果第一条命令插入此元组,则该元组的t_cid
会被设置为0。如果第二条命令插入此元组,则其t_cid
会被设置为1,依此类推。t_ctid
保存着指向自身或新元组的元组标识符(tid
)。如第1.3节中所述,tid
用于标识表中的元组。在更新该元组时,其t_ctid
会指向新版本的元组;否则t_ctid
会指向自己。
5.3 元组的增删改
本节会介绍元组的增删改过程,并简要描述用于插入与更新元组的自由空间映射(Free Space Map, FSM)。
这里主要关注元组,页首部与行指针不会在这里画出来,元组的具体表示如图5.3所示。
图5.3 元组的表示
5.3.1 插入
在插入操作中,新元组将直接插入到目标表的页面中,如图5.4所示。
图5.4 插入元组
假设元组是由txid=99
的事务插入页面中的,在这种情况下,被插入元组的首部字段会依以下步骤设置。
Tuple_1
:
t_xmin
设置为99,因为此元组由txid=99
的事务所插入。t_xmax
设置为0,因为此元组尚未被删除或更新。t_cid
设置为0,因为此元组是由txid=99
的事务所执行的第一条命令所插入的。t_ctid
设置为(0,1)
,指向自身,因为这是该元组的最新版本。
pageinspect
PostgreSQL自带了一个第三方贡献的扩展模块
pageinspect
,可用于检查数据库页面的具体内容。
123456789101112 testdb=# CREATE EXTENSION pageinspect;CREATE EXTENSIONtestdb=# CREATE TABLE tbl (data text);CREATE TABLEtestdb=# INSERT INTO tbl VALUES(A);INSERT 0 1testdb=# SELECT lp as tuple, t_xmin, t_xmax, t_field3 as t_cid, t_ctidFROM heap_page_items(get_raw_page(tbl, 0));tuple | t_xmin | t_xmax | t_cid | t_ctid-------+--------+--------+-------+--------1 | 99 | 0 | 0 | (0,1)(1 row)
5.3.2 删除
在删除操作中,目标元组只是在逻辑上被标记为删除。目标元组的t_xmax
字段将被设置为执行DELETE
命令事务的txid
。如图5.5所示。
图5.5 删除元组
假设
Tuple_1
被txid=111
的事务删除。在这种情况下,Tuple_1
的首部字段会依以下步骤设置。
Tuple_1
:
t_xmax
被设为111。
如果txid=111
的事务已经提交,那么Tuple_1
就不是必需的了。通常不需要的元组在PostgreSQL中被称为死元组(dead tuple)。
死元组最终将从页面中被移除。清除死元组的过程被称为清理(VACUUM)过程,第6章将介绍清理过程。
5.3.3 更新
在更新操作中,PostgreSQL在逻辑上实际执行的是删除最新的元组,并插入一条新的元组(图5.6)。
图5.6 两次更新同一行
假设由txid=99
的事务插入的行,被txid=100
的事务更新两次。
当执行第一条UPDATE
命令时,Tuple_1
的t_xmax
被设为txid 100
,在逻辑上被删除;然后Tuple_2
被插入;接下来重写Tuple_1
的t_ctid
以指向Tuple_2
。Tuple_1
和Tuple_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
被插入。Tuple_2
和Tuple_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
就成了死元组。
5.3.4 空闲空间映射
插入堆或索引元组时,PostgreSQL使用表与索引相应的FSM来选择可供插入的页面。
如1.2.3节所述,表和索引都有各自的FSM。每个FSM存储着相应表或索引文件中每个页面可用空间容量的信息。