postgreSQL——事务

(一)事务的性质:

1.ACID

原子性:就是将一组操作看作一个原子操作,这一组操作和原子一样不可再分,这就需要数据库能够提供一种机制:事务如果没有正常完成,那么数据应该能够回滚到事务开始之前的状态。事务回滚的方法各不相同,有些数据库通过记录Undo日志的方式对异常终止的事务进行回滚,有些数据库采用保留旧版本和获取快照的方式来判断数据的可见性。无论如何,为了事务能够回滚,都需要保留被修改元组的前像,无非是保存的位置不同。

持久性:事务提交之后,数据应该是事务执行之后的正确反映,除非有其他事务改变当前的状态,否则数据库就应该维持当前的数据状态。即使在软件错误或硬件故障的情况下,都应该能保证数据的状态不变,即数据不错、不丢,也就是保证数据的持久性,目前主要通过记录WAL(Write Ahead Log,预写式日志)的方式来保证事务的持久性。

隔离性:事务的隔离性要求在多个事务并发执行的情况下,事务2要么能够看到事务1发生之前的状态,要么能够看到事务1提交之后的状态,不能看到事务1在执行过程中的状态。

一致性:在应用层面,通常需要借助完整性约束检查来保证数据的一致性。

2.事务的性质和实现的方法

事务的性质实现方法
原子性 并发控制,故障恢复
隔离性 并发控制
持久性 故障恢复
一致性 SQL的完整性约束(主键约束、外键约束)

常用的并发控制技术有基于锁的并发控制和基于时间戳的并发控制,PostgreSQL数据库针对DDL语句采用两阶段锁技术,而针对DML语句则采用多版本控制技术(Multi-Version Concurrency Control,MVCC)。PostgreSQL数据库的故障恢复采用WAL日志的方式来实现,目前主要支持Redo日志,通过Redo日志和MVCC可以保证事务读写的一致性。

当前,PostgreSQL社区也在研发一个新的存储引擎——Zheap,它通过记录Undo日志来回滚异常终止的事务,这样就可以把元组的历史版本从原来的Heap页面中转移到Undo日志中,避免了在Heap中保留历史版本导致占用磁盘空间膨胀的问题。

 

(二)事务的隔离级别:

事务的原子性、一致性、持久性被破坏是不可容忍的(PostgreSQL的事务异步提交属于对事务持久性的放松),但可以考虑在事务的隔离性上做一些妥协。ANSI SQL标准中将事务的隔离性分成了不同的级别,分别是读未提交、读已提交、可重复读和可串行化,不同的隔离级别允许不同的异常现象发生。

异常现象:

脏写:事务1在写,事务2基于事务1的基础上也在写,此后事务1回滚了,那么事务2的写是错误的。

脏读:事务1在写,这时事务2读取了事务1修改后的内容,但是此后事务1回滚了,那么事务2读取的结果是错误的。

不可重复读,事务1读了结果,此时事务2修改或删除了数据,并且进行了提交,此后事务1再次读取时发现前后结果不一致。

幻读:事务1读取结果,这时事务2插入或者删除了一部分数据,这部分数据满足事务1的查询条件,并且事务2提交了,事务1再次读取时发现结果集变多或者变少了。

丢失更新:事务T1先是读取了元组中的某个值,然后事务T2修改了这个值,此时事务T1并不知道事务T2修改了这个值,它还是根据自己以前读到的值去做更新(例如做+1操作),这将会覆盖事务T2更新的值,产生了丢失更新异常。

丢失更新和脏写的最主要区别在于: 丢失更新是基于先前读到的值而后进行更新的,事务1 先读到更新的这段时间内并不知道其它事务的更新操作。

而脏写则强调的是不同写操作之间对值的覆盖导致异常,根本原因是后面的写基于前面的写,但是前面的写回滚了。

读偏序:假设x和y具有x+y>0的一致性约束,事务T1读取了元组x的值。这时事务T2同时更新了x和y的值,然后事务T2提交,事务T1再去读y的值,那么T1读到的x和y的值就可能违反了一致性约束,因为它读到的x是事务T2提交之前的值,而y则是事务T2提交之后的值。此时x+y<0,违反了一致性约束。

写偏序:假设x和y具有x+y>0的一致性约束,事务T1读取了x=50和y=50,然后事务T2读取x=50和y=50。事务T2更新x的值为-40,此时它认为x+y =10 >0,满足一致性约束。事务T1更新了y的值为-40,此时它也认为x+y = 10 > 0 满足一致性约束。但两个事务提交之后,x+y = -80 < 0,违反了一致性约束(注:严格的两阶段提交,即S2PL,不会出现这种异常,因为在事务T1和T2读取x、y的值时会加读锁,并且读锁会持续到事务提交,由于读写冲突,S2PL能保证两个事务无法同时更新x、y的值,会出现锁等待或者死锁,在SSI相关的章节中会继续介绍写偏序异常,因为在MVCC机制下,读写互相不阻塞,需要借助SSI方法解决该异常),

事务的可串行化:指事务虽然是并发交叉执行的,但执行结果和串行执行的结果一致。事物的并发控制机制主要是防止出现由于事务的并发执行导致的异常现象。PostgreSQL数据库默认的隔离级别是读已提交,同时可以支持可重复读和可串行化。用户可以在事务开始时通过设置ISOLATION LEVEL来指定隔离级别,也可以通过GUC参数transaction_isolation来指定。

事务的隔离级别P0脏写P1脏读P2不可重复读P3幻读
读未提交 不可能出现 可能出现 可能出现 可能出现
读已提交 不可能出现 不可能出现 可能出现 可能出现
可重复读 不可能出现 不可能出现 不可能出现 可能出现
可串行化 不可能出现 不可能出现 不可能出现 不可能出现

读已提交(Read Committed)目前,大部分商业数据库的默认隔离级别是读已提交,这个隔离级别既能满足大部分用户的需求,也能最大限度地提高并发度。

可重复读(Repeatable Read)PostgreSQL的可重复读隔离级别在事务开始时即获得快照,在事务的执行过程中都使用这个快照作为基准来读取数据,因此即使其他并发的事务已经提交,可重复读也保证不会读到这些数据。

可串行化(Serializable)老版本的PostgreSQL使用SI(Snapshot Isolation)隔离级别来替代可串行化隔离级别,但SI隔离有写偏序异常,直到SSI(Serializable Snapshot Isolation)实现之后才解决了该问题。

 

(三)并发控制

事务的可串行化:指事务虽然是并发交叉执行的,但执行结果和串行执行的结果一致。

并发控制还可以分为乐观并发控制和悲观并发控制,这两种控制思想是从“何时检测冲突”的角度来定义的。

乐观并发控制属于事后检测冲突的并发控制,通常分为以下3个阶段。

• 读阶段(Read Phase):事务维护一个读集合和一个写集合,并且将对对象的写操作存放到私有空间中。

• 有效性确认阶段(Validation Phase):在事务提交时,每个事务都需要检查自己的读集合和写集合,这两个集合记录了事务运行的历史信息,如果发现某个事务违反了可串行化原则,那么这个事务终止。

• 写阶段(Write Phase):在有效性确认之后,对事务在私有空间中的数据进行应用,事务提交成功。

 

冲突等价:假设有多个事务并发执行,一个调度S通过交换其中不冲突的操作得到一个等价的新的调度S',就称S和S'是冲突等价的。

冲突可串行化:如果一个调度和某一个串行调度是冲突等价的,那么这个调度就是冲突可串行化的。

悲观并发控制则在事先检测冲突,它在识别到冲突的操作后,就可以尝试阻塞冲突的一方,从而实现事务的冲突可串行化。例如两阶段锁技术就属于悲观并发控制的范畴,它在读取数据库对象时给对象加共享锁,

而在修改数据库对象时对对象加排他锁,这样就能保证事务在并发的过程中不会出现各种异常现象。

两阶段锁:要实现冲突可串行化的调度,可以采用对数据库对象封锁的方式。当读取一个数据库对象时,可以对这个对象加共享锁(S锁);当修改一个数据库对象时,可以对这个对象加排他锁(X锁)。

加锁操作需要在访问数据库对象之前执行,而放锁的时机就比较微妙了。如果要实现一个真正的可串行化调度,那么需要借助两阶段锁机制。两阶段锁协议将事务的封锁过程分成了以下两个阶段。

• 增长阶段(Growing Phase):事务可以尝试申请任何类型的锁,但是这个阶段不允许释放锁。

• 收缩阶段(Shrinking Phase):事务可以放锁,但是禁止再申请新的锁。

从两阶段锁的描述中可以看出,它的增长阶段和收缩阶段是严格区分的,也就是说加锁和放锁不会穿插进行。

 

(四) 锁

4.1 锁的分类:

在PostgreSQL数据库中,锁可以分为以下3个层次。

• 自旋锁(Spin Lock):是一种和硬件结合的互斥锁。它借用了硬件提供的原子操作的原语来对一些共享变量进行封锁,通常适用于临界区比较小的情况。

• 轻量锁(Lightweight Lock):在PostgreSQL数据库中,大量使用共享内存来保存数据结构。不同的进程需要对这些数据结构频繁地进行读写操作,轻量锁负责保护这些共享内存中的数据结构。

• 常规锁(Regular Lock):对数据库对象加锁,PostgreSQL的两阶段锁就是借助常规锁实现的。

4.2轻量锁:

轻量锁是一种读写锁,有共享和排他两种模式。

image.png

如果要申请的是一个排他锁,而当前锁的持有者是共享锁,这时候申请者就会被安排到等待队列中。轻量锁通过LWLockRelease函数来释放封锁。释放封锁的时候,如果发现锁已经没有持有者,则要考虑唤醒等待队列中的等待者,此时会分成两种情况:

        • 如果等待队列中的第一个申请者申请的是排他锁,则只有这一个申请者被唤醒,其他申请者需要继续等待。

        •  如果等待队列中的第一个申请者申请的是共享锁,那么等待队列中的所有共享锁都可以被唤醒,只剩排他锁继续等待。

4.3常规锁:

常规锁根据封锁对象的不同,它又分成了多种不同的粒度,例如可以对表、页面、元组、事务ID等分别加锁。

以最常用的表锁为例,当不同的事务操作一个表时,会尝试通过表的Oid来构造Lock Tag。这样每个数据库对象都会有一个唯一标识,然后根据这个唯一标识到锁表中申请锁。

4.3.1 常规锁:

image.png

常规锁的相容性矩阵:

image.png

常规锁的弱锁:

image.png

常规锁的强锁和弱锁的相容性矩阵:

image.png

在PostgreSQL中,常规锁主要保存在以下4个位置。

• 本地锁表:对重复申请的锁进行计数,避免频繁访问主锁表和进程锁表,相当于一层“缓存”。

• 快速路径(Fast Path):将对“弱锁”的访问保存到本进程,避免频繁访问主锁表和进程锁表。

• 主锁表:保存一个锁对象的所有相关信息。

• 进程锁表:保存一个锁对象中与当前会话(进程)相关的信息。

4.3.2 常规锁流程:

1 如果当前申请的锁模式与其它正在等待的锁模式冲突,则必须进入等待状态 。

 反证法:例如事务T0获取了锁l0,事务T1申请锁 l1时发现与 l0冲突则进入等待队列,此时事务T1还持有者锁 l3.   随后事务T2申请锁l2,虽然l2与事务T0正在持有的锁 l0不冲突,但是与正在等待的事务T1待申请的l1发生冲突。 如果没有放入等待队列就执行了,由于事务T2在执行过程中要申请l3, 但是l3被事务T1持有。T1要申请的l1与T2的l2冲突,所以会得不到执行,也不会释放l3,这时T1与T2就形成了死锁。

2 如果没有事务等待这个锁,则检查当前申请的锁模式,是否与其它事务已持有的锁冲突。如果出现冲突,则需要放入等待队列。

将当前事务(或进程)加入等待队列也是有技巧的。通常而言,等待队列应该按照锁的申请顺序排列,因此当前事务应该加入等待队列的队尾。但是如果本事务A除了当前申请的锁模式,已经持有了这个对象的其他锁模式,而且等待队列中某个事务B所等待的锁模式和当前事务A持有的锁模式冲突,这时候如果把事务A插入这个等待者B的后面,就隐含着死锁的可能,所以可以考虑把事务A插入这个等待者的前面。

 

posted @   小兵要进步  阅读(1662)  评论(0编辑  收藏  举报
相关博文:
阅读排行:
· 分享4款.NET开源、免费、实用的商城系统
· 全程不用写代码,我用AI程序员写了一个飞机大战
· MongoDB 8.0这个新功能碉堡了,比商业数据库还牛
· 记一次.NET内存居高不下排查解决与启示
· 白话解读 Dapr 1.15:你的「微服务管家」又秀新绝活了

侧边栏
点击右上角即可分享
微信分享提示