PG中的MVCC(多版本并发控制)、事务回卷等

0    1099    6

Tags:

👉 本文共约11202个字,系统预计阅读时间或需43分钟。

MVCC简介

多版本并发控制(Multi-version Concurrency Control, MVCC)是每一个写操作都创建一个新版本数据,并保留旧版本数据。当事务读取数据时,系统会选择一个合适的版本呈现出来,通过这种方式实现各事务之间相互隔离。与MVCC相对的,是基于锁的并发控制(Lock-Based Concurrency Control)。MVCC最大的好处是:读不加锁,读写不冲突。在读多写少的OLTP应用中,读写不冲突是非常重要的,极大的增加了系统的并发性能,这也是为什么现阶段,几乎所有的RDBMS,都支持了MVCC。

比如MySQLOracle,新版本数据写入时,将旧版本数据写入到回滚段,用新版本数据项覆盖原有数据区域。PostgreSQL是将新旧版本数据都写入到数据文件中,当其他事物读取数据项时,根据可见性校验规则,选择合适的版本呈现出来。SQL Server数据库是写入到tempdb数据库中。

MVCC实现方法

一般MVCC有2种实现方法:

  • 写新数据时,把旧数据快照存入其他位置(如Oracle和MySQL的回滚段、sqlserver的tempdb)。当读数据时,读的是快照的旧数据。

  • 写新数据时,旧数据不删除,直接插入新数据。PostgreSQL就是使用的这种实现方法。

快照读 (snapshot read)与当前读 (current read)

在MVCC并发控制中,读操作可以分成两类:快照读 (snapshot read)与当前读 (current read)。快照读,读取的是记录的可见版本 (有可能是历史版本),不用加锁。当前读,读取的是记录的最新版本,并且,当前读返回的记录都会加上锁,保证其他事务不会再并发修改这条记录。

在一个支持MVCC并发控制的系统中,哪些读操作是快照读?哪些操作又是当前读呢?以MySQL InnoDB为例:

 快照读:简单的select操作,属于快照读,不加锁。(当然,也有例外)

       select * from table where ?;

 当前读:特殊的读操作,插入/更新/删除操作,属于当前读,需要加锁。  

所有以上的语句,都属于当前读,读取记录的最新版本。并且,读取之后,还需要保证其他并发事务不能修改当前记录,对读取记录加锁。其中,除了第一条语句,对读取记录加S锁 (共享锁)外,其他的操作,都加的是X锁 (排它锁)。

更多可以参考:https://www.cnblogs.com/crazylqy/p/7611069.html

PG中的MVCC

PostgreSQL的MVCC实现方式优缺点:

优点:

1、无论事务进行了多少操作,事务回滚可以立即完成。
2、数据可以进行很多更新,不必像Oracle和MySQL的Innodb引擎需要保证回滚段不会被用完,也不会经常遇到“ORA-1555”错误的困扰

缺点:

1、MVCC会导致表膨胀,是表膨胀的罪魁祸首,所以旧版本的数据需要清理。当然,从PostgreSQL 9.x版本开始已经增加了自动清理的辅助进程来定期清理。使用插件pg_repack解决表和索引的膨胀问题可以参考:https://www.xmmup.com/pgshiyongchajianpg_repackjiejuebiaohesuoyindepengzhangwenti.html
2、旧版本的数据可能会导致查询需要扫描的数据块增多,从而导致查询变慢

PostgreSQL中MVCC的实现思路

为了实现MVCC机制,必须要:

1、定义多版本的数据——使用元组头部信息的字段来标示元组的版本号
2、定义数据的有效性、可见性、可更新性——通过当前的事务快照和对应元组的版本号判断
3、实现不同的数据库隔离级别——通过在不同时机获取快照实现

事务ID

在PostgreSQL中,每一个事务都有一个唯一(并非真正唯一)标识符(txid)。txid是一个32位无符号整数,约42亿个(2^32-1)。在事务启动后,执行txid_current()函数,即可获得当前事务ID。txid可以比较大小,比当前txid大的事务,认为是未来的事务,在当前事务中不可见。比当前txid小的事务,认为是过去的事务,在当前的事务中可见。

PostgreSQL保留以下三个特殊txid

  • 0 ,InvalidTransactionId,表示无效的txid
  • 1 ,BootstrapTransactionId,表示系统初始启动的txid,仅用于数据库集群的初始化initdb过程。
  • 2 ,FrozenTransactionId,表示冻结的txid

任何大于2的事务ID都是普通的事务ID。

所以数据库系统的第一个正常的事务ID是从3开始的,然后不停递增,达到4字节整数的最大值后,再从3开始。事务ID为0、1、2的始终保留。

事务ID大小比较:事务间的可见性

PostgreSQL多版本并发控制MVCC (Multi-version Concurrency Control)是在写数据时创建一个新版本数据,并将新旧版本数据都保存在数据文件中。而并行的其他事务查询数据时,应该选择哪一个版本的数据来呈现呢?

txid间可以相互比较大小,任何事务只可见txid<其自身txid的事务修改结果。

txid是理论上是无限大的,而实际上实现无限大不现实。PostgreSQL将txid空间视为一个环,可以无限循环。对于某个特定的txid,其环内前约21亿个txid属于过去的,而其环内后约21亿个txid属于未来的。txid在环内循环(txid随增长环内循环,至于如何保证过去事务不变成未来事务,这个是另一个冻结txid的话题)。

实际上虽然txid空间有42亿,却并非按实际数字大小来判断可见性。pg将txid空间一分为二,对于某个特定的txid,其后约21亿个txid属于未来,均不可见;其前约21亿个txid属于过去,均可见。

例如对于txid=100的事务,从101到2^31+100均为不可见事务(即n+1到n+2^31);从2^31+101到99均为可见事务(即n+2^31+1到n-1)。

PG中的MVCC(多版本并发控制)、事务回卷等

事务回卷

txid并不是无限的,当42亿数据用尽之后又应该如何判断可见性?

pg将txid空间视为一个环,若不进行特殊处理,txid到达最大值后又会从3开始分配(0-2保留),如果进行简单的比大小,之前的事务就可以看到这个新事务创建的元组,而新事务不能看到之前事务创建的元组,这违反了事务的可见性。这种现象称为PG的事务ID回卷问题。

pg必须保证一个数据库中两个有效的事务之间的年龄最多是2^31(同一个数据库中,存在的最旧和最新两个事务txid相差不得超过2^31)。

事务回卷问题(transaction wraparound problem) 是指本来属于过去的事务突然间就变成了属于未来 — 这意味着它们的输出变成不可见。简而言之,就是灾难性的数据丢失(实际上数据仍然在那里,但是如果你不能得到它也无济于事)。为了避免发生这种情况,必要至少每 21 亿个事务就清理每个数据库中的每个表。

假定 txid 100 插入元组 Tuple_1,即 Tuple_1 的 普通t_xmin 为 100。服务器运行了很长时间,Tuple_1 没有被修改。当前 txid 为21亿+100,并执行 SELECT 命令。此时,Tuple_1 可见,因为 txid 100 是过去(可见的)。然后,执行相同的SELECT 命令; 此时,目前的 txid 为21亿+101。然而,Tuple_1 不再可见,因为 txid 100 在未来(如下图)。

PG中的MVCC(多版本并发控制)、事务回卷等

冻结事务

为了保证同一个数据库中的最新和最旧的两个事务之间的年龄不超过2^31,pg引入了冻结(freeze)功能。

周期性的清理能够解决事务回卷问题,pg数据库提供了VACUUM清理机制。VACUUM会把行标记为 冻结 FREEZE,这表示它们是被一个在足够远的过去提交的事务所插入, 这样从 MVCC 的角度来看,效果就是该插入事务对所有当前和未来事务来说当然都是可见的。PostgreSQL保留了一个特殊的 XID (FrozenTransactionId),这个 XID 并不遵循普通 XID 的比较规则 并且总是被认为比任何普通 XID 要老。要阻止事务回卷的发生,被冻结行版本会被看成其插入 XID 为FrozenTransactionId, 这样它们对所有普通事务来说都是“在过去”,而不管回卷问题。并且这样 的行版本将一直有效直到被删除,不管它有多旧。

需要冻结的元组

与freeze相关的参数主要有三个:

vacuum_freeze_min_age
vacuum_freeze_table_age
autovacuum_freeze_max_age

vacuum_freeze_min_age
每个元组距离上次freeze操作后多久(多少txid)需要重新freeze。

每次表被freeze之后,会更新pg_class.relfrozenxid列为本次freeze的txid。该列保存对应表最近冻结的txid,意味着小于此值的txid均已被冻结。
表年龄就是当前最新的txid与relfrozenxid的差值,而元组年龄可以理解为每个元组的t_xmin与relfrozenxid的差值。当元组年龄超过vacuum_freeze_min_age后需要进行freeze。
增大该参数可以避免一些无用的freeze操作,减小该参数可以使得在表必须被强制清理之前保留更多的XID 空间。该参数最大值为20亿,最小值为2亿。
图:lazy mode vacuum(非aggressive vacuum)

PG中的MVCC(多版本并发控制)、事务回卷等

vacuum_freeze_table_age
在freeze过程中,需要对所有可见且未被all-frozen的数据页进行扫描,这个扫描过程称为aggressive vacuum(声势浩大的vacuum)。每次vacuum都去扫描每个表所有符合条件的数据页显然是不现实的,而vacuum_freeze_table_age就用来决定aggressive vacuum的周期。

vacuum_freeze_table_age表示表的年龄大于该值时,会进行aggressive vacuum。该参数最大值为20亿,最小值为1.5亿。如果为0,则每次扫描表都进行aggressive vacuum。
如果当前db中所有表都进行了冻结,pg会更新pg_database.datfrozenxid列,该列包含对应db中最小的pg_class.relfrozenxid

PG中的MVCC(多版本并发控制)、事务回卷等

图:aggressive vacuum(9.6前)

PG中的MVCC(多版本并发控制)、事务回卷等

9.6开始利用vm进行判断

PG中的MVCC(多版本并发控制)、事务回卷等

到这里,我们可以看出:

  • vacuum_freeze_table_age决定要不要进行aggressive vacuum(而不决定要不要冻结元组);当表的年龄超过vacuum_freeze_table_age则会aggressive vacuum
  • vacuum_freeze_min_age决定要不要冻结元组;当元组的年龄超过vacuum_freeze_min_age后可以进行freeze

为了保证上文中同一数据库的最老最新事务差不超过2^31的原则,两次aggressive vacuum之间的新老事务差不能超过2^31,即vacuum_freeze_table_age不能超过20亿减vacuum_freeze_min_age。但是看上面的参数,很明显不能保证这个约束,为了解决这个问题,pg引入了autovacuum_freeze_max_age参数。

autovacuum_freeze_max_age
如果当前最新的txid减去元组的t_xmin>=autovacuum_freeze_max_age,则元组对应的表会强制进行autovacuum(即使已经关闭了autovacuum)。该参数最小值为2亿,最大值为20亿。

也就是说,在经过autovacuum_freeze_max_age-vacuum_freeze_min_age的txid增长之后,这个表肯定会被强制进行一次freeze。因为autovacuum_freeze_max_age最大值为20亿,所以在两次freeze之间,txid的增长肯定不会超过20亿,这就保证了上文中所说的20亿原则。

参数设置建议

值得一提的是,如果vacuum_freeze_table_age>autovacuum_freeze_max_age要高,则在vacuum_freeze_table_age生效前autovacuum_freeze_max_age已生效,起不到减少数据页扫描的作用。所以建议vacuum_freeze_table_age要设置的比autovacuum_freeze_max_age小(官方文档建议为95%),太小会造成频繁的aggressive vacuum。

freeze 操作会消耗大量的IO,对于不经常更新的表,可以合理地增大autovacuum_freeze_max_age和vacuum_freeze_min_age的差值。但是如果设置过大,因为需要存储更多的事务提交信息,会造成pg_xact 和 pg_commit目录占用更多的空间。例如,我们把autovacuum_freeze_max_age设置为最大值20亿,pg_xact大约占500MB,pg_commit_ts大约是20GB(一个事务的提交状态占2位)。如果是对存储比较敏感的用户,也要考虑这点影响。

减小vacuum_freeze_min_age会造成vacuum 做很多无用功,因为当数据库freeze了符合条件的row后,这个row很可能接着会被改变。理想的状态就是,当该行不会被改变,才去freeze这行。

遗憾的是,无论参数怎么调优,都存在一个问题,freeze是不能主动预测的,只能被动触发,所以更提倡用户进行主动预测需要freeze 的时机,选择合适的时间(比如说应用负载较低的时间)主动执行vacuum freeze命令。接下来我们会具体讨论如何去做关于vacuum freeze 的运维。

主动执行冻结

当数据库最老的表年龄达到了1000万时,数据库会打印如下的warning:

根据提示,对该数据库执行vacuum free命令,可以解决这个潜在的问题。注意因为非超级用户没有权限更新database的datfrozenxid,只能使用超级用户执行vacuum free database_name。

当数据库可用的txid空间还有100万时,即当前最新与最老txid差值还差100万达到20亿时,pg会变为只读并拒绝开启任何新的事务,同时在日志中打印如下错误信息:

根据提示,用户可以以单用户模式启动pg并执行vacuum freeze命令,但此时已经影响了业务。

如果freeze发生的时间正好是数据库比较繁忙的时间,会造成IO资源争抢,导致正常的业务受损。用户可以自己监控数据库和表的年龄,在业务比较空闲的时间主动执行以下操作:

查询当前所有表的年龄:

查询所有数据库的年龄:

查看1个表的年龄

本人提供Oracle(OCP、OCM)、MySQL(OCP)、PostgreSQL(PGCA、PGCE、PGCM)等数据库的培训和考证业务,私聊QQ646634621或微信db_bao,谢谢!

设置vacuum_cost_delay为一个比较高的数值(例如50ms),减少普通vacuum对正常数据查询的影响
设置vacuum_freeze_table_age=0.5*autovacuum_freeze_max_age,vacuum_freeze_min_age为原来值的0.1倍
对上面查询的表依次执行vacuum freeze,注意要预估好时间。

目前已经有很多实现好的开源PostgreSQL vacuum freeze监控管理工具,比如flexible-freeze,能够:

  • 确定数据库的高峰和低峰期
  • 在数据库低峰期创建一个cron job执行flexible_freeze.py
  • flexible_freeze.py会自动对具有最老XID的表进行vacuum freeze

手动冻结回收一张表的元组的 xid 的sql:

手动冻结回收一个库里面的所有表 xid 的命令:

冻结回收过程是一个重 IO 的操作,这个过程内核会描述表的所有页面,然后把符合要求的元组的 t_xmin 字段更新为 2,操作过程中一定要控制好并发数,否则非常容易把实例打挂,所以这个过程需要在业务低峰进行,避免影响业务。

元组结构

pg中元组由三部分组成——元组头结点、空值位图、用户数据。

PG中的MVCC(多版本并发控制)、事务回卷等

官方文档中解释如下 :https://www.postgresql.org/docs/14/storage-page-layout.html

Table 70.4. HeapTupleHeaderData Layout

FieldTypeLengthDescription
t_xminTransactionId4 bytesinsert XID stamp
t_xmaxTransactionId4 bytesdelete XID stamp
t_cidCommandId4 bytesinsert and/or delete CID stamp (overlays with t_xvac)
t_xvacTransactionId4 bytesXID for VACUUM operation moving a row version
t_ctidItemPointerData6 bytescurrent TID of this or newer row version
t_infomask2uint162 bytesnumber of attributes, plus various flag bits
t_infomaskuint162 bytesvarious flag bits
t_hoffuint81 byteoffset to user data

All the details can be found in src/include/access/htup_details.h.

其中与MVCC相关的重要信息为:

  • t_xmin:保存插入该元组的事务txid(该元组由哪个事务插入)
  • t_xmax:保存更新或删除该元组的事务txid。若该元组尚未被删除或更新,则t_xmax=0,即invalid
  • t_cid:保存命令标识(command id,cid),指在该事务中,执行当前命令之前还执行过几条sql命令(从0开始计算)
  • t_ctid:一个指针,保存指向自身或新元组的元组的标识符(tid)。
    当更新该元组时,t_ctid会指向新版本元组。若元组被更新多次,则该元组会存在多个版本,各版本通过t_cid串联,形成一个版本链。通过这个版本链,可以找到最新的版本。t_ctid是一个二元组(页号,页内偏移量),其中页号从0开始,页内偏移量从1开始。

在PostgreSQL中,表中每一行数据称为一个元组tuple,每个tuple中除了用户自定义的数据外,还有其他隐藏列,用于并发控制的几个列包括t_ctid, t_xmin, t_xmax, t_cid,用来展示如何实现新旧版本数据都写入到数据文件中。

在PG中,可以使用pageinspect这个外部扩展来观察数据库页面的内容。

在展示之前,需要安装插件pageinspect,如下:

t_xmin:保存插入该元组事务txid。

t_xmax:保存删除或更新该元组事务txid。

t_cid:保存事务内的第几条SQL命令,从0开始。如事务内第一条SQL记录为0,第二条SQL记录为1。

t_ctid:保存着指向自身或新元组的元组标识符tidtid用于标识表中的元组。在更新该元组时,其t_ctid会指向新版本的元组;否则t_ctid会指向自己。

数据insert、update、delete

insert数据

插入操作最简单,直接将新元组插入目标表中页面即可

PG中的MVCC(多版本并发控制)、事务回卷等

插入操作的过程和结果分析:

  • t_xmin 被设置为99,表示插入该元组的txid
  • t_xmax 被设置为0,因为该元组还未被更新或删除过
  • t_cid 被设置为0,因为这是该事务的第一条命令
  • t_ctid 指向自身,被设置为(0,1),表示该元组位于0号page的第1个位置上

示例:insert数据的事务txid为3809,共insert两条数据。所以t_xmin为3809,t_ctid分别为(0,1),(0,2)代表0号数据块的第一条数据和第二条数据,t_cid代表事务内的第几条SQL命令。

update数据

pg不会直接修改数据,而是将目标元组标记为删除,并插入一条新元组,同时修改t_ctid执行新版本元组。

PG中的MVCC(多版本并发控制)、事务回卷等

更新操作的过程和结果分析

首先看第一条update:

Tuple_1

t_xmin 不变,表示插入该元组的txid
t_xmax 被设置为100,即删除该元组的txid
t_cid 被设置为0,因为这是该事务的第一条命令
t_ctid 指向新版本元组,被设置为(0,2),表示新元组位于0号page的第2个位置上
Tuple_2

t_xmin 被设置为100,表示插入该元组的txid
t_xmax 被设置为0,因为该元组还未被更新或删除过
t_cid 被设置为0,因为这是该事务的第一条命令(虽然又删又增,实际都是一条update操作的)
t_ctid 指向自身,被设置为(0,2),表示该元组位于0号page的第2个位置上

再看第二条update:

Tuple_2

t_xmin 不变,表示插入该元组的txid
t_xmax 被设置为100,即删除该元组的txid
t_cid 被设置为1,因为这是该事务的第二条命令
t_ctid 指向新版本元组,被设置为(0,3),表示新元组位于0号page的第3个位置上
Tuple_3

t_xmin 被设置为100,表示插入该元组的txid
t_xmax 被设置为0,因为该元组还未被更新或删除过
t_cid 被设置为1,因为这是该事务的第二条命令
t_ctid 指向自身,被设置为(0,3),表示该元组位于0号page的第3个位置上

示例: id=2的数据将新旧版本数据都写入到数据文件中。旧版本数据t_xmax等于3810,表明3810号事务已将该数据更新或删除。新版本数据t_xmin等于3810,表明该条数据是3810号事务写入的。再次查询a表,只select到了新版本数据,旧版本数据在没有其他事务使用时,变成了dead tupledead tuple数据占用的磁盘空间会被vacuum进程回收再利用,本文不做讨论)

delete数据

pg的删除只是将目标元组在逻辑上标为删除(将t_xmax设为执行delete命令的事务txid),实际该元组依然存在于数据库的存储页面,直至该元组被清理进程清理掉。

PG中的MVCC(多版本并发控制)、事务回卷等

删除操作的过程和结果分析:

t_xmin 不变,表示插入该元组的txid
t_xmax 被设置为111,即删除该元组的txid
t_cid 被设置为0,因为这是该事务的第一条命令
t_ctid 指向自身,被设置为(0,1),表示该元组位于0号page的第1个位置上
当txid=111的事务提交时,tuple_1就不再需要了,称为dead tuple。但是这个tuple依然残留在页面上, 随着数据库的运行,这种死元组越来越多,它们会在VACUUM时最终被清理掉。

示例:id=2的数据已经全部被删除。但是在数据文件中并没有被删除,只是在t_xmax上打了一个标志。

事务快照

事务快照是在某个时间点看到的事务状态信息,包括哪些事务已经完成,那些事务还未开始,哪些事务正在进行中。在PostgreSQL中,用txid_current_snapshot()函数来获得当前的事务快照。

事务快照的文本表现形式:xmin:xmax:xip_list 具体含义如下:

事务快照项解释说明
xminEarliest transaction ID (txid) that is still active. All earlier transactions will either be committed and visible, or rolled back and dead.最早的活跃事务的txid,txid<xmin的事务要么提交,要么回滚
xmaxFirst as-yet-unassigned txid. All txids greater than or equal to this are not yet started as of the time of the snapshot, and thus invisible.第一个尚未分配的txid,txid>=xmin的事务没有开启,所以不可见。
xip_listActive txids at the time of the snapshot. The list includes only those active txids between xmin and xmax; there might be active txids higher than xmax. A txid that is xmin <= txid < xmax and not in this list was already completed at the time of the snapshot, and thus either visible or dead according to its commit status. The list does not include txids of subtransactions.xmin<=txid<xmax中仍然活跃的事务。

例1: 100:100: xmin为100,因此txid < 100的事务是非活跃的,要么提交,要么回滚。xmax为100,因此txid ≥ 100的事务尚未开始。

例2: 100:104:100,102 xmin为100,因此txid < 100的事务是非活跃的,要么提交,要么回滚。xmax为104,因此txid ≥ 104的事务尚未开始。xip_list为100,102,所以100,102号事务仍然活跃,101,103号事务不活跃,要么提交、要么回滚。

非活跃事务是回滚还是提交?需要查看PostgreSQL中clog记录,本文不做解释。

事务快照与事务隔离级别

PostgreSQL中事务隔离级别是通过事务快照来实现的,获取事务快照用来检查元组的可见性。对read committed隔离级别,每执行一条语句都会获得事务快照;对repeatable read,事务只会在执行第一条SQL时获取一次快照。注意,事务快照不是在事务开始时获取,而是在事务中执行语句时获取,看例子:

时间事务一事务二事务三说明
Time_1begin transaction isolation level repeatable read;事务二首先开始,并设置为repeatable read,此时事务二并没有获取事务快照。
Time_2begin;事务一开始,默认read committed隔离级别
Time_3select count(1) from a;结果是0条insert into a values (1 , 'a');事务一插入一条记录。
Time_4commit;事务一提交
Time_5select count(1) from a; 结果是1条事务二获取事务快照,此时事务一已经提交,事务一的结果可以查询
Time_6begin;insert into a values (2 , 'b');commit ;事务三又插入一条记录,默认read committed隔离级别。
Time_7select count(1) from a; 结果是1条事务二仍然使用Time_5获取是事务快照,只看到一条记录。
Time_8commit;事务二结束

PG默认的事务隔离级别为Read Committed。

参考

https://blog.csdn.net/Hehuyi_In/article/details/102869893

标签:

头像

小麦苗

学习或考证,均可联系麦老师,请加微信db_bao或QQ646634621

您可能还喜欢...

发表回复

嘿,我是小麦,需要帮助随时找我哦
  • 18509239930
  • 个人微信

  • 麦老师QQ聊天
  • 个人邮箱
  • 点击加入QQ群
  • 个人微店

  • 回到顶部
返回顶部