闲聊数据库的并发控制

gosh

1.概述

最近在学习分布式相关知识,接触到分布式事务。其实本质就是在分布式环境下,对于事务处理可以做到和单节点一样的效果。那我们可能就需要对单节点事务有足够深的理解才行,但是我们很清楚如果数据库的事务都串行执行,确实可以保证各种隔离级别,但是对于性能影响是非常大的。所以该文主要聊聊当前数据库为了提升性能,执行并发事务的一些策略。也就是并发控制。

2.概述并发事务

为了避免串行化并发事务,于是引入了并发事务,但是我们可以想想,如果不进行一些并发控制,多线程直接去操作数据库必然会造成一些不可预知的后果。毕竟我们在应用程序中多线程操作内存都会有奇怪的事情发生。

但我们清楚,在java中进行并发控制是通过加锁实现(乐观锁、悲观锁和CAS),数据库也是如此。数据库有乐观并发控制、悲观并发控制、还有就是MVCC。MVCC其实就是和前面两个任意一个结合起来,提升数据库的读性能。

3.乐观并发事务

其实是使用乐观锁去实现,乐观锁并不是真正的锁。只不过就是我们假设我们可以更新成功,再尝试的过程中,如果失败,那么就需要会滚。具体分为两种方式

(1)基于时间戳的方式

对于每个事务,我们都会为其生成全局的时间戳,我们保证这个时间戳是按递增增长的,有一定的顺序的。在single情况下,这个是很容易满足的,但是在分布式环境下就需要一些措施了。
拥有了时间戳,我们就能保证事务执行的顺序是按照时间戳顺序来执行。这样,其实每个数据项,都会有两个实现戳,一个是读,一个是写。
在读写的时候,我们不需要考虑其他事务在做什么,我们只需要保证时间戳的顺序,那么就不会有问题。

6EBDCDF6-F0EC-4F05-9723-FBF741406853.png
每次读取的时候会判断这个时间戳,如果时间戳大于当前值,说明比他时间大的事务对其有操作,那么我们就只能回滚事务,然后会给回滚的事务设置一个新的时间戳,重新执行事务。

(2)基于验证的方式

主要是分为两个阶段,第一个阶段会执行事务的所有读操作和写操作,但是写的数据只是会存储到临时变量中,并不会直接更新数据库数据。第二个阶段其实就是通过CAS的语义将数据更新到数据库,这其实包括了验证和写入。验证阶段就需要判断涉及到的数据是否被其他事务影响。如果有那么就需要回滚,如果没有则成功。验证和写入操作是原子的。只要成功写入,后续冲突的事务就需要回滚。
其实这里我有个疑惑,如果是同一个事务,我怎么保证验证和写入原子性呢?

4.悲观并发事务

其实就是加锁来实现。对于数据处理的过程中都会被锁定。保证并发安全。
为了降低锁的粒度,数据库有两种锁,共享锁和互斥锁。
共享锁也就是多个事务可以同时持有,因为对于读取操作,并不会影响数据库的数据,但是如果需要update语义,就必须持有互斥锁。

两阶段协议(2PL)

保证了事务的串行化执行,其实主要分为增长阶段和缩减阶段。增长阶段不能释放锁,也就是一直加锁的过程。
比如同一个事务需要更新两条数据,那么先需要持A的锁,然后执行更新,再去获取B锁,这个过程就是增长阶段,然后都更新完成,会进入缩减阶段。提交后释放所有的锁。释放过程中不能再去获取其它锁。
众所周知,如果是通过2Pl,会引起死锁,也就是对于资源不正确的访问顺序导致系统处于僵死状态。

避免死锁

想要做到避免死锁是很难的,因为事务并一开始就很难确定需要哪些锁,如果确定了我们可以通过顺序加锁来解决死锁。但是会导致一开始锁定很多资源,影响性能

解决死锁

如果我们不忍心影响性能做到避免,那么我就需要在遇到死锁的时候,去解决死锁。数据库采用抢占和事务回滚的方式去解决死锁。但是对于回滚哪个事务,我们就需要权衡。
比如我们可能想要回滚链路相对较短的事务,因为这样会相对提高性能。但如果一直这样,会导致事务饥饿问题,因为锁都是抢占访问的。所以可以通过加入时间戳解决饥饿问题。

对于msyql的innodb存储引擎,引入了事务检查点,所以事务的回滚,不需要全部回滚,只需要回滚到检查点,解决死锁问题,继续执行即可。

4.多版本并发控制

基于乐观锁的实现,对于读多写少的问题,我们性能会有可见的提升,但是如果冲突比较多的话,就会导致大量的事务回滚,影响性能。
但是对于悲观锁的实现,不但会产生死锁问题,对于读写操作,我们并不能真正进行并发操作。因为执行事务的时候需要加锁,加锁的话,对于同一个数据的读取,可能因为互斥锁导致线程的阻塞。
实际场景中,大部分只是读取数据,甚至我们能接受读取到已经修改的数据,也就是读已提交。

通过多版本并发控制(MVCC),大多数数据库实现了可重复度的隔离级别。并且提升了读取性能。其实MVCC就是通过快照读再结合悲观或者乐观并发控制实现的。

通俗的讲其实就是在每次写操作的时候,并不会直接覆盖原有的数据,而是新建一条数据,也就是数据有多个版本。在我们读取的时候我们保证只会读取之前的版本。也就是一致性读。

Mysql的MVCC

通过快照读和悲观锁实现的。

其实在Mysql的innodb存储引擎,是有一些隐藏字段,这个其实就是为了MVCC而留的。
89A0C349-7F5A-4B79-AD2A-EC0E8C167D8F.png
如上图

多版本主要体现在不同版本的统一数据,其事务ID是不一样的。

对于Mysql操作

  • insert:创建一条新的记录,DB_TRX_ID为当前事务的id,回滚指针为null
  • update 复制旧数据,更新事务Id和回滚指针。
  • delete:设置delete标志位,更新事务Id

一致性读(快照读)

我们首先明白一个概念,对于事务的起始点,其实不是以begin为基准,而是以其第一条语句为基准的。Mysql的一致性读是通过read view实现的。
使用read view主要是用来进行可见性判断的。其实主要就是本事务不可见的其它活跃事务的列表。
满足隔离性,就需要保证不可见,所以我们拥有了这个列表,我们就可以做一些事情。

在read view这个实现中,有两个变量

    trx_id_t    low_limit_id;

                /*!< The read should not see any transaction

                with trx id >= this value. In other words,

                this is the "high water mark". */

    trx_id_t    up_limit_id;

                /*!< The read should see all trx ids which

                are strictly smaller (<) than this value.

                In other words,

                this is the "low water mark". */
low_limit_id主要就是最早的事务ID。

up_limit_id并不是最迟的,而是下一个事务的事务id,比如当前最大的id为1008,那么up_limit_id就是1008+1

有了这么两个变量,再结合read view的活跃事务列表。我们就可以实现快照读。如何实现呢,就是通过可见性判断

假设要读取的行的最后一次提交的事务ID(最稳定的事务ID,因为每次对数据行的更新都会更新事务ID)为trx_id。

那么比较过程如下

1.trx_id < up_limit_id => 此记录的最后一次修改在read view创建之前,跳转到步骤5;

也就是说最后一次修改的小于当前最小的事务Id,那么说明数据是可见的。因为该事务之前,数据已经提交。

2.trx_id > low_limit_id => 此记录的最后一次修改在read view创建之后,跳转到步骤4;
说明在该事务之后,有其他事务对该条记录进行了提交,那我们就不能读取这条记录。如果读取了,就会出现不可重复读问题。

3.up_limit_id <= trx_id <= low_limit_id => 从up_limit_id到low_limit_id进行遍历,如果trx_id等于他们之中的某个事务id的话,表示该记录的最后一次修改尚未保存(因为事务还是活跃状态),跳转到步骤4。否则跳转到步骤5;(这里避免了一个问题,也就是自己的修改是可以读取到的)

4.从此记录的DB_ROLL_PTR指针所指向的undo log(此记录的上一次修改),将undo log的DB_TRX_ID赋值给trx_id,跳转到步骤1重新开始计算可见性;

5.如果此记录的DELELE_BIT为false,说明该记录未被删除,可以返回,否则不返回

对于Innodb
RR级别,就是通过read view 解决的,也就是说在事务的第一条select操作执行的时候,会创建read view,也就是后面的读取操作都会基于 read view,这样就会保证整个事务过程中读取的数据都是一致可靠的。

RC级别,Innodb每次select的时候都会创建新的 read view,也就是说,他会读取到新事物提交的数据,如果使用老的 read view,意味着那个活跃事务列表不会变,那么如果有事务提交了,也是不可见的。

PostgreSQL中MVCC的具体实现

PostgreSQL采用乐观锁进行并发控制,其实就是基于实现戳协议,和innodb一样,也是有一个事务id。每个数据都有读写的时间戳,其实就是事务id,在读操作执行的时候,并不会阻塞,根据可见性直接返回,在写操作执行的时候,事务的时间戳一定要大于等于数据的时间戳,不然只能回滚。

PostgreSQL的乐观锁其实就是不会删除数据,每次更新都会创建新的数据,并更新事务时间戳,而mysql则会更新数据,将旧数据写入redolog,通过回滚指针链接,等事务提交,会置空对应的回滚指针。

PostgreSQL的优点就是,无论执行多少操作,事务都是可以通过回滚完成。
缺点就是,因为每次更新都会产生新的数据,我们需要对数据进行清理。不然导致查询扫描的数据增加而影响性能。

参考资料

https://juejin.im/post/5c519bb8f265da617831cfff

https://draveness.me/database-concurrency-control

http://mysql.taobao.org/monthly/2017/10/01/

阅读 397
6 声望
1 粉丝
0 条评论
你知道吗?

6 声望
1 粉丝
宣传栏