InnoDB关于事务、锁、MVCC专题

目录

并发所带来的的问题

脏写

B事务修改且已经提交的数据被另外一个事务回滚(分析图如下)

如何解决脏读呢?

这里简单说一下,就是事务A开启后就给这个记录上锁,事务B要操作相同的记录只能等待拿到锁

因为数据库的操作是在内存中执行的,在 Buffer Pool 中执行所以就算是这种串行也不会特别慢

 

 

 脏读

B事务读取到了A事务尚未提交的数据(分析图如下)

不可重复读

A事务读到了B事务已经提交的数据,即A事务在B事务提交之前和提交之后读取到的数据内容不一致(AB事务操作的是同一条数据)(分析图如下)

 

幻读

B事务读到了A事务已经提交的数据,即A事务执行新增或删除操作,B事务在A事务前后读到的数据数量不一致

 幻读和不可重复读有点类似(这里就不画图了)不可重复读强调的是数据的值不一样,重点是修改,而幻读强调的是记录的数量不一样,重点是新增或删除。就好像是看花眼产生重影一样。

事务

事务的特性:ACID

A:原子性(Atomicity),原子性是指事务是一个不可分割的工作单位,事务中的操作,要么都发生,要么都不发生。

:假设你在购物车里添加了两件衣服:上衣和裤子,当你把两件衣服作为一个订单提交支付的时候,要么两件衣服一起支付成功,要么都失败,不可能存在上衣付完钱了,裤子还没付完的情况,反之亦然。

C:一致性(Consistency),在一个事务中,事务前后数据的完整性必须保持一致。

:假设用户A和用户B两者的钱加起来一共是200,那么不管A和B之间如何转账,转几次账,事务结束后两个用户的钱相加起来应该还得是200,这就是事务的一致性。

I:隔离性(Isolation),存在于多个事务中,事务的隔离性是指多个用户并发访问数据库时,一个用户的事务不能被其它用户的事务所干扰,多个并发事务之间数据要相互隔离。

:对于任意两个并发的事务T1和T2,在事务T1看来,T2要么在T1开始之前就已经结束,要么在T1结束之后才开始,这样每个事务都感觉不到有其他事务在并发地执行。

D:持久性(Durability),持久性是指一个事务一旦被提交,它对数据库中数据的改变就是永久性的,接下来即使数据库发生故障也不应该对其有任何影响。

:我们在操作数据库时,事务提交或者回滚都会直接改变数据库中的值。

事务的四种隔离级别

  • read uncommitted(读未提交): 一个事务还没提交时,它做的变更就能被别的事务看到,读取尚未提交的数据,不能解决脏读、不可重复读、幻读;
  • read committed(读已提交):一个事务提交之后,它做的变更才会被其他事务看到,读取已经提交的数据,可以解决脏读 ---- oracle默认的,不能解决不可重复读、幻读;
  • repeatable read(可重复读):一个事务执行过程中看到的数据,总是跟这个事务在启动时看到的数据是一致的,可以解决脏读和不可重复读 ---mysql默认的,会存在幻读;
  • serializable(串行化):顾名思义是对于同一行记录,“写”会加“写锁”,“读”会加“读锁”。当出现读写锁冲突的时候,后访问的事务必须等前一个事务执行完成,才能继续执行。可以解决脏读、不可重复读和虚读---相当于锁表。

备注:虽然serializable级别可以解决所有的数据库并发问题,但是它会在读取的每一行数据上都加锁,这就可能导致大量的超时和锁竞争问题,从而导致效率下降。所以我们在实际应用中也很少使用serializable,只有在非常需要确保数据的一致性而且可以接受没有并发的情况下,才考虑采用该级别。

为什么要加锁

如果有多个并发请求存取数据,在数据就可能会产生多个事务同时操作同一行数据。

也就是我们上面说到的在并发情况下会发生的这些问题,

如果并发操作不加控制,不加锁的话,就可能写入了不正确的数据,或者导致读取了不正确的数据,破坏了数据的一致性。因此需要考虑加锁

InnoDB的七种锁

共享/排他锁

InnoDB呢实现了两种标准的行级锁:共享锁(简称S锁)、排他锁(简称X锁)。

  • 共享锁:简称为S锁,在事务要读取一条记录时,需要先获取该记录的S锁。
  • 排他锁:简称X锁,在事务需要改动一条记录时,需要先获取该记录的X锁。

如果事务T1持有行R的S锁,那么另一个事务T2请求访问这条记录时,会做如下处理:

  • T2 请求S锁立即被允许,结果 T1和T2都持有R行的S
  • T2 请求X锁不能被立即允许,此操作会阻塞

如果T1持有行R的X锁,那么T2请求R的X、S锁都不能被立即允许,T2 必须等待T1释放X锁才可以,因为X锁与任何的锁都不兼容。

兼容性  S X
 S 兼容 不兼容
 X  不兼容  不兼容

 

 意向锁

意向锁是一种不与行级锁冲突的表级锁。未来的某个时刻,事务可能要加共享或者排它锁时,先提前声明一个意向。注意一下,意向锁,是一个表级别的锁

因为InnoDB是支持表锁和行锁共存的,如果一个事务A获取到某一行的排他锁,并未提交,这时候事务B请求获取同一个表的表共享锁。因为共享锁和排他锁是互斥的,因此事务B想对这个表加共享锁时,需要保证没有其他事务持有这个表的表排他锁,同时还要保证没有其他事务持有表中任意一行的排他锁

然后问题来了,你要保证没有其他事务持有表中任意一行的排他锁的话,去遍历每一行?这样显然是一个效率很差的做法。为了解决这个问题,InnoDB提出了意向锁。

如果一个事务A获取到某一行的排他锁,并未提交,这时候表上就有意向排他锁和这一行的排他锁。这时候事务B想要获取这个表的共享锁,此时因为检测到事务A持有了表的意向排他锁,因此事务A必然持有某些行的排他锁,也就是说事务B对表的加锁请求需要阻塞等待,不再需要去检测表的每一行数据是否存在排他锁啦。这样效率就高很多啦。

记录锁

记录锁是最简单的行锁,仅仅锁住一行。如:SELECT c1 FROM t WHERE c1 = 10 FOR UPDATE,如果c1字段是主键或者是唯一索引的话,这个SQL会加一个记录锁(Record Lock)

记录锁永远都是加在索引上的,即使一个表没有索引,InnoDB也会隐式的创建一个索引,并使用这个索引实施记录锁。它会阻塞其他事务对这行记录的插入、更新、删除。

一般我们看死锁日志时,都是找关键词,比如lock_mode X locks rec but not gap,就表示一个X型的记录锁。记录锁的关键词就是rec but not gap

间隙锁

为了解决幻读问题,InnoDB引入了间隙锁(Gap Lock)。间隙锁是一种加在两个索引之间的锁,或者加在第一个索引之前,或最后一个索引之后的间隙。它锁住的是一个区间,而不仅仅是这个区间中的每一条数据。

比如lock_mode X locks gap before rec表示X型gap锁。

 临键锁

Next-key锁是记录锁和间隙锁的组合,它指的是加在某条记录以及这条记录前面间隙上的锁。说得更具体一点就是:临键锁会封锁索引记录本身,以及索引记录之前的区间,即它的锁区间是前开后闭,比如(5,10]

如果一个会话占有了索引记录R的共享/排他锁,其他会话不能立刻在R之前的区间插入新的索引记录。

插入意向锁

插入意向锁,是插入一行记录操作之前设置的一种间隙锁。这个锁释放了一种插入方式的信号。它解决的问题是:多个事务,在同一个索引,同一个范围区间插入记录时,如果插入的位置不冲突,就不会阻塞彼此。

假设有索引值4、7,几个不同的事务准备插入5、6,每个锁都在获得插入行的独占锁之前用插入意向锁各自锁住了4、7之间的间隙,但是不阻塞对方因为插入行不冲突

自增锁

自增锁是一种特殊的表级别锁。它是专门针对AUTO_INCREMENT类型的列,对于这种列,如果表中新增数据时就会去持有自增锁。简言之,如果一个事务正在往表中插入记录,所有其他事务的插入必须等待,以便第一个事务插入的行,是连续的主键值。

自增锁是一个表级别锁,那为什么会话A事务还没结束,事务会话B可以执行插入成功呢?不是应该锁表嘛?

这是因为在参数innodb_autoinc_lock_mode上,这个参数设置为1的时候,相当于将这种auto_inc lock弱化为了一个更轻量级的互斥自增长机制去实现,官方称之为mutex

不同事务RR和RC下加锁的规则

  1. RC(读已提交) 的隔离级别下,对查询条件是主键id的场景,会加一个排他锁(X锁),或者说加一个X型的记录锁。
  2. RC(读已提交) 的隔离级别下,对查询条件是唯一索引的场景,该SQL需要加两个X锁,一个对应于 唯一索引上的记录,另一把锁对应于聚簇索引上(主键索引)。

    为什么主键索引上的记录也要加锁呢?

    如果并发的一个SQL,是通过主键索引来,此时,如果delete语句没有将主键索引上的记录加锁,那么并发的update就会感知不到delete语句的存在,违背了同一记录上的更新/删除需要串行执行的约束。

  3. 在RC(读已提交) 的隔离级别下,对查询条件是普通索引的场景,那么对应的所有满足SQL查询条件的记录,都会加上锁。同时,这些记录对应主键索引,也会上锁

  4. 在RC(读已提交) 的隔离级别下,对查询条件是无索引(只是一个常规的列)的场景,MySQL会走聚簇索引进行全表扫描过滤。每条记录都会加上X锁。但是,为了效率考虑,MySQL在这方面进行了改进,在扫描过程中,若记录不满足过滤条件,会进行解锁操作

  5. 在RR(可重复读)的隔离级别下,对查询条件是主键id的场景,会加一个排他锁(X锁),或者说加一个X型的记录锁。和RC是一样的

  6. 在RR(可重复读)的隔离级别下,对查询条件是唯一索引的场景,该SQL需要加两个X锁,一个对应于 唯一索引上的记录,另一把锁对应于聚簇索引上(主键索引)。和RC是一样的

  7. 在RR(可重复读)的隔离级别下,对查询条件是普通索引的场景,除了会加X锁,还会加间隙Gap。Gap锁的提出,是为了解决幻读问题引入的,它是一种加在两个索引之间的锁。

  8. 在RR(可重复读)的隔离级别下,对查询条件是无索引的场景,查询条件列没有索引,主键索引的所有记录,都将加上X锁,每条记录间也都加上间隙Gap锁。任何加锁并发的SQL,都是不能执行的,全表都是锁死的状态。

RR隔离级别下加锁规则

两个原则两个优化和一个bug

  • 原则1:加锁的基本单位都是next-key locknext-key lock(临键锁)是前开后闭区间。
  • 原则2:查找过程中访问到的对象才会加锁。
  • 优化1:索引上的等值查询,给唯一索引加锁的时候,next-key lock退化为行锁(Record lock)
  • 优化 2:索引上的等值查询,向右遍历时且最后一个值不满足等值条件的时候,next-key lock退化为间隙锁(Gap lock)。
  • 一个 bug:唯一索引上的范围查询会访问到不满足条件的第一个值为止。

limit 语句减少加锁范围

总结

  • 如果查询没有命中索引,则退化为表锁;
  • 如果等值查询唯一索引且命中唯一一条记录,则退化为行锁;
  • 如果等值查询唯一索引且没有命中记录,则退化为临近结点的间隙锁;
  • 如果等值查询非唯一索引且没有命中记录,退化为临近结点的间隙锁(包括结点也被锁定);如果命中记录,则锁定所有命中行的临键锁,并同时锁定最大记录行下一个区间的间隙锁。
  • 如果范围查询唯一索引或查询非唯一索引且命中记录,则锁定所有命中行的临键锁 ,并同时锁定最大记录行下一个区间的间隙锁。
  • 如果范围查询索引且没有命中记录,退化为临近结点的间隙锁(包括结点也被锁定)。

MVCC

我们知道排他锁与任何锁互斥,一旦写数据的任务没有完成,数据是不能被其他任务读取的,这对并发度有较大的影响。

有没有可能,进一步提高并发呢?

即使写任务没有完成,其他读任务也可能并发,这就引出了数据多版本。

数据多版本是一种能够进一步提高并发的方法,它的核心原理是:

(1)写任务发生时,将数据克隆一份,以版本号区分;

(2)写任务操作新克隆的数据,直至提交;

(3)并发读任务可以继续读取旧版本的数据,不至于阻塞;

MVCC 实现的原理大致:

InnoDB 每一行数据都有一个隐藏的回滚指针,用于指向该行修改前的最后一个历史版本,这个历史版本存放在 undo log 中。如果要执行更新操作,会将原记录放入 undo log 中,并通过隐藏的回滚指针指向 undo log 中的原记录。其它事务此时需要查询时,就是查询 undo log 中这行数据的最后一个历史版本。MVCC 最大的好处是读不加锁,读写不冲突,极大地增加了 MySQL 的并发性。通过 MVCC,保证了事务 ACID 中的 I(隔离性)特性。

redo、undo

为什么要有redo日志?

redo log 指事务中操作的任何数据,将最新的数据备份到一个地方

数据库事务提交后,必须将更新后的数据刷到磁盘上,以保证ACID特性。磁盘随机写性能较低,如果每次都刷盘,会极大影响数据库的吞吐量。优化方式是,将修改行为先写到redo日志里(此时变成了顺序写),再定期将数据刷到磁盘上,这样能极大提高性能

redo log 不是随着事务的提交才写入的,而是在事务的执行过程中,便开始写入 redo 中。具体的落盘策略可以进行配置 。防止在发生故障的时间点,尚有脏页未写入磁盘,在重启 MySQL 服务的时候,根据 redo log 进行重做,从而达到事务的未入磁盘数据进行持久化这一特性。

为什么要有undo日志?

undo log 指事务开始之前,在操作任何数据之前,首先将需操作的数据备份到一个地方。

数据库事务未提交时,会将事务修改数据的镜像(即修改前的旧版本)存放到undo日志里,当事务回滚时,或者数据库奔溃时,可以利用undo日志,即旧版本数据,撤销未提交事务对数据库产生的影响。

事务未提交之前,Undo 保存了未提交之前的版本数据,Undo 中的数据可作为数据旧版本快照供其他并发事务进行快照读。是为了实现事务的原子性而出现的产物,在 MySQL innodb 存储引擎中用来实现多版本并发控制

当前读和快照读

当前读Locking Read)也称锁定读,读取当前数据的最新版本,而且读取到这个数据之后会对这个数据加锁,防止别的事务更改即通过next-key锁(行锁+gap锁)来解决当前读的问题。在进行写操作的时候就需要进行“当前读”,读取数据记录的最新版本,包含以下SQL类型:select ... lock in share mode 、select ... for updateupdate 、delete 、insert

快照便是进行select的那一刻,生成的当前数据库系统中所有未提交的事务id数组(数组里最小的idmin_id)和已经创建的最大事务idmax_id)的集合,即我们所说的一致性视图readview。在进行快照读的过程中要根据一定的规则将版本链中每个版本的事务idreadview进行匹配查询我们需要的结果。快照读是不会看到别的事务插入的数据的。因此,幻读在“当前读”下才会出现。快照读的实现是基于多版本并发控制,即MVCC,可以认为MVCC是行锁的一个变种,但它在很多情况下,避免了加锁操作,降低了开销;既然是基于多版本,即快照读可能读到的并不一定是数据的最新版本,而有可能是之前的历史版本。

RC和RR隔离级别下的快照读和当前读:RC隔离级别下,快照读和当前读结果一样,都是读取已提交的最新;RR隔离级别下,当前读结果是其他事务已经提交的最新结果,快照读是读当前事务之前读到的结果。RR下创建快照读的时机决定了读到的版本。

总结:快照读本质上读取的是历史数据(原理是回滚段),属于无锁查询

  1. Serializable 隔离级别下 - 普通 select 也变成当前读,即加共享读
  2. 在 RC 隔离级别下 - 每次 select 都会建立新的快照
  3. 在 RR 隔离级别下
    1. 事务启动后,首次 select 会建立快照
    2. 如果事务启动选择了 with consistent snapshot,事务启动时就建立快照
    3. 基于旧数据的修改操作,会重新建立快照
posted @ 2022-10-06 15:42  yetangjian  阅读(185)  评论(1编辑  收藏  举报