目录
5.2 可重复读、SI与更新丢失(写-写冲突)与解决方法(先更新者生效)
5.3 SSI与写偏差(读-写冲突)及其解决方法(先提交者生效)
概述:事务的标识是一个32位无符号整数,约42亿个。事务并不是执行开始语句就被分配了id,而是执行第一条语句时分配的id。为了id的可重用性,这些事务标识组成一个环,对于事务n来说,向环的前数,半个环的事务都是过去的事务,它们插入的元组对事务n都可见;往后数半个环的事务都是未来的事务,它们要不还没提交,要不还没启动,对当前事务不可见。所以其实事务的id大小与事务的过去未来的关系不是绝对的。比如对于事务8来说,ID为40亿的事务为过去的事务,对事务8来说是可见的。

其中有几个特殊的事务id:
0:表示无效的txid
1:表示初始启动的txid,仅用于数据库集群的初始化过程
2:冻结事务id,比所有的事务标识都小,即该事务插入的元组对于所有事务都可见。
标识回卷:如果系统运行了很久了,执行了很多事务,比如当事务id达到40亿时,事务8被判定为未来的事务,但其实早在系统刚开始运行的时候,事务8就已经执行过了,但系统认为它还没启动或者没提交,那么事务8当初插入的元组就会对事务40亿不可见。为了解决这种情况,当系统运行到事务21亿左右时,每新启一个事务,就会定位当前事务往前数半个环的事务,也就是定位到了事务3,将其插入的元组设置为是事务2插入的。上文提到,txid为2表示它比所有事务标识都小,所以事务3就对当前事务可见了。那么当事务id达到40亿时,事务8插入的元组早就被标记为是事务2插入的,对事务40亿就可见了。
为了保留元组的插入事务的id信息,在10版本以后,系统不再将元组的插入事务id更改为2,而是将其标记为冻结元组,达到的效果是一样的:冻结元组对所有事务可见(如果没有被标记删除的话,其实冻结之前就会清空所有被删除的元组吧?)。
在第一章中,我们主要将的是页表的结构,这里细讲其中元组的结构。元组分为头部与用户数据部。头部包括:
t_xmin:插入该元组的事务id;
t_xmax:最后修改该元组的id,包括更新与删除;如果没有,则为0,即表示无效。
t_cid:插入的语句在插入事务中的id,如事务的第3条语句插入了本元组,则tcid=2(从0计数)
t_ctid:保存一个元组位置TID,TID(3,4)表示第3页,第4个元组,这个元组一般是本元组,但当本元组被标记为更新时,这个ctid保存的就是新版本的其他元组。
元组的增已经在第一章中说过了。
删除操作不会物理删除元组,而是将元组的用户数据标记为删除的死元组,t_xmax记录删除本元组的事务id,ctid保存本元组。

更新操作是删除与插入的结合,旧元组的ctid会保存新元组的ctid。这是为了多版本控制中,不影响其他事务对数据的读取之类的。也不需要更新元组指针,也就不需要更新索引指向的TID。

可以使用pgeinspect扩展来查看数据页面的具体内容。如某一元组或索引元组的各个参数值。
上述的增删改情况下的元组内的数据,不会随着执行该事务是否回滚而改变,比如更新,你不能从元组的数据中看出来执行该操作的事务是否成功执行了还是回滚了。即使执行更新的事务其实已经回滚了,但是通过Pageinspect扩展看到的元组数据仿佛就像该更新成功了一样。(所以还需要查clog判断对应的事务的状态,这个状态有时也会记录在元组数据中)
在插入和更新数据时,PG使用FSM空闲空间映射来选择可插入的页面,可以使用pg_freespacemap扩展来查看每个页面的空闲率
CLOG:提交日志是一个记录事务的二维数组,第一维为事务id,第二维为事务状态(已提交、已回滚、进行中、子事务已提交)。随着系统执行过的事务越来越多,CLOG数组会越来越大,但它并不会舍弃很久以前的事务id与状态,虽然他们可能已经过去很久了(很久以前的数据不一定不会被访问到,访问到的时候,就需要查看操作该数据的事务是否成功,即查询很久以前的事务状态),而是申请新的8K页来存。当数据库关掉时,CLOG会保存为文件,每个文件最大为256K,可以保存多个8K页。数据库启动时,又从文件读取到工作内存中转换为CLOG数组。第六章提到的vacuum会删除过期的CLOG文件(一般就是当前事务的前半个圈的事务的记录,当然删除是有前提的,元组的数据需要都可见,那些不可见的死元组需要都被清除)。
200:204:200,203:200表示<200的事务id都是已提交的,204表示≥204的事务id都是未分配的。而200/201/202/203这四个事务中,200与203是活动的事务,未写出来的201/202是已经提交的事务。
读已提交会在执行每条语句之前都获取一下快照,以读取其他事务刚提交的数据,而可重复读与可串行化只会在事务启动时获取一次快照。
当前事务去读取一个元组,发现该元组的t_xmin事务的状态为已回滚(当clog被清理了之后,只会留下可见的被标记为冻结的数据,死元组会被移除。所以不用担心clog被清理后,不能判断元组的可见性,被标记为冻结的都可见,没被标记的clog都没有被清除!),那么该元组对当前事务不可见。//规则1
插入元组的事务正在进行的话,大部分情况都是对当前事务不可见的。//规则4
除非插入元组的事务正是当前事务,那么分情况:

因为事务在执行过程中,系统会频繁地通过三个函数去读元组中标的其他事务的状态,为了减少对CLOG的读取,在读取或写入元组是,pg会择机将一些事务状态存储到元组的参数中,即事务状态提示位。这样系统不必调用函数,而是访问元组就知道与该元组相关的事务的状态。
| 脏读 | 幻读 | 串行化异常 | ||
| 读已提交 | 不会 | 会 | 会 | 会 |
| 可重复读(基于SI) | 不会 | 不会 | 不会 | 会 |
| 可串行化(9.1版后) | 不会 | 不会 | 不会 | 不会 |
脏读就是读到了其他还没提交的事务所修改的数据,为了防止脏读,提出了读已提交。
根据读已提交这个名字就知道,不会出现这种情况。
但读已提交会出现不可重复读与幻读。
不可重复读与幻读很像,但不可重复读在更新与删除上,A/B事务同时开始,读到同一个数值500,A将该值改为600后提交,B再读,发现该值变为了600,而且之后每次都是600,再也不可重复读到之前的500了。
幻读在插入上,并且不能通过加行锁来解决。当A锁住了所有元组,读取了一次所有元组,B虽然不能修改已有的元组,但是可以插入新的元组,B提交后,A读取数据发现了多出来的元组,就像幻觉一样。
之所以会出现不可重复读与幻读,是因为读已提交会读取它开始之后其他事务已经提交的数据。这些属于写-读冲突
为了防止出现不可重复读与幻读,设置了更高的事务隔离级别:可重复读,当事务的隔离级别为可重复读时,一旦事务开启后,它不会管别人是否已经提交了新的值,而是从始至终只会读取自己开启事务时的那个版本的数据(因为前面提到,元组的删除与更新并不是物理删除与更新,原来的元组数据还是存在在数据页中的,所以可以实现),对于新插入的元组,也是一样读不到,从而避免了不可重复读与幻读。
但是这种方法带来了新的问题:更新丢失,当A/B同时读到数值500后,A将其加100,改为600并提交,B不会读到这个600,而是一直读到500,他加上200后,改为700并提交。结果该值本来应该是800的,但是之后查到的一直是700,丢失了A事务对其的更新,即更新丢失。更新丢失又被称为写-写冲突。
为了避免更新丢失,可重复读以上的隔离等级在对数据进行修改前,会再次检查该值是否已被其他事务修改。如果是,则报出异常并中止事务。
可重复读、9.1版前的可串行化使用的是谁先更新谁生效的原则:
先更新的那个事务一定是最终赢家,所以B从来就没有机会执行修改语句(1中之所以需要等待,而不是立即报错,是因为B只能读已提交?)。但如果B是读已提交的隔离级别,则1/2两种情况B都能修改并成功提交。
但可重复读与SI还会导致写偏差,也就是串行化异常的一种,属于读-写冲突。所以pg9.1将可串行化中的SI升级为SSI,以实现真正的可串行化,避免写偏差。
写偏差示例1:比如公司需要至少一个人值班,然后现在有两个人在值班,他们不在一块,于是他们通过查表,发现原来还有另一个人值班,于是两个人同时溜了,导致没人值班。放在数据库中就是,A/B事务开始,读取表的元组数,发现2,于是A删去了一行,B删去了一行,都成功提交了,导致元组数不满足≥1的约束。原则上,后面的写之所以能成功是要依赖于前面读的值≥2才行,但导致冲突的原因是,当B执行写时,其实该事务前面读的条件已经不再满足了,但事务却不知道,于是成功提交了。
示例2:当A读取a时,B读取b,然后A修改b,B修改a,假设A对b的修改是基于a的值的,但B改了a,所以读依赖发生了改变。同样B对a的修改时基于b值,而A对其进行了修改,依赖的读也发生了改变,这个例子中其实存在两个写偏差了。
写偏差是写操作依赖的前面的读操作已不再满足导致的冲突,更新丢失其实是一种特殊的写偏差,它依赖的本元组前面读到的500已经不再满足了导致的。但因为更新丢失是在同一条元组上执行的,所以可重复读自己可以解决,但写偏差是在不同元组上执行的写,是可重复读自己解决不了的。于是引出了可串行化快照隔离这一事务隔离级别。
SSI希望让并发的事务在系统中也像串行执行那样,不会互相冲突,即使他们实际上是并行的。
pg使用SIREAD锁(谓词锁)的机制来避免读-写冲突。Pg中的锁分为行锁、页锁与表锁,当一个页面中的所有行都被上了锁,则以一个页锁替代这些行锁,如果一张表的所有页都上了锁,就以一个表锁替代这些页锁。
读-写冲突为三元组,包括SIREAD锁、分别读写该SIREAD锁的事务txid。
对于上面示例2,当A读a时,给它加行锁,B也一样给b加锁,A去写b时,发现有锁,B去写a时,也发现有锁,但此时,系统会让两个都正常写,然后先提交者生效,后提交者报错中止。谁先提交谁生效可能出现的情况如下:
也就是在一个事务提交生效之后,另外的事务更新或者提交都会报错,因为真正的赢家已经出现,但如果一个事务只修改但还没提交,这时候其他事务是可以修改的,因为它会觉得,诶,万一我先提交,生效的就是我了(就是上面的第3种情况)。
情况1的图示:

系统有时会判定一些假阳性的写偏差。
当目标列上没有索引,从而使用顺序扫描时,整张表都会被加锁。如A顺序扫描时,将整张表都锁了,但它的读依赖并不依赖整张表的数据,导致B事务在对该表中A不依赖的数据进行写时,判定为写偏差。A与B中的一个事务会被中止。

当元组的目标列上有索引,使用索引扫描时,那么指向目标元组的索引项所在的页也会被加锁。那么同时读取并写入该索引页中另一索引项所指的元组的事务会被判定与当前事务有读-写冲突。导致一个事务被中止。

上面两个假阳性的写偏差一个是由于顺序扫描锁了整张表,一个是由于索引扫描锁了整个页引起的。虽然会报这些假阳性导致一些不必要的事务中止,但这都是为了保证数据库中数据的约束不受破坏。
pg中的事务一般会是使用可重复读与可串行化。与可重复读相比,可串行化能避免写偏差,但会导致一些不必要的事务中止。而且动不动就锁一整张表或整个索引页,加大了事务执行的成本。