4、mysql事务、乐观锁,悲观锁与MVCC

事务的定义

  • 事务(Transaction):一个最小的不可再分的工作单元;通常一个事务对应一个完整的业务
  • 一个完整的业务需要批量DML(数据操纵语言 insert、update、delete) 语句共同联合完成
  • 事务只和DML语句有关,或者说DML语句才有事务。这个和业务逻辑有关,业务逻辑不同,DML语句的个数不同

事务的特性

事务具有ACID特性:原子性(A,atomicity)、一致性(C,consistency)、隔离性(I,isolation)、持久性(D,durabulity)。

  • 原子性: 事务内的所有操作要么都执行,要么都不执行。
  • 一致性: 事务开始和结束前后,数据都满足数据一致性约束,而不是经过事务控制之后数据变得不满足条件或业务规则。
  • 隔离性: 事务之间不能互影响,它们必须完全的各行其道,互不可见。
  • 持久性: 事务完成后,该事务内涉及的数据必须持久性的写入磁盘保证其持久性。当然,这是从事务的角度来考虑的的持久性,从操作系统故障或硬件故障来说,这是不一定的

事务的隔离级别

事务隔离级别SQL标准定义了4中隔离级别:

  • read uncommitted: 未提交读
  • read committed: 已提交读
  • repeatable read: 可重复读
  • serializable: 串行化

MySQL中默认的隔离级别是repeatable read
SQL Server和oracle的默认隔离级别都是read committed

事务特性(ACID)中的**隔离性(I,isolation)**就是隔离级别,它通过锁来实现。也就是说,设置不同的隔离级别,其本质只是控制不同的锁行为。例如操作是否申请锁,什么时候申请锁,申请的锁是立刻释放还是持久持有直到事务结束才释放等。

不同的隔离级别会造成不同的问题:

  • 脏读: 所谓脏读,就是指事务A读到了事务B还没有提交的数据。目前只有未提交读会有这个问题
  • 不可重复读: 是指在一个事务里面读取了两次某个数据,读出来的数据不一致。未提交读和已提交读会有这个问题,可重复读通过MVCC的快照读实现。
  • 幻读: 是在同一个事务中,两次读取的数据条数不一样,删除或者插入了新数据。MVCC可以解决部分幻读情况,加上间隙锁可以解决全部幻读情况

4、mysql事务、乐观锁,悲观锁与MVCC_第1张图片

前提概念


数据库并发场景有三种,分别为:

  • 读-读: 不存在任何问题,也不需要并发控制
  • 读-写: 有隔离性问题,可能遇到脏读,幻读,不可重复读
  • 写-写: 可能存更新丢失问题,比如第一类更新丢失,第二类更新丢失。

其中

  • 读写方式通过MVCC和锁机制一起控制。
  • 写写通过锁机制控制。

乐观锁和悲观锁的澄清

  • 无论是悲观锁还是乐观锁,他们本质上不是数据库中具体的锁概念,而是我们定义出来,用来描述两种类别的锁的思想。所以有了设计的分类,我们就可以通过这个分类去对数据库中具体的锁进行分门别类;
  • 不过数据库中的乐观锁更倾向叫乐观并发控制(OCC),悲观锁叫悲观并发控制(PCC),还有区别于乐观悲观锁的一种控制叫MVCC,多版本并发控制。
  • 也不要把乐观锁和悲观锁与数据库中的行锁,表锁,排他锁,共享锁混为一谈,他们并不是一个维度的东西;前者是一个锁思想,可以将后者根据是否进行趋近于乐观或悲观锁的思想进行分类
  • 乐观锁和悲观锁的概念不仅仅存在于数据库领域,可以说存在线程安全,存在并发的场景几乎都有乐观锁和悲观锁的适用场景,比如Java中也有乐观锁和悲观锁思想的具体实现;但不同领域的乐观和悲观锁的具体实现都不尽相同,要解决的问题也可能有所不一样

所以要是别人再问你乐观锁和悲观锁是什么,你千万别说它是一种具体的锁,它只是一种锁的设计思想,他可以有很多具体的实现类

悲观锁


什么是悲观锁?

在关系数据库管理系统里,悲观并发控制(又名“悲观锁”,Pessimistic Concurrency Control,缩写“PCC”)是一种并发控制的方法; 悲观锁指的是采用一种持悲观消极的态度,默认数据被外界访问时,必然会产生冲突,所以在数据处理的整个过程中都采用加锁的状态,保证同一时间,只有一个线程可以访问到数据,实现数据的排他性;通常,数据库的悲观锁是利用数据库本身提供的锁机制去实现的,数据库的悲观并发控制可以解决读-写冲突和写-写冲突,指在用加锁的方式去解决

悲观并发控制实际上是“先取锁再访问”的保守策略,为数据处理的安全提供了保证。但是在效率方面,处理加锁的机制会让数据库产生额外的开销,还有增加产生死锁的机会;另外,在只读型事务处理中由于不会产生冲突,也没必要使用锁,这样做只能增加系统负载;还有会降低了并行性,一个事务如果锁定了某行数据,其他事务就必须等待该事务处理完才可以处理那行数

悲观锁的实现

通常情况下,数据库的悲观锁就是利用数据库本身提供的锁去实现的

  • 外界要访问某条数据,那它就要首先向数据库申请该数据的锁(某种锁)
  • 如果获得成功,那它就可以操作该数据,在它操作期间,其他客户端就无法再操作该数据了
  • 如果获得失败,则代表同一时间已有其他客户端获得了该锁,那就必须等待其他客户端释放锁

当然数据库提供了非常多的锁,每种数据库提供的锁也不尽然相同,所以具体情况就要看是什么锁了,比如行锁,表锁等

优点与缺点

优点:

适合在写多读少的并发环境中使用,虽然无法维持非常高的性能,但是在乐观锁无法提更好的性能前提下,可以做到数据的安全性

缺点:

加锁会增加系统开销,虽然能保证数据的安全,但数据处理吞吐量低,不适合在读多写少的场合下使用

乐观锁


什么是乐观锁?

在关系数据库管理系统里,乐观并发控制(又名“乐观锁”,Optimistic Concurrency Control,缩写“OCC”)是一种并发控制的方法;乐观锁( Optimistic Locking ) 是相对悲观锁而言,乐观锁是假设认为即使在并发环境中,外界对数据的操作一般是不会造成冲突,所以并不会去加锁(所以乐观锁不是一把锁),而是在数据进行提交更新的时候,才会正式对数据的冲突与否进行检测,如果发现冲突了,则让返回冲突信息,让用户决定如何去做下一步,比如说重试,直至成功为止;数据库的乐观锁,并不是利用数据库本身的锁去实现的,可能是利用某种实现逻辑去实现做到乐观锁的思想,数据库的乐观并发控制要解决的是数据库并发场景下的写-写冲突,指在用无锁的方式去解决

CAS思想

其实数据库乐观锁的具体实现几乎就跟Java中乐观锁采用的CAS算法思想是一致,所以我们可以从CAS算法中学习到数据库乐观锁的设计:

CAS指令全称为Compare and Swap,它是系统的指令集,整个CAS操作是一个原子操作,是不可分割的。从具体的描述上,我们可以这么看CAS操作
CAS指令需要3个操作数,分别是内存位置V,旧的预期值A,和新值B。CAS指令执行时,当我们读取的内置位置V的现值等于旧预期值A时,处理器才会将新值B去更新内置位置V的值。否则它就不执行更新,但无论是否更新V的值,都会返回V的旧值。

我们通俗的放到代码层次上去理解i = 2; i++,就是说:

  • 首先线程1从内存位置V中读取到了值,保存并作为旧预期值A. (v = 2 ,a = 2)
  • 然后在因为i要进行++操作,系统会比较内存位置V的现值跟旧预期值A进行比较,既V = A。
  • 如果相等,B = i++ = 3 ,新值B就会对内存位置V进行更新,所以内存位置V的值就变成了B的值。
  • 如果不相等,则说明有其他的线程修改过了内存位置V的值,比如线程2在线程1修改i的值前就更新了i的值,所以线程1会更新变量i失败。但线程不会挂起,而是返回失败状态,等待调用线程决定是否重试或其他操作。(通常会重试直到成功)

数据库层的乐观锁实现也类似代码层面的实现

数据库中乐观锁的实现

通常乐观锁的实现有两种,但它们的内在都是CAS思想的设计:

方式一: 使用数据版本(version)实现

这是乐观锁最常用的一种实现方式。什么是数据版本呢?就是在表中增添一个字段作为该记录的版本标识,比如叫version,每次对该记录的写操作都会让 version+ 1。所以当我们读取了数据(包括version),做出更新,要提交的时候,就会拿取得的version去跟数据库中的version比较是否一致,如果一致则代表这个时间段,并没有其他的线程的也修改过这个数据,给予更新,同时version + 1;如果不一致,则代表在这个时间段,该记录以及被其他线程修改过了, 认为是过期数据,返回冲突信息,让用户决定下一步动作,比如重试(重新读取最新数据,再过更新)

update table set num = num + 1 , version = version + 1 where version =#{version} and id = #{id}

方式二: 使用时间戳(timestamp)实现

表中增加一个字段,名称无所谓,比如叫update_time, 字段类型使用时间戳(timestamp)
原理和方式一一致,也是在更新提交的时检查当前数据库中数据的时间戳和自己更新前取到的时间戳是否一致,如果一致则代表此刻没有冲突,可以提交更新,同时时间戳更新为当前时间,否则就是该时间段有其他线程也更新提交过,返回冲突信息,等待用户下一步动作。

update table set num = num + 1 ,update_time = unix_timestamp(now()) where id = #{id} and update_time = #{updateTime}

但是我们要注意的是,要实现乐观锁的思想的同时,我们必须要要保证CAS多个操作的原子性,即获取数据库数据的版本,拿数据库的数据版本与之前拿到的版本的比较,以及更新数据等这几个操作的执行必须是连贯执行,具有复合操作的原子性;所以如果是数据库的SQL,那么我们就要保证多个SQL操作处于同一个事务中

优点与缺点

优点:
在读多写少的并发场景下,可以避免数据库加锁的开销,提高Dao层的响应性能,其实很多情况下,我们orm工具都有带有乐观锁的实现,所以这些方法不一定需要我们人为的去实现

缺点:
在写多读少的并发场景下,即在写操作竞争激烈的情况下,会导致CAS多次重试,冲突频率过高,导致开销比悲观锁更高

MVCC多版本并发控制


注意:MVCC主要用于解决读写冲突,提高性能,也能解决部分幻读的问题。

什么是mvcc?

MVCC全称Multi-Version Concurrency Control,即多版本并发控制。MVCC是一种并发控制的方法,一般在数据库管理系统中,实现对数据库的并发访问,在编程语言中实现事务内存。

MVCC在MySQL InnoDB中的实现主要是为了提高数据库并发性能,用更好的方式去处理读-写冲突,做到即使有读写冲突时,也能做到不加锁,非阻塞并发读。

当前读和快照读

当前读

像select lock in share mode(共享锁), select for update ; update, insert ,delete(排他锁)这些操作都是一种当前读,为什么叫当前读?就是它读取的是记录的最新版本,读取时还要保证其他并发事务不能修改当前记录,会对读取的记录进行加锁。

当前读会更新read view视图

快照读

像不加锁的select操作就是快照读,即不加锁的非阻塞读;快照读的前提是隔离级别不是串行级别,串行级别下的快照读会退化成当前读;之所以出现快照读的情况,是基于提高并发性能的考虑,快照读的实现是基于多版本并发控制,即MVCC,可以认为MVCC是行锁的一个变种,但它在很多情况下,避免了加锁操作,降低了开销;既然是基于多版本,即快照读可能读到的并不一定是数据的最新版本,而有可能是之前的历史版本

说白了快照读就是MVCC思想在MySQL的具体非阻塞读功能实现,整个MVCC多并发控制的目的就是为了实现读-写冲突不加锁,提高并发读写性能,而这个读指的就是快照读, 而非当前读,当前读实际上是一种加锁的操作,是悲观锁的实现

实现原理

MVCC的目的就是多版本并发控制,在数据库中的实现,就是为了解决读写冲突,它的实现原理主要是依赖记录中的 3个隐式字段,undo日志 ,Read View 来实现的。

隐式字段

每行记录除了我们自定义的字段外,还有数据库隐式定义的DB_TRX_ID,DB_ROLL_PTR,DB_ROW_ID等字段

  • DB_TRX_ID

6byte,最近修改(修改/插入)事务ID:记录创建这条记录/最后一次修改该记录的事务ID

  • DB_ROLL_PTR

7byte,回滚指针,指向这条记录的上一个版本(存储于rollback segment里)

  • DB_ROW_ID

6byte,隐含的自增ID(隐藏主键),如果数据表没有主键,InnoDB会自动以DB_ROW_ID产生一个聚簇索引

  • 实际还有一个删除flag隐藏字段, 既记录被更新或删除并不代表真的删除,而是删除flag变了

4、mysql事务、乐观锁,悲观锁与MVCC_第2张图片

如上图,DB_ROW_ID是数据库默认为该行记录生成的唯一隐式主键,DB_TRX_ID是当前操作该记录的事务ID,而DB_ROLL_PTR是一个回滚指针,用于配合undo日志,指向上一个旧版本

undo日志

undo log主要分为两种:

  • insert undo log

代表事务在insert新记录时产生的undo log, 只在事务回滚时需要,并且在事务提交后可以被立即丢弃

  • update undo log

事务在进行update或delete时产生的undo log; 不仅在事务回滚时需要,在快照读时也需要;所以不能随便删除,只有在快速读或事务回滚不涉及该日志时,对应的日志才会被purge线程统一清除

purge

  • 从前面的分析可以看出,为了实现InnoDBMVCC机制,更新或者删除操作都只是设置一下老记录的deleted_bit,并不真正将过时的记录删除。
  • 为了节省磁盘空间,InnoDB有专门的purge线程来清理deleted_bittrue的记录。为了不影响MVCC的正常工作,purge线程自己也维护了一个read view(这个read view相当于系统中最老活跃事务的read view);如果某个记录的deleted_bittrue,并且DB_TRX_ID相对于purge线程的read view可见,那么这条记录一定是可以被安全清除的。

对MVCC有帮助的实质是update undo log ,undo log实际上就是存在rollback segment中旧记录链,它的执行流程如下:

一、 比如一个有个事务插入persion表插入了一条新记录,记录如下,name为Jerry, age为24岁,隐式主键是1,事务ID和回滚指针,我们假设为NULL
4、mysql事务、乐观锁,悲观锁与MVCC_第3张图片

二、 现在来了一个事务1对该记录的name做出了修改,改为Tom

  • 在事务1修改该行(记录)数据时,数据库会先对该行加排他锁
  • 然后把该行数据拷贝到undo log中,作为旧记录,既在undo log中有当前行的拷贝副本
  • 拷贝完毕后,修改该行name为Tom,并且修改隐藏字段的事务ID为当前事务1的ID, 我们默认从1开始,之后递增,回滚指针指向拷贝到undo log的副本记录,既表示我的上一个版本就是它
  • 事务提交后,释放锁

4、mysql事务、乐观锁,悲观锁与MVCC_第4张图片

Read View(读视图)

什么是Read View?

4、mysql事务、乐观锁,悲观锁与MVCC_第5张图片

什么是Read View,说白了Read View就是事务进行快照读操作的时候生产的读视图(Read View),在该事务执行的快照读的那一刻,会生成数据库系统当前的一个快照,记录并维护系统当前活跃事务的ID(当每个事务开启时,都会被分配一个ID, 这个ID是递增的,所以最新的事务,ID值越大)

所以我们知道 Read View主要是用来做可见性判断的, 即当我们某个事务执行快照读的时候,对该记录创建一个Read View读视图,把它比作条件用来判断当前事务能够看到哪个版本的数据,既可能是当前最新的数据,也有可能是该行记录的undo log里面的某个版本的数据。

Read View遵循一个可见性算法,主要是将要被修改的数据的最新记录中的DB_TRX_ID(即当前事务ID)取出来,与系统当前其他活跃事务的ID去对比(由Read View维护),如果DB_TRX_ID跟Read View的属性做了某些比较,不符合可见性,那就通过DB_ROLL_PTR回滚指针去取出Undo Log中的DB_TRX_ID再比较,即遍历链表的DB_TRX_ID(从链首到链尾,即从最近的一次修改查起),直到找到满足特定条件的DB_TRX_ID, 那么这个DB_TRX_ID所在的旧记录就是当前事务能看见的最新老版本

那么这个判断条件是什么呢?
4、mysql事务、乐观锁,悲观锁与MVCC_第6张图片

一张源码图,如上,它是一段MySQL判断可见性的一段源码,即changes_visible方法(不完全哈,但能看出大致逻辑),该方法展示了我们拿DB_TRX_ID去跟Read View某些属性进行怎么样的比较。

在展示之前,我先简化一下Read View,我们可以把Read View简单的理解成有三个全局属性

  • trx_list: 一个数值列表,用来维护Read View生成时刻系统正活跃的事务ID
  • up_limit_id: 记录trx_list列表中事务ID最小的ID
  • low_limit_id: ReadView生成时刻系统尚未分配的下一个事务ID,也就是目前已出现过的事务ID的最大值+1

比较逻辑:

  • 首先比较DB_TRX_ID < up_limit_id, 如果小于,则当前事务能看到DB_TRX_ID 所在的记录,如果大于等于进入下一个判断
  • 接下来判断 DB_TRX_ID 大于等于 low_limit_id , 如果大于等于则代表DB_TRX_ID 所在的记录在Read View生成后才出现的,那对当前事务肯定不可见,如果小于则进入下一个判断
  • 判断DB_TRX_ID 是否在活跃事务之中,trx_list.contains(DB_TRX_ID),如果在,则代表我Read View生成时刻,你这个事务还在活跃,还没有Commit,你修改的数据,我当前事务也是看不见的;如果不在,则说明,你这个事务在Read View生成之前就已经Commit了,你修改的结果,我当前事务是能看见的。

整体流程

我们在了解了隐式字段,undo log, 以及Read View的概念之后,就可以来看看MVCC实现的整体流程是怎么样了

整体的流程是怎么样的呢?我们可以模拟一下

  • 当事务2对某行数据执行了快照读,数据库为该行数据生成一个Read View读视图,假设当前事务ID为2,此时还有事务1和事务3在活跃中,事务4在事务2快照读前一刻提交更新了,所以Read View记录了系统当前活跃事务1,3的ID,维护在一个列表上,假设我们称为trx_list
事务1 事务2 事务3 事务4
事务开始 事务开始 事务开始 事务开始
修改且已提交
进行中 快照读 进行中
  • Read View不仅仅会通过一个列表trx_list来维护事务2执行快照读那刻系统正活跃的事务ID,还会有两个属性up_limit_id(记录trx_list列表中事务ID最小的ID),low_limit_id(记录trx_list列表中事务ID最大的ID,也有人说快照读那刻系统尚未分配的下一个事务ID也就是目前已出现过的事务ID的最大值+1,我更倾向于后者 ,所以在这里例子中up_limit_id就是1,low_limit_id就是4 + 1 = 5,trx_list集合的值是1,3,Read View如下图

4、mysql事务、乐观锁,悲观锁与MVCC_第7张图片

我们的例子中,只有事务4修改过该行记录,并在事务2执行快照读前,就提交了事务,所以当前该行当前数据的undo log如下图所示;我们的事务2在快照读该行记录的时候,就会拿该行记录的DB_TRX_ID去跟up_limit_id,low_limit_id和活跃事务ID列表(trx_list)进行比较,判断当前事务2能看到该记录的版本是哪个。
4、mysql事务、乐观锁,悲观锁与MVCC_第8张图片

所以先拿该记录DB_TRX_ID字段记录的事务ID 4去跟Read View的的up_limit_id比较,看4是否小于up_limit_id(1),所以不符合条件,继续判断 4 是否大于等于 low_limit_id(5),也不符合条件,最后判断4是否处于trx_list中的活跃事务, 最后发现事务ID为4的事务不在当前活跃事务列表中, 符合可见性条件。
所以事务4修改后提交的最新结果对事务2快照读时是可见的,所以事务2能读到的最新数据记录是事务4所提交的版本,而事务4提交的版本也是全局角度上最新的版本。

MVCC与幻读之间的关系

幻读: 同一个事务中,两次查询的数据条数不一样,可能是其他事务删除或者添加了新的数据

MVCC可以解决部分幻读的问题,不能解决当前产生的的幻读。

MVCC解决幻读的情况

假设有个表:a_test,当前数据为。
4、mysql事务、乐观锁,悲观锁与MVCC_第9张图片

事务一
4、mysql事务、乐观锁,悲观锁与MVCC_第10张图片

事务二:
4、mysql事务、乐观锁,悲观锁与MVCC_第11张图片

执行结果:
4、mysql事务、乐观锁,悲观锁与MVCC_第12张图片

先执行事务一,在事务一执行sleep的时候,执行事务二,如果事务一种第11、13行都和第7行的执行结果一样则不存在幻读。

原因:
事务一在第7行快照读的时候生成了read view视图,之后的视图不变,查询sql一样,则第11、13行查询时的可见数据不变。

MVCC产生幻读的情况

和上面案例相同,不同的是在事务一种的12行进行的数据的更新,更新的返回也会影响到事务二新插入的数据
4、mysql事务、乐观锁,悲观锁与MVCC_第13张图片

执行后发现第13行查询的数据,也查出了新增的id为6的数据,产生了幻读。
4、mysql事务、乐观锁,悲观锁与MVCC_第14张图片

原因:
执行了当前读的操作,会更新操作数据的read view的视图,新插入的数据则对事物一可见了,下次再查询的时候则有可能产生幻读。

解决方案

查询可以使用当前读查询,使用间隙锁解决幻读问题。
4、mysql事务、乐观锁,悲观锁与MVCC_第15张图片

上图操作的时候,会锁住范围内的数据,事务二插入这个范围内的数据的时候会等待间隙锁的释放。

所以只有事物一执行完,事务二才会执行。

你可能感兴趣的:(mysql,java,mysql)