MVCC原理
多版本并发控制(Multi-Version Concurrency Control, MVCC) , 是数据库中并发访问数据时保证数据一致性的一种方法。
在并发操作中, 当正在写时, 如果有用户在读, 这时写可能只写了一半, 如一行的前半部分刚写入, 后半部分还没有写入, 这时可能读的用户读取到的数据行的前半部分数据是新的, 后半部分数据是原来的, 这就导致了数据一致性问题。
解决这个问题的最简单的方法是使用读写锁, 写的时候不允许读, 正在读的时候也不允许写, 但这种方法会导致读和写的操作不能并发执行。
于是, 有人想到了一种能够让读写并发执行的方法, 这种方法就是MVCC。
MVCC方法是写数据时, 原数据并不删除, 并发的读还能读到原数据, 这样就不会有数据一致性问题了。
实现MVCC的方法有以下两种:
- 第一种: 写新数据时, 把原数据移到一个单独的位置, 如回滚段中, 其他用户读数据时, 从回滚段中把原数据读出来。
- 第二种: 写新数据时, 原数据不删除, 而是把新数据插入进来。
PostgreSQL数据库使用的是第二种方法, 而Oracle数据库和MySQL数据库中的InnoDB引擎使用的是第一种方法。
PostgreSQL中的多版本并发控制
PostgreSQL中的多版本实现是通过把原数据留在数据文件中, 新插入一条数据来实现多版本的功能的。
每张表上都有4个系统字段“xmin”“xmax”“cmin”“cmax”, 这4个字段就是为多版本的功能而添加的。(参见《Postgresql表中系统字段详解》)
当两个事务同时访问记录时, 通过参考xmin和xmax的标记判断记录的版本, 根据版本号与自己当前的事务标识进行比较, 确定自己的数据权限。
当删除数据时, 记录并没有从数据块中被删除, 空间也没有立即释放。
PostgreSQL的多版本实现中首先要解决的是原数据的空间释放问题。
PostgreSQL通过运行Vaccum进程来回收之前的存储空间, 默认PostgreSQL数据库中的AutoVacuum是打开
的, 也就是说, 当一个表的更新量达到一定值时, AutoVacuum自动回收空间。
当然也可以关闭AutoVacuum进程, 然后在业务低峰期手动运行VACUUM命令来回收空间。
在PostgreSQL中, 若一个事务执行失败, 在数据文件中该事务产生的数据并不会在事务回滚时被清理掉。 为什么要这样做呢? 为什么不在事务提交时把这些数据标记成有效,而在事务回滚时把这些数据标记成无效呢?
这是出于效率的考虑。
若事务提交或回滚时再次标记数据, 那这些数据就有可能会被刷新到磁盘中, 再次标记会导致另一次I/O, 从而降低性能。
那么如何知道这些数据是有效还是无效呢?
PostgreSQL通过记录事务的状态来实现。
数据行上记录了xmin和xmax, 只需了解xmin和xmax对应的事务是成功提交还是回滚了, 就可以知道这些数据行是否有效。
PostgreSQL把事务状态记录在Commit Log中,简称CLOG, CLOG在数据目录的pg_clog子目录下, 示例如下:
osdba@osdba-VirtualBox:~/pgdata$ ls -l pg_clog
total 8
-rw------- 1 osdba osdba 8192 Nov 30 21:43 0000
事务的状态有以下4种:
- TRANSACTION_STATUS_IN_PROGRESS=0x00: 表示事务正在进行中。
- TRANSACTION_STATUS_COMMITTED=0x01: 表示事务已提交。
- TRANSACTION_STATUS_ABORTED=0x02: 表示事务已回滚。
- TRANSACTION_STATUS_SUB_COMMITTED=0x03: 表示子事务已提交。
事务ID, 在PostgreSQL中有时缩写为xid, 是一个32bit的数字。
有以下3个特殊的事务ID是给系统内部使用的, 代表特殊的含义:
- InvalidTransactionId=0: 表示是无效的事务ID。
- BootstrapTransactionId=1: 表示系统表初使化时的事务ID。
- FrozenTransactionId=2: 冻结的事务ID。
所以数据库系统第一个正常的事务ID是从3开始的, 然后连续递增, 达到最大值后,再从3开始。
事务ID为0、 1、 2的始终保留。
通常, 使用值为0的事务ID是为了让内部编程更为方便, 当PostgreSQL内部的事务ID设置为0时, 表示它是一个无效的事务ID。
比如, 使用函数GetCurrentTransactionIdIfAny查询当前的事务ID时, 如果返回的事务ID为0, 则表示当前还没有分配事务ID。
值为1的事务ID是Initdb服务初始化系统表时在表上填写的事务ID, 此时数据库还没有启动, 但在系统表中的cmin下也需要一个有效的事务ID, 这个事务ID就为1, 示例如下:
os dba=# select cmin, cmax, relname from pg_class where relname in ('pg_type','pg_attribute');
cmin | cmax | relname
------+------+--------------
1 | 1 | pg_type
1 | 1 | pg_attribute
(2 rows)
事务回卷问题
事务ID一直递增, 总会到达4字节整数的最大值, 到达最大值后再从头开始时, 以前的事务ID都会比当前的事务ID大, 在进行比较时, 会认为以前的事务ID是将来的事务ID, 这会导致严重的问题, 即事务ID回卷的问题。
另外, PostgreSQL中多版本实现中经常需要判断事务之间的新旧关系, 例如: 如果数据行中的已提交的事务比当前事务更早, 则在当前事务中这行数据应该是可见的。
在事务ID没有回卷时, 简单比较两个事务ID的大小就可以知道事务之间的先后关系。
如4294967290<4294967295, 所以事务ID为4294967290的事务必然比事务ID4294967295的事务更早。
但在事务ID回卷后, 事务ID为5的事务应该比事务ID4294967295的事务更新, 再简单地比较大小就行不通了。
为了解决事务回卷问题和满足比较事务新旧的需求, PostgreSQL中规定, 存在的最早和最新两个事务之间的年龄差最多是231, 而不是无符号整数的最大范围232, 只有该范围的一半, 当要超过231时, 就把旧的事务换成一个特殊的事务ID, 也就是前面介绍的名为“FrozenTransactionId”的特殊事务。
当正常事务ID与冻结事务ID进行比较时, 会认为正常事务ID比冻结事务ID更新。
做了以上的规定后, 两个普通的事务ID比较新旧就可以使用如下公式:
((int32) (id1 - id2)) < 0
如果该公式的返回结果为真, 则表明事务id1比事务id2更早。
从这个公式中可以看出, 当事务ID没有回卷时, 上面的公式相当于直接比较大小, 在事务ID回卷后, 如id1=4294967295, id2=5, id1-id2=4294967290, 这是一个正数, 但转换成有符号的int32时, 由于超出了有符号数的取值范围, 会转换成一个负数, 这样的结果对于事务ID回卷后的情况也适用。
PostgreSQL多版本的优劣分析
Oracle数据库和MySQL数据库的InnoDB引擎也都实现了多版本的功能, 但它们与PostgreSQL的实现方式是不一样的, 在这两个数据库中, 旧版本的数据并不记录在原先的数据块中, 而是被记录在回滚段中, 如果要读取旧版本的数据, 需要根据回滚段的数据重构旧版本数据。
PostgreSQL的多版本机制与Java虚拟机的垃圾回收机制比较相像。
事务提交前, 只需要访问原来的数据即可; 提交后, 系统更新元组的存储标识, 直到Vaccum进程收回为止。
相对于InnoDB和Oracle, PostgreSQL的多版本的优势在于以下几点:
- 事务回滚可以立即完成, 无论事务进行了多少操作。
- 数据可以进行很多更新, 不必像Oracle和InnoDB那样需要经常保证回滚段不会被用完, 也不会像Oracle数据库那样, 经常遇到“ORA-1555”错误的困扰。
相对于InnoDB和Oracle, PostgreSQL的多版本的劣势在于以下几点:
- 旧版本数据需要清理。 PostgreSQL清理旧版本称为VACUUM, 并提供了VACUUM命令进行清理。
- 旧版本的数据会导致查询更慢一些, 因为旧版本的数据存储于数据文件中, 查询时需要扫描更多的数据块。