MySQL的四大特性以及四大隔离级别的实现

198 阅读17分钟

本文以MySQL的InnoDB为例讲解四大特性(原子性、隔离性、一致性、持久性)以及四大隔离级别的实现。

两种日志

bin log

bin log可以抽象地理解成SQL语句,以二进制的形式记录对数据修改的逻辑日志。常用于数据同步,数据恢复等。

binlog是mysql的逻辑日志,并且由Server层进行记录,使用任何存储引擎的mysql数据库都会记录binlog日志。

binlog刷盘

binlog会在特定的时机进行刷盘,刷盘时机由sync_binlog参数来设定。刷盘即将事务commit到内存中的数据写入磁盘。

sync_binlog参数值如下:

0:每次事务commit,会将数据写入系统的page cache中,会将由系统自行判断何时写入磁盘;

1:每次commit的时候都要将binlog写入磁盘;

N:每N个事务都commit,才会将binlog写入磁盘。

不同的参数带来系统性能影响是不一样的,参数为0则性能较高,但是存在数据一致性和持久性的问题。参数为1,最大限度保证数据的安全性,但是频繁刷盘带来性能下降问题。参数为N,则需要视业务场景进行设置,需要在性能与数据安全之间做一个权衡。

但一般情况下,基于保守考虑,参数默认设置为1,保证数据的安全。

redo log

redo log包括两部分:一个是内存中的日志缓冲(redo log buffer) ,另一个是磁盘上的日志文件(redo log file) ,mysql每执行一条DML语句,先将记录写入redo log buffer,后续某个时间点再一次性将多个操作记录经过操作系统缓存(os buffer)通过调用函数fsync写到redo log file,完成持久化。

这种先写日志,再写磁盘的技术就是MySQL里经常说到的WAL(Write-Ahead Logging) 技术。在持久化一个数据页之前,先将内存中相应的日志页持久化。

同理,redo log的写入磁盘时机也有参数innodb_flush_log_at_trx_commit设置

image.png image.png 参数为0时,1秒前的数据都在redo log buffer中,并没有写入到os buffer,有宕机数据丢失的风险,但是性能较高。

参数为1时,是commit的同时写入到os buffer并且调用fsync写入磁盘(redo log file)。

参数为2时,每次提交都写入到os buffer,这样MySQL服务宕机也不会造成数据丢失,但是如果是断电,也会导致数据丢失。

redo log记录的数据的变更,redo log实际上记录数据页的变更,而这种变更记录是没必要全部保存,因此redo log实现上采用了大小固定,循环写入的方式,当写到结尾时,会回到开头循环写日志。

当数据落盘后,redo log的数据会被覆盖。

image.png 整体来看,redo log的存在是为了临时提交到内存的数据不丢失而设计。这也是Write-Ahead Log(预先日志持久化) 的实现,先将数据以redo log file的形式持久化后,等数据真正写入磁盘(data)后,才算一次事务真正的持久化完成。redo log为事务持久化提供了提高了可靠性。

如下图所示:

webp.webp

bin log 和 redo log的区别

bin logredo log
文件大小可通过参数配置每个文件的大小每个redo log 的大小是固定的,循环写入。
实现方式Server层面实现的,所有引擎都可以适用bin log日志InnoDB引擎层面实现的,不是所有引擎都有
记录方式通过追加的方式记录,当文件超过设定值,则会新建一个文件继续追加。采用循环写的方式,
适用场景崩溃恢复主从复制以及数据恢复。

bin log 以及 redo log存在的意义

bin log 与 redo log 是相辅相成的存在,bin log 记录了几乎全量的逻辑日志,保证断电宕机后的数据恢复。而redo log 是为当前一次事务持久化提供保障,两者同时配合,才能将数据安全性达到最高。

两阶段提交

什么是crash-safe能力?

我们知道redo log,保证了MySQL宕机后恢复数据的能力。

即使宕机,重启后系统会自动检查redo log,将未写入到MySQL的数据从redo log中恢复到MySQL中去。

两阶段提交

两阶段提交实际上也是最大限度地保证宕机后数据可以重新恢复。

主要过程如下图:

image.png

  1. 执行器调用存储引擎接口,存储引擎将修改更新到内存中后,将修改操作写到redo log里面,此时redo log处于prepare状态;
  2. 存储引擎告知执行器执行完毕,执行器开始将操作写入到bin log中,写完后调用存储引擎的接口提交事务;
  3. 存储引擎将redo log的状态置为commit。

为什么需要两阶段提交?

bin log其中之一的功能就是恢复数据,如误删数据,就需要从bin log中恢复。

为了保证Bin log 和redo log上的数据一致。

假设redo log和binlog分别提交,可能会造成用日志恢复出来的数据和原来数据不一致的情况。

  1. 假设先写redo log再写binlog,即redo log没有prepare阶段,写完直接置为commit状态,然后再写binlog。那么如果写完redo log后Mysql宕机了,重启后系统自动用redo log 恢复出来的数据就会比binlog记录的数据多出一些数据,这就会造成磁盘上数据库数据页和binlog的不一致,下次需要用到binlog恢复误删的数据时,就会发现恢复后的数据和原来的数据不一致。
  2. 假设先写binlog再写redolog。如果还未写redo log 而且Mysql宕机了。那当前事务无效,但是binlog上的记录就会比磁盘上数据页的记录多出一些数据出来,下次用binlog恢复数据,就会发现恢复后的数据和原来的数据不一致。

简单说,redo log 和 binlog 都可以用于表示事务的提交状态,而两阶段提交就是让这两个状态保持逻辑上的一致。

四大特性

原子性:事务为最小的执行单位,不可再分,一个事物要么全部成功,要么全部失败。

隔离性:多个事务并发执行时,事物之间是互相独立不影响的。

一致性:数据前后合法,这个不做过多讲解,与编码和业务强相关。

持久性:事务提交后,它的修改应当是永久的,即使数据库宕机也不会对其有影响。

原子性的实现

undo log

数据库事务四大特性中有一个是原子性,具体来说就是 原子性是指对数据库的一系列操作,要么全部成功,要么全部失败,不可能出现部分成功的情况。

实际上,原子性底层就是通过undo log实现的。undo log主要记录了数据的逻辑变化,比如一条INSERT语句,对应一条DELETE的undo log,对于每个UPDATE语句,对应一条相反的UPDATE的undo log,这样在发生错误时,就能回滚到事务之前的数据状态

持久性的实现

当数据修改时,除了修改Buffer Pool中的数据,还会在redo log记录这次操作;当事务提交时,会调用fsync接口对redo log进行刷盘。如果MySQL宕机,重启时可以读取redo log中的数据,对数据库进行恢复。redo log采用的是WAL(Write-ahead logging,预写式日志),所有修改写入日志,更新到Buffer Pool,保证了数据不会因MySQL宕机而丢失,从而满足了持久性要求。

redo log在上面有详细的介绍,此处就不过多赘述。

一致性的实现

概念描述:一致性是指事物执行结束后,数据库的完整性没有被破坏,事务执行的前后都是合法的数据状态。数据库的完整性包括但是不限于: 实体完整性(如行的主键存在且唯一) 列完整性(如字段的类型,大小,长度符合要求), 外键约束(外键约束还存在) 用户自定义完整性(如转账前后,两个账户的和应该是不变的)

可以说,一致性是事物追求的最终目标,前面提到的原子性,隔离性,持久性都是为了保证数据库的一致性。此外除了数据库底层的保障,一致性的实现也需要应用层的保障。

  • 保证原子性、持久性和隔离性,如果这些特性无法保证,事务的一致性也无法保证
  • 数据库本身提供保障,例如不允许向整形列插入字符串值、字符串长度不能超过列的限制等
  • 应用层面进行保障,例如如果转账操作只扣除转账者的余额,而没有增加接收者的余额,无论数据库实现的多么完美,也无法保证状态的一致

隔离性的实现(重点)

隔离性追求的是并发情形下事务之间不会相互干扰,简单起见,我们仅考虑最简单的读操作和写操作(暂时不考虑带锁读等特殊操作),那么隔离性的探讨,主要可以分为两个方面:

  • (一个事务)写操作对(另一个事务)写操作的影响:锁机制保证隔离性
  • (一个事务)写操作对(另一个事务)读操作的影响:MVCC保证隔离性

总的来说,隔离性是由MVCC+锁机制来实现。

锁机制

按照粒度,分为表锁与行锁: MyISAM 支持表锁,InnoDB 支持表锁和行级锁,默认是行级锁表级锁:开销小,加锁快,不会出现死锁。锁定粒度大,发送锁冲突的概率比较高,并发处理效果较低。 行级锁: 开销大,加锁慢,会出现死锁,锁定粒度较大,发生锁冲突的概率会小一点,并发处理的效果高

事务并发问题

有T1,T2两个事务。

脏读: T1读取到T2修改但未提交的数据,但T2又回滚了,此时T1读取的就是无效的脏数据。

幻读: T1查询到的结果集后,T2增加一条数据,这条数据恰好满足T1的查询条件,当T1再次使用相同条件查询,会发现两次查询结果集数量不一致。好似发生了幻觉。

不可重复读: 事务中可以查询到别的事务当前提交的数据,即可能发声两次同样的查询返回不一样的结果集。

MySQL四种隔离级别

读未提交

事务可以读到其他事务修改但未提交的数据,此隔离级别会发生脏读、幻读、不可重复度等并发问题。

读已提交

只能看到其他事务已经提交的数据,此隔离级别会发生不可重复、幻读度问题。

可重复读

解决了脏读问题,同一个事务多次查询同一数据结果一致。但是并未解决幻读问题。

串行化

是最高的隔离级别,通过强制事务串行化执行,避免了前面所说到的幻读的问题。就是可串行化 会在读取的每一行数据上都加上锁,但是这样会导致超时和锁争用问题。

MVCC

在早期的数据库中,只有读读之间的操作才可以并发执行,读写,写读,写写操作都要阻塞,这样就会导致MySQL的并发性能极差。

采用了MVCC机制后,只有写写之间相互阻塞,其他三种操作都可以并行,这样就可以提高了MySQL的并发性能。

ReadView

ReadView可以理解为数据库中某一个时刻所有未提交事务的快照。ReadView有几个重要的参数:

  • m_ids:表示生成ReadView时,当前系统正在活跃的读写事务的事务Id列表
  • min_trx_id:表示生成ReadView时,当前系统中活跃的读写事务的最小事务Id
  • max_trx_id:表示生成ReadView时,当前时间戳InnoDB将在下一次分配的事务id
  • creator_trx_id:当前事务id。

所以当创建ReadView时,可以知道这个时间点上未提交事务的所有信息。

隐藏列

InnoDB存储引擎中,它的聚簇索引记录中都包含两个必要的隐藏列,分别是:

  • trx_id:事务Id,每次一个事务对某条聚簇索引记录进行改动时,都会把该事务的事务id赋值给trx_id隐藏列。
  • roll_pointer:回滚指针,每次对某条聚簇索引记录进行改动时,都会把旧的版本写入到undo log中,然后这个隐藏列就相当于一个指针,可以通过它来找到该记录修改前的信息。

版本链

假如数据初始状态如下

image.png

数据第一次被修改时:

image.png

数据第二次被修改时:

image.png

由图可知,每一次修改回滚指针都会指向相应的undo_log。

实现机制

在四个隔离级别中,读未提交是没有进行版本控制的。而串行化则是严格遵守先后顺序执行事务,不需要进行版本控制。

而读已提交和可重复读这两个隔离级别则使用到了MVCC。下面就来展开说说。

读已提交的实现

在这个隔离级别中,当前事务是可以读取到所有事物已经提交的修改,即,所有事物提交的修改对所有事务可见。

每次查询都会生成一个ReadView,若在当前事务中查询,则会查询到最新已提交的修改。

由于每次查询都会生成readview,即每一次查询的数据都可能不一样,所以不可避免会有不可重复读的问题。

只要解决了不可重复读的问题,就达到了可重复读这个隔离级别。

如何解决?

可重复读的实现

关键就在于,在可重复读的这个隔离级别下,只会在事务开始后第一次读取数据时生成一个 Read View

所以在一个事务中,只有唯一一个ReadView,所以从始至终它读取到的数据都是一致的。无

论在此过程中其他事务是否提交,当前事务可见的数据只有一个版本,即开启事务读取的版本。

详细原理参照本篇文章:

javaguide.cn/database/my…

解决幻读问题

我们知道,幻读是两次读取的结果集数量不一致。

当前读

它读取的数据库记录,都是当前最新版本,会对当前读取的数据进行加锁,防止其他事务修改数据。是悲观锁的一种操作。

如下操作都是当前读:

  • select lock in share mode (共享锁)
  • select for update (排他锁)
  • update (排他锁)
  • insert (排他锁)
  • delete (排他锁)
  • 串行化事务隔离级别

快照读

快照读的实现是基于多版本并发控制,即MVCC,既然是多版本,那么快照读读到的数据不一定是当前最新的数据,有可能是之前历史版本的数据。

如下操作是快照读:

  • 不加锁的select操作(注:事务级别不是串行化)

在普通SELECT的操作中,会使用生成一次ReadView的方式来防止幻读。

2、执行 select...for update/lock in share mode、insert、update、delete 等当前读

在当前读下,读取的都是最新的数据,如果其它事务有插入新的记录,并且刚好在当前事务查询范围内,就会产生幻读!InnoDB 使用 Next-key Lock 来防止这种情况。当执行当前读时,会锁定读取到的记录的同时,锁定它们的间隙,防止其它事务在查询范围内插入数据。只要我不让你插入,就不会发生幻读。

next-key lock可以理解为行锁+间隙锁。


2022.11.23 10点55分 更新

这次更新主要想补齐遗漏掉的知识点。

Read和版本链如何匹配?

说明:以下内容都是以RR级别为基础。

前面只是说明了ReadView(以下简称RV),以及隐藏列。

我们知道,开启事务,执行第一条sql时,会产生RV,这个RV只是告诉了你,在当前时刻:

哪些事务在活跃(m_ids)、活跃事务的最小id(min_trx_id)、下一次事务的生成id(max_trx_id)、以及当前事务的id(creator_trx_id)

目标:我们要根据当前生成的RV,去版本链中寻找到当前事务可见的版本!

当前事务可见版本

在RR级别下,我们只能读取到提交的事务,所以通过RV,我们可以确定已经提交的事务的范围,即不处于活跃事务的m_ids范围中,由于事务id是递增的

所以,我们可以确定,我们可见的事务id范围是:小于min_trx_id ∪ (NOT IN m_ids &&( 大于min_trx_id && 小于max_trx_id))

这里需要揣摩一下,解释一次,我们是RR级别,所以我们可见的事务id都是提交了的,那么小于min_trx_id 都是提交的,这个很好理解。

但是第二部分,(NOT IN m_ids && 大于min_trx_id && 小于max_trx_id),这里可能比较抽象,那我们拆开来看,不在m_ids(活跃事务) 中,那就是已提交事务了,( 大于min_trx_id && 小于max_trx_id),这一段就是在(min_trx_id ,max_trx_id)这个范围并且也是已提交的事务

用坐标轴来表示:

image.png

webp.webp

版本链的结构是有序的,那么大胆猜测是使用链表为基础结构实现的😄,并且采用的是头插法,最新的版本会通过头插放在头部。

注意,版本链是基于隐藏列实现的,每一条记录都有隐藏列,所以,版本链是基于记录的修改来生成的。

此时有两个事务,且都没有提交,此时生成了一个RV。

image.png

此时,事务B改了这条记录,产生了新的版本,由于事务B是未提交状态,所以事务A是不可以用trx_id = 18这个版本。

也可以由RV推断,m_ids中包含18,所以这个版本对于事务A不可见。

那此时,事务A匹配的版本就是trx_id =8。

image.png

当然实际Mysql的情况,比上图所示的更复杂,下面用伪代码来展示RV与undolog版本链是怎么匹配的

ReadView

  • m_ids:表示生成ReadView时,当前系统正在活跃的读写事务的事务Id列表
  • min_trx_id:表示生成ReadView时,当前系统中活跃的读写事务的最小事务Id
  • max_trx_id:表示生成ReadView时,当前时间戳InnoDB将在下一次分配的事务id
  • creator_trx_id:当前事务id。

所以当创建ReadView时,可以知道这个时间点上未提交事务的所有信息。

readview与undologs匹配逻辑

//遍历整个版本链
for(undolog : undologs){
    //如果版本的trx_id 小于 min_trx_id 或者 max_trx_id > undolog.trx_id > min_trx_id 并且 trx_id 不在活跃事务ids中
    if(
        undolog.trx_id < min_trx_id || 
       (max_trx_id > undolog.trx_id > min_trx_id  && undolog.trx_id not in m_ids)
      ){
​
        return undologs
​
    }
​
}

有注释的源码,可能更好懂。

453868f196224890885af57617800aadtplv-k3u1fbpfcp-zoom-in-crop-mark4536000.webp

一图胜千言

image.png