PostgreSQL的事务隔离和MVCC

     因为之前遇到的并发问题,所以又把pg的事务隔离和mvcc实现温习了一遍,稍微整理,遂有此文。

数据库并发问题

     说到事务隔离,得先说说数据库可能产生的几种并发问题:

     不可重复读和幻读很容易混淆,其实幻读是不可重复读的一种特殊情况,只不过不可重复读侧重于修改,幻读侧重于新增或删除,解决不可重复读的问题只需要锁住满足条件的行,解决幻读需要提高事务的隔离级别,但与此同时,事务的隔离级别越高,并发能力也就越低。所以,所以还需要权衡。

事务隔离级别

     为了有效保证并发读取数据的正确性,提出的事务隔离级别:

     在SQL标准中,定义了不同隔离级别会出现的并发问题:

隔离级别脏读不可重复读幻读
未提交读可能可能可能
已提交读不可能可能可能
可重复读不可能不可能可能
串行读不可能不可能不可能

     注意,这是ANSI SQL标准中的定义,但是具体到数据库的实现可能不一样,只会更严格。比如Read Uncommitted这种隔离级别,其实在实际使用中是没有意义的,设置这种级别还不如用Nosql,所以在pg 中其实Read Uncommitted和Read Committed是一样的。
     关于Repeated Read,因为在同一个事务内查询到的数据都是在开启事务后第一次查询时候的快照,可知在业务层开启事务后,事务内所有的乐观锁机制都将失效,毕竟都读不到其他事务的提交了,一定会造成写冲突。除此之外,看了不少的讲pg的事务隔离的文章,都是说pg的Repeated Read不会出现幻读…然后实际上,自测发现并不能完全避免。因为幻读其实分为好多种,在《A Critique of ANSI SQL Isolation Levels》论文就定义了好多种的幻读。可以说,pg的Repeated Read基于其MVCC的实现可以避免一部分,但是是无法完全避免的。      关于Serializable,pg在9.1之前,是没有Repeated Read这个隔离级别,只有Serializable。9.1之后,才把之前的Serializable重命名为Repeated Read,然后加上一个更为严格的Serializable隔离级别。在这两个隔离级别下,如果两个不同事务中同时修改一条记录都会导致其中某个事物写失败,所以在应用层需要有重试机制。
     关于两者的不同,应该是说Serializable这种隔离级别可以解决更多的幻读问题,Serializable使用谓词锁(Predicate Lock)来防止这些幻读,意思是如果一个事务T1正在执行一个查询,该查询的的WHERE子句存在一个条件表达式E1,同时这个查询下面还有其他更新或者插入操作,那么另外一个事务T2 就不能插入或删除任何满足E1的数据行。比如之前遇到的并发问题,其实可以简单表达为(伪代码):

if not Binding.objects.filter(sku_code='test').all():
	# 这里只有用all()才能触发pg的谓词锁,first(),exist()都不行
	Binding.objects.create(sku_code='test')

     如果是在Repeated Read下,可能就重复创建两条sku_code=‘test’的Binding了,但是在Serializable里面因为存在读写依赖,并发两条事务中肯定会又一条会失败,会提示’could not serialize access due to read/write dependencies among transactions’错误

MVCC常用实现方法

     对于以上的事物隔离级别数据库应该怎么来实现呢。早期有通过用复杂的锁来实现的,最终也只是实现一部分,无法避免所有的并发问题,而且用锁来实现,会影响数据库的并发性能。对如今大部分的主流数据库,都是使用 MVCC(Multi-Version Concurrency Control)多版本并发控制来实现,达到并发并能和隔离性的完美平衡。      MVCC的基本思想是写数据时,旧的数据作为旧版本并不删除,并发的读还能读到旧版本的数据,这样读和写就可以并发了。一般MVCC有2种实现方法:

两种方法各有利弊,相对于第一种来说,PostgreSQL的MVCC实现方式优势在于:

相比InnoDB和Oracle,PostgreSQL的MVCC缺点在于:

PostgreSQL的MVCC实现

    PG为了实现MVCC,表上面会有一些系统的隐藏字段(InnoDB类似),在每个tuple(其他数据库的Row)上,会有t_xmin,t_xmax,cmin和cmax,ctid,t_infomask这些字段。其中:

#define HEAP_HASNULL        0x0001    /* has null attribute(s) */
#define HEAP_HASVARWIDTH        0x0002    /* has variable-width attribute(s) 有可变参数 */
#define HEAP_HASEXTERNAL        0x0004    /* has external stored attribute(s) */
#define HEAP_HASOID        0x0008    /* has an object-id field */
#define HEAP_XMAX_KEYSHR_LOCK    0x0010    /* xmax is a key-shared locker */
#define HEAP_COMBOCID        0x0020    /* t_cid is a combo cid */
#define HEAP_XMAX_EXCL_LOCK    0x0040    /* xmax is exclusive locker */
#define HEAP_XMAX_LOCK_ONLY    0x0080    /* xmax, if valid, is only a locker */
/* xmax is a shared locker */
#define HEAP_XMAX_SHR_LOCK  (HEAP_XMAX_EXCL_LOCK | HEAP_XMAX_KEYSHR_LOCK)
#define HEAP_LOCK_MASK    (HEAP_XMAX_SHR_LOCK | HEAP_XMAX_EXCL_LOCK | \
                         HEAP_XMAX_KEYSHR_LOCK)
#define HEAP_XMIN_COMMITTED    0x0100    /* t_xmin committed 即xmin已经提交*/
#define HEAP_XMIN_INVALID        0x0200    /* t_xmin invalid/aborted */
#define HEAP_XMIN_FROZEN        (HEAP_XMIN_COMMITTED|HEAP_XMIN_INVALID)
#define HEAP_XMAX_COMMITTED    0x0400    /* t_xmax committed即xmax已经提交*/
#define HEAP_XMAX_INVALID        0x0800    /* t_xmax invalid/aborted */
#define HEAP_XMAX_IS_MULTI        0x1000    /* t_xmax is a MultiXactId */
#define HEAP_UPDATED        0x2000    /* this is UPDATEd version of row */
#define HEAP_MOVED_OFF        0x4000    /* moved to another place by pre-9.0                    * VACUUM FULL; kept for binary                     * upgrade support */
#define HEAP_MOVED_IN        0x8000    /* moved from another place by pre-9.0                * VACUUM FULL; kept for binary                  * upgrade support */
#define HEAP_MOVED (HEAP_MOVED_OFF | HEAP_MOVED_IN)
#define HEAP_XACT_MASK        0xFFF0    /* visibility-related bits */

    同时,每个事务都有自己的id,事务id是一个32位的无符号整数。有3个特殊的事务id。

    可用的有效最小事务ID为3,然后开始递增。同时,事务的状态会保存在Commit log里面,简称clog,事务状态有以下4种:0x00:表示事务正在进行,0x01:事务已提交,0x02:事务已回滚,0x03:子事务已提交。
    这些隐藏字段具体到每个操作的变化:

可见性规则

     那怎么判断一个tuple当前事务是否可见呢?tuple对于当前事务的可见性受生成它的事务(t_xmin)的状态、更新它的事务(t_xmax)的状态、当前事务的隔离级别和事务快照(Snapshot)共同影响。
     通过SELECT txid_current_snapshot()可以查看当前的事务快照,里面主要包含三个字段:xmin、xmax、xip

     如上图,当前的事务id为315,获取此时的快照,其中xmin为125,xmax为201(200+1),所以此时的snapshot = 125 : 201 : 140。

     事务ID小于xmin的事务表示已经被完结,其涉及的修改对当前快照可见;事务ID大于或等于xmax的事务表示正在执行,其所做的修改对当前快照不可见。事务ID处在 [xmin, xmax)区间的事务,已经完结的对当前事务可见,否则不可见。具体到其涉及的每个tuple,需要结合活跃事务列表与事务提交日志CLOG,判断其所作的修改对当前快照是否可见:

    以上列出的判断规则其实主要还是为了保证只看到已经完结的事务(避免脏读)。因为没有看源码中的方法,来源都是实际实验中测出来的或者其他文章的总结,不能保证一定全,只是用来帮助理解,更深入的可以查看源码或者《PostgreSQL数据库内核分析》     因为更新的时候,会把原版本的t_ctid指向新的tuple,这样就从旧到新形成了一条版本链(InnoDB类似,不过是从新到旧)。不过需要注意的是,更新操作可能会使表的每个索引也产生新版本的索引记录,即对一条元组的每个版本都有对应版本的索引记录,即对一条元组的每个版本都有对应版本的索引记录。这样带来的问题就是浪费了存储空间,旧版本占用的空间只有在进行VACCUM时才能被回收,增加了数据库的负担。为了减缓更新索引带来的影响,8.3之后开始使用HOT机制。定义符合下面条件的为HOT元组:

    更新一条HOT元组不需要引入新版本的索引,当通过索引获取元组时首先会找到最旧的元组,然后通过元组的版本链找到HOT元组。这样HOT机制让拥有相同索引键值的不同版本元组共用一个索引记录,减少了索引的不必要更新。

隔离级别的实现

    了解了MVCC原理,那PostgreSQL是怎么实现事务隔离级别呢,在这点上,其实InnoDB也类似,都是根据获取快照的时机不同,实现不同的隔离级别:

总结

    对比InnoDB,PG的MVCC实现方法有利有弊。其中最直接的问题就是表膨胀,为了解决这个问题引入了AutoVacuum自动清理辅助进程,将MVCC带来的垃圾数据定期清理。PG的回滚可以立即完成,但是InnoDB需要回退undo log中的数据。另一方面判断可见性PG更复杂,开销更大,pg还需要访问clog来判断事务状态,底层也因为采用了堆存储数据而不是聚集索引来组织数据导致VACUUM回收的时候可能会产生碎片。不过对比Serializable级别的实现,PG貌似更先进些,这部分还了解的不是很清楚,留个坑后面再详解

参考链接

© 2019 - 2022 · Firsy · Theme Simpleness Powered by Hugo ·