MySQL事务的实现原理

文章目录

  • MySQL事务的实现原理
    • 1. redo log 实现持久性
    • 2. bin log
    • 3. undo log 实现原子性
    • 4. MVCC实现隔离性
      • 4.1 事务的隔离级别
      • 4.2 read view (快照)
      • 4.3 隔离级别
    • 5. MVCC是如何解决幻读的?
    • 6. MySQL InnoDB的锁
      • 6.1 共享锁和独占锁
      • 6.2 意向锁
      • 6.3 三种锁算法

MySQL事务的实现原理

  • 事务的原子性是通过 undo log 来实现的
  • 事务的持久性性是通过 redo log 来实现的
  • 事务的隔离性是通过 (读写锁+MVCC)来实现的
  • 而事务的终极大 boss 一致性是通过原子性,持久性,隔离性来实现的!!!

MySQL事务的实现原理_第1张图片

1. redo log 实现持久性

问题1: 为什么需要redo log?

InnoDB作为MySQL的存储引擎,数据是存放在磁盘中的,但如果每次读写数据都需要磁盘IO,效率会很低。为此,InnoDB提供了缓存(Buffer Pool),作为访问数据库的缓冲:当从数据库读取数据时,会首先从Buffer Pool中读取,如果Buffer Pool中没有,则从磁盘读取后放入Buffer Pool;当向数据库写入数据时,会首先写入Buffer Pool,Buffer Pool中修改的数据会定期刷新到磁盘中 。

Buffer Pool的使用大大提高了读写数据的效率,但是也带了新的问题:如果MySQL宕机,而此时Buffer Pool中修改的数据还没有刷新到磁盘,就会导致数据的丢失,事务的持久性无法保证。

问题2:redo log如何保证事务的持久性?

Redo log可以简单分为以下两个部分:

  • 一是内存中重做日志缓冲 (redo log buffer),是易失的,在内存中
  • 二是重做日志文件 (redo log file),是持久的,保存在磁盘中

这里再细说下写入Redo的时机:

  • 在数据页修改完成之后,在脏页刷出磁盘之前,写入redo日志。注意的是先修改数据,后写日志
  • redo日志比数据页先写回磁盘
  • 聚集索引、二级索引、undo页面的修改,均需要记录Redo日志。

MySQL事务的实现原理_第2张图片

在 MySQL中,如果每一次的更新操作都需要写进磁盘,然后磁盘也要找到对应的那条记录,然后再更新,整个过程 IO 成本、查找成本都很高。为了解决这个问题,MySQL 的设计者就采用了日志(redo log)来提升更新效率。

当事务提交时,先将 redo log buffer 写入到 redo log file 进行持久化,待事务的commit操作完成时才算完成。这种做法也被称为 Write-Ahead Log(预先日志持久化),在持久化一个数据页之前,先将内存中相应的日志页持久化。

具体来说,当有一条记录需要更新的时候,InnoDB 引擎就会先把记录写到 redo log(redo log buffer)里面,并更新内存(buffer pool),这个时候更新就算完成了。同时,InnoDB 引擎会在适当的时候(如系统空闲时),将这个操作记录更新到磁盘里面(刷脏页)。

在一个事务中可以修改多个页,Write-Ahead Log 可以保证单个数据页的一致性,但是无法保证事务的持久性,Force-log-at-commit 要求当一个事务提交时,其产生所有的mini-transaction 日志必须刷新到磁盘中,若日志刷新完成后,在缓冲池中的页刷新到持久化存储设备前数据库发生了宕机,那么数据库重启时,可以通过日志来保证数据的完整性。

问题3:重写日志的流程?

MySQL事务的实现原理_第3张图片

上图表示了重做日志的写入流程,每个mini-transaction对应每一条DML操作,比如一条update语句,其由一个mini-transaction来保证,对数据修改后,产生redo1,首先将其写入mini-transaction私有的Buffer中,update语句结束后,将redo1从私有Buffer拷贝到公有的Log Buffer中。当整个外部事务提交时,将redo log buffer再刷入到redo log file中。( redo log是按照顺序写入的,磁盘的顺序读写的速度远大于随机读写)

问题4:数据写入后的最终落盘,是从 redo log 更新过来的还是从 buffer pool 更新过来的呢?

实际上,redo log 并没有记录数据页的完整数据,所以它并没有能力自己去更新磁盘数据页,也就不存在由 redo log 更新过去数据最终落盘的情况。

① 数据页被修改以后,跟磁盘的数据页不一致,称为脏页。最终数据落盘,就是把内存中的数据页写盘。这个过程与 redo log 毫无关系。

② 在崩溃恢复场景中,InnoDB 如果判断到一个数据页可能在崩溃恢复的时候丢失了更新,就会将它读到内存,然后让 redo log 更新内存内容。更新完成后,内存页变成脏页,就回到了第一种情况的状态。

问题5:redo log buffer 是什么?是先修改内存,还是先写 redo log 文件?

在一个事务的更新过程中,日志是要写多次的。比如下面这个事务:

Copybegin;
INSERT INTO T1 VALUES ('1', '1');
INSERT INTO T2 VALUES ('1', '1');
commit;

这个事务要往两个表中插入记录,插入数据的过程中,生成的日志都得先保存起来,但又不能在还没 commit 的时候就直接写到 redo log 文件里。

因此就需要 redo log buffer 出场了,它就是一块内存,用来先存 redo 日志的。也就是说,在执行第一个 insert 的时候,数据的内存被修改了,redo log buffer 也写入了日志。

但是,真正把日志写到 redo log 文件,是在执行 commit 语句的时候做的。

redo log buffer 本质上只是一个 byte 数组,但是为了维护这个 buffer 还需要设置很多其他的 meta data,这些 meta data 全部封装在 log_t 结构体中。

问题6:redo log顺序写入磁盘?

redo log以顺序的方式写入文件,当全部文件写满的时候则回到第一个文件相应的起始位置进行覆盖写,每次提交事务之后,都先将相关的操作日志写入redo日志文件中,并且都追加到文件末尾,这是一个顺序I/O。

MySQL事务的实现原理_第4张图片

图中展示了一组 4 个文件的 redo log 日志,checkpoint 是当前要擦除的位置,擦除记录前需要先把对应的数据落盘(更新内存页,等待刷脏页)。write pos 到 checkpoint 之间的部分可以用来记录新的操作,如果 write pos 和 checkpoint 相遇,说明 redolog 已满,这个时候数据库停止进行数据库更新语句的执行,转而进行 redo log 日志同步到磁盘中。checkpoint 到 write pos 之间的部分等待落盘(先更新内存页,然后等待刷脏页)。

有了 redo log 日志,那么在数据库进行异常重启的时候,可以根据 redo log 日志进行恢复,也就达到了 crash-safe。

redo log 用于保证 crash-safe 能力。innodb_flush_log_at_trx_commit 这个参数设置成 1 的时候,表示每次事务的 redo log 都直接持久化到磁盘。这个参数建议设置成 1,这样可以保证 MySQL 异常重启之后数据不丢失。

2. bin log

MySQL 整体来看,其实就有两块:一块是 Server 层,它主要做的是 MySQL 功能层面的事情;还有一块是引擎层,负责存储相关的具体事宜。上面我们聊到的 redo log 是 InnoDB 引擎特有的日志,而 Server 层也有自己的日志,称为 binlog(归档日志)。

为什么会有两份日志呢?

因为最开始 MySQL 里并没有 InnoDB 引擎。MySQL 自带的引擎是 MyISAM,但是 MyISAM 没有 crash-safe 的能力,binlog 日志只能用于归档。而 InnoDB 是另一个公司以插件形式引入 MySQL 的,既然只依靠 binlog 是没有 crash-safe 能力的,所以 InnoDB 使用另外一套日志系统——也就是 redo log 来实现 crash-safe 能力。

这两种日志有以下三点不同。
① redo log 是 InnoDB 引擎特有的;binlog 是 MySQL 的 Server 层实现的,所有引擎都可以使用。
② redo log 是物理日志,记录的是“在某个数据页上做了什么修改”;binlog 是逻辑日志,记录的是这个语句的原始逻辑,比如“给 ID=2 这一行的 c 字段加 1 ”。
③ redo log 是循环写的,空间固定会用完;binlog 是可以追加写入的。“追加写”是指 binlog 文件写到一定大小后会切换到下一个,并不会覆盖以前的日志。

MySQL事务的实现原理_第5张图片

有了对这两个日志的概念性理解后,再来看执行器和 InnoDB 引擎在执行这个 update 语句时的内部流程。

① 执行器先找引擎取 ID=2 这一行。ID 是主键,引擎直接用树搜索找到这一行。如果 ID=2 这一行所在的数据页本来就在内存中,就直接返回给执行器;否则,需要先从磁盘读入内存,然后再返回。

② 执行器拿到引擎给的行数据,把这个值加上 1,比如原来是 N,现在就是 N+1,得到新的一行数据,再调用引擎接口写入这行新数据。

③ 引擎将这行新数据更新到内存(InnoDB Buffer Pool)中,同时将这个更新操作记录到 redo log 里面,此时 redo log 处于 prepare 状态。然后告知执行器执行完成了,随时可以提交事务。

④ 执行器生成这个操作的 binlog,并把 binlog 写入磁盘。

⑤ 执行器调用引擎的提交事务接口,引擎把刚刚写入的 redo log 改成提交(commit)状态,更新完成。

下图为 update 语句的执行流程图,图中灰色框表示是在 InnoDB 内部执行的,绿色框表示是在执行器中执行的。

其中将 redo log 的写入拆成了两个步骤:prepare 和 commit,这就是两阶段提交(2PC)。

问题1: 两阶段提交原理?

MySQL 使用两阶段提交主要解决 binlog 和 redo log 的数据一致性的问题。

两阶段提交原理描述:

① redo log 写盘,InnoDB 事务进入 prepare 状态。

② 如果前面 prepare 成功,binlog 写盘,那么再继续将事务日志持久化到 binlog,如果持久化成功,那么 InnoDB 事务则进入 commit 状态 。

redo log 和 binlog 有一个共同的数据字段,叫 XID。崩溃恢复的时候,会按顺序扫描 redo log:

① 如果碰到既有 prepare、又有 commit 的 redo log,就直接提交;

② 如果碰到只有 parepare、而没有 commit 的 redo log,就拿着 XID 去 binlog 找对应的事务。
binlog无记录,回滚事务
binlog有记录,提交事务。

问题2:为什么必须有“两阶段提交”呢?

如果不使用两阶段提交,假设当前 ID=2 的行,字段 c 的值是 0,再假设执行 update 语句过程中在写完第一个日志后,第二个日志还没有写完期间发生了 crash,会出现什么情况呢?

**先写 redo log 后写 binlog。**假设在 redo log 写完,binlog 还没有写完的时候,MySQL 进程异常重启。由于我们前面说过的,redo log 写完之后,系统即使崩溃,仍然能够把数据恢复回来,所以恢复后这一行 c 的值是 1。
但是由于 binlog 没写完就 crash 了,这时候 binlog 里面就没有记录这个语句。因此,之后备份日志的时候,存起来的 binlog 里面就没有这条语句。
然后你会发现,如果需要用这个 binlog 来恢复临时库的话,由于这个语句的 binlog 丢失,这个临时库就会少了这一次更新,恢复出来的这一行 c 的值就是 0,与原库的值不同。

**先写 binlog 后写 redo log。**如果在 binlog 写完之后 crash,由于 redo log 还没写,崩溃恢复以后这个事务无效,所以这一行 c 的值是 0。但是 binlog 里面已经记录了“把 c 从 0 改成 1”这个日志。所以,在之后用 binlog 来恢复的时候就多了一个事务出来,恢复出来的这一行 c 的值就是 1,与原库的值不同。

可以看到,如果不使用“两阶段提交”,那么数据库的状态就有可能和用它的日志恢复出来的库的状态不一致。

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

3. undo log 实现原子性

undo log有两个作用:提供回滚和多版本控制(MVCC)。

在数据修改的时候,不仅记录了redo,还记录了相对应的undo,undo log主要记录的是数据的逻辑变化,为了在发生错误时回滚之前的操作,需要将之前的操作都记录下来,然后在发生错误时才可以回滚。

undo日志,只将数据库逻辑地恢复到原来的样子,在回滚的时候,它实际上是做的相反的工作,比如一条INSERT ,对应一条 DELETE,对于每个UPDATE,对应一条相反的 UPDATE,将修改前的行放回去。undo日志用于事务的回滚操作进而保障了事务的原子性。

实现原子性的关键,是当事务回滚时能够撤销所有已经成功执行的sql语句。 InnoDB 实现回滚,靠的是undo log :当事务对数据库进行修改时,InnoDB 会生成对应的undo log 如果事务执行失败或调用了rollback,导致事务需要回滚,便可以利用undo log中的信息将数据回滚到修改之前的样子。

在InnoDB存储引擎中,undo log分为:

  • insert undo log
  • update undo log

insert undo log是指在insert 操作中产生的undo log,因为insert操作的记录,只对事务本身可见,对其他事务不可见。故该undo log可以在事务提交后直接删除,不需要进行purge操作。

而update undo log记录的是对delete 和update操作产生的undo log,该undo log可能需要提供MVCC机制,因此不能再事务提交时就进行删除。提交时放入undo log链表,等待purge线程进行最后的删除。

补充:purge线程两个主要作用是:清理undo页和清除page里面带有Delete_Bit标识的数据行。在InnoDB中,事务中的Delete操作实际上并不是真正的删除掉数据行,而是一种Delete Mark操作,在记录上标识Delete_Bit,而不删除记录。是一种"假删除",只是做了个标记,真正的删除工作需要后台purge线程去完成。

innodb中通过B+树作为索引的数据结构,并且主键所在的索引为ClusterIndex(聚簇索引), ClusterIndex中的叶子节点中保存了对应的数据内容。一个表只能有一个主键,所以只能有一个聚簇索引,如果表没有定义主键,则选择第一个非NULL唯一索引作为聚簇索引,如果还没有则生成一个隐藏id列作为聚簇索引。
除了Cluster Index外的索引是Secondary Index(辅助索引)。辅助索引中的叶子节点保存的是聚簇索引的叶子节点的值。
InnoDB行记录中除了刚才提到的rowid外,还有trx_id和db_roll_ptr, trx_id表示最近修改的事务的id,db_roll_ptr指向undo segment中的undo log。

新增一个事务时事务id会增加,trx_id能够表示事务开始的先后顺序。
Undo log分为Insert和Update两种,delete可以看做是一种特殊的update,即在记录上修改删除标记。
update undo log记录了数据之前的数据信息,通过这些信息可以还原到之前版本的状态。
当进行插入操作时,生成的Insert undo log在事务提交后即可删除,因为其他事务不需要这个undo log。
进行删除修改操作时,会生成对应的undo log,并将当前数据记录中的db_roll_ptr指向新的undo log。

MySQL事务的实现原理_第6张图片

4. MVCC实现隔离性

4.1 事务的隔离级别

MySQL事务的实现原理_第7张图片

① 脏读:某一个事务,读取了另一个事务未提交的数据。

② 不可重复读:某一个事务,对同一个数据前后读取的结果不一致。

③ 幻读:某一个事务,对同一个表前后查询到的行数不一致。(针对表,要锁住整张表)。幻读,指的是当某个事务在读取某个范围内的记录时,另外一个事务又在该范围内插入了新行,当之前的事务再次读取该范围的记录时,会产生幻行。InnoDB存储引擎通过多版本并发控制(MVCC)解决了幻读问题。

① 读未提交是指,一个事务还没提交时,它做的变更就能被别的事务看到。
② 读已提交是指,一个事务提交之后,它做的变更才会被其他事务看到。
③ 可重复读是指,一个事务执行过程中看到的数据,总是跟这个事务在启动时看到的数据是一致的。当然在可重复读隔离级别下,未提交变更对其他事务也是不可见的。按照可重复读的定义,一个事务启动的时候,能够看到所有已经提交的事务结果。但是之后,这个事务执行期间,其他事务的更新对它不可见。
④ 串行化,顾名思义是对于同一行记录,“写”会加“写锁”,“读”会加“读锁”。当出现读写锁冲突的时候,后访问的事务必须等前一个事务执行完成,才能继续执行。

在这里插入图片描述

① 若隔离级别是“读未提交”, 则 V1 的值就是 2。这时候事务 B 虽然还没有提交,但是结果已经被 A 看到了。因此,V2、V3 也都是 2。

② 若隔离级别是“读提交”,则 V1 是 1,V2 的值是 2。事务 B 的更新在提交后才能被 A看到。所以, V3 的值也是 2。
若隔离级别是“可重复读”,则 V1、V2 是 1,V3 是 2。之所以 V2 还是 1,遵循的就是这个要求:事务在执行期间看到的数据前后必须是一致的

④ 若隔离级别是“串行化”,则在事务 B 执行“将 1 改成 2”的时候,会被锁住。直到事务 A 提交后,事务 B 才可以继续执行。所以从 A 的角度看, V1、V2 值是 1,V3 的值是 2。

“读提交”和“可重复读”在实现上,数据库里面会创建一个ReadView,访问的时候以ReadView的逻辑结果为准

① 在“可重复读”隔离级别下,这个ReadView是在事务启动时创建的,整个事务存在期间都用这个ReadView。

② 在“读提交”隔离级别下,这个ReadView是在每个 SQL 语句开始执行的时候创建的。

③ 在“读未提交”隔离级别下直接返回记录上的最新值,没有视图概念;

④ 在“串行化”隔离级别下直接用加锁的方式来避免并行访问。

4.2 read view (快照)

InnoDB 在实现 MVCC 时用到的一致性读视图,即 consistent read view,用于支持 RC(Read Committed,读提交)和 RR(Repeatable Read,可重复读)隔离级别的实现。

在可重复读隔离级别下,事务在启动的时候就“拍了个快照”。

MySQL的MVCC快照并不是每一个事务进来就copy一份数据库信息,而是基于数据表每行信息后面保存的系统版本号去实现的。如下图所示,一行信息会有多个版本并存,每个事务可能读取到的版本不一样。

MVCC-快照-行版本.png

InnoDB 里面每个事务有一个唯一的事务 ID,叫作 transaction id。它是在事务开始的时候向 InnoDB 的事务系统申请的,是按申请顺序严格递增的。

而每行数据也都是有多个版本的。每次事务更新数据的时候,都会生成一个新的数据版本,并且把 transaction id 赋值给这个数据版本的row trx_id 。同时,旧的数据版本要保留,并且在新的数据版本中,能够有信息可以直接拿到它 。

数据表中的一行记录,其实可能有多个版本 (row),每个版本有自己的 row trx_id。 就是一个记录被多个事务连续更新后的状态。

MySQL事务的实现原理_第8张图片

图中虚线框里是同一行数据的 4 个版本,当前最新版本是 V4,k 的值是 22,它是被 transaction id 为 25 的事务更新的,因此它的 row trx_id 也是 25。

语句更新会生成 undo log(回滚日志)吗?那么,undo log 在哪呢?
实际上,图 2 中的三个虚线箭头,就是 undo log;而 V1、V2、V3 并不是物理上真实存在的,而是每次需要的时候根据当前版本和 undo log 计算出来的。比如,需要 V2 的时候,就是通过 V4 依次执行 U3、U2 算出来。

按照可重复读的定义,一个事务启动的时候,能够看到所有已经提交的事务结果但是之后,这个事务执行期间,其他事务的更新对它不可见。因此,一个事务只需要在启动的时候声明说,“以我启动的时刻为准,如果一个数据版本是在我启动之前生成的,就认;如果是我启动以后才生成的,我就不认,我必须要找到它的上一个版本”。当然,如果“上一个版本”也不可见,那就得继续往前找。还有,如果是这个事务自己更新的数据,它自己还是要认的。

继续看看ReadView。已提交读和可重复读的区别就在于它们生成ReadView的策略不同:
MySQL事务的实现原理_第9张图片

InnoDB 为每个事务构造了一个数组,用来保存这个事务启动瞬间,当前正在“活跃”的所有事务 ID,也就是begin了还未提交的事务。通过这个数组来判断记录的某个版本是否对当前事务可见。其中最主要的与可见性相关的属性如下:

① up_limit_id:当前已经提交的事务号 + 1,事务号 < up_limit_id ,对于当前Read View都是可见的。理解起来就是创建Read View视图的时候,之前已经提交的事务对于该事务肯定是可见的。

② low_limit_id:当前最大的事务号 + 1,事务号 >= low_limit_id,对于当前Read View都是不可见的。理解起来就是在创建Read View视图之后创建的事务对于该事务肯定是不可见的。

③ trx_ids:为活跃事务id列表,即Read View初始化时当前未提交的事务列表。所以当进行RR读的时候,trx_ids中的事务对于本事务是不可见的(除了自身事务,自身事务对于表的修改对于自己当然是可见的)。理解起来就是创建RV时,将当前活跃事务ID记录下来,后续即使他们提交对于本事务也是不可见的。

比如: 我们有如下表:

3.png

现在有一个事务id是60的执行如下语句并提交

update user set name = '强哥1' where id = 1;

此时undo log存在版本链如下:

MySQL事务的实现原理_第10张图片

提交事务id是60的记录后,接着有一个事务id为100的事务,修改name=强哥2,但是事务还没提交。则此时的版本链是:

MySQL事务的实现原理_第11张图片

此时另一个事务发起select语句查询id=1的记录,因为trx_ids当前只有事务id为100的,所以该条记录不可见,继续查询下一条,发现trx_id=60的事务号小于up_limit_id,则可见,直接返回结果强哥1。

那这时候我们把事务id为100的事务提交了,并且新建了一个事务id为110也修改id为1的记录name=强哥3,并且不提交事务。这时候版本链就是:

MySQL事务的实现原理_第12张图片

这时候之前那个select事务又执行了一次查询,要查询id为1的记录。

如果你是读已提交隔离级别READCOMMITED,这时候你会重新一个ReadView,那你的活动事务列表中的值就变了,变成了110。按照上的说法,你去版本链通过trxid对比查找到合适的结果就是强哥2。

如果你是可重复读隔离级别REPEATABLE_READ,这时候你的ReadView还是第一次select时候生成的ReadView,也就是列表的值还是100。所以select的结果是强哥1。所以第二次select结果和第一次一样,所以叫可重复读!

也就是说已提交读隔离级别下的事务在每次查询的开始都会生成一个独立的ReadView,而可重复读隔离级别则在第一次读的时候生成一个ReadView,之后的读都复用之前的ReadView。
这就是Mysql的MVCC,通过版本链,实现多版本,可并发读-写,写-读。通过ReadView生成策略的不同实现不同的隔离级别。

我觉得上面的例子已经讲的很明白了,再来一个例子加深理解:

insert into t(id, k) values(1,1),(2,2);

MySQL事务的实现原理_第13张图片

分析下事务 A 的语句返回的结果,为什么是k=1?这里,我们不妨做如下假设:

① 事务 A 开始前,系统里面只有一个活跃事务 ID 是 99;

② 事务 A、B、C 的版本号分别是 100、101、102,且当前系统里只有这四个事务;

③ 三个事务开始前,(1,1)这一行数据的 row trx_id 是 90。

这样,事务 A 的视图数组就是 [99,100], 事务 B 的视图数组是 [99,100,101], 事务 C 的视图数组是 [99,100,101,102]。
MySQL事务的实现原理_第14张图片

从图中可以看到:

① 第一个有效更新是事务 C,把数据从 (1,1) 改成了 (1,2)。这时候,这个数据的最新版本的 row trx_id 是 102,而 90 这个版本已经成为了历史版本。
② 第二个有效更新是事务 B,把数据从 (1,2) 改成了 (1,3)。这时候,这个数据的最新版本(即row trx_id)是 101,而 102 又成为了历史版本。
③ 你可能注意到了,在事务 A 查询的时候,其实事务 B 还没有提交,但是它生成的 (1,3) 这个版本已经变成当前版本了。但这个版本对事务 A 必须是不可见的,否则就变成脏读了。
④ 好,现在事务 A 要来读数据了,它的视图数组是 [99,100]。当然了,读数据都是从当前版本读起的。

所以,事务 A 查询语句的读数据流程是这样的:

1、找到 (1,3) 的时候,判断出 row trx_id=101,不可见;
2、接着,找到上一个历史版本,一看 row trx_id=102,不可见;
3、再往前找,终于找到了(1,1),它的 row trx_id=90,可见。

这样执行下来,虽然期间这一行数据被修改过,但是事务 A 不论在什么时候查询,看到这行数据的结果都是一致的,所以我们称之为一致性读。

一个数据版本,对于一个事务read view来说,除了自己的更新总是可见以外,有三种情况:
1、版本未提交,不可见;
2、版本已提交,但是是在read view创建后提交的,不可见;
3、版本已提交,而且是在read view创建前提交的,可见。

不知道你有没有发现一个问题:在可重复读的隔离级别下,事务开启后应该对其他事务提交的数据不可见,那么为什么事务 B 的视图数组是先生成的,之后事务 C 才提交,不是应该看不见 (1,2)吗,怎么能算出 (1,3) 来?
MySQL事务的实现原理_第15张图片

是的,如果事务 B 在更新之前查询一次数据,这个查询返回的 k 的值确实是 1。
但是,当它要去更新数据的时候,就不能再在历史版本上更新了,否则事务 C 的更新就丢失了。因此,事务 B 此时的 set k=k+1 是在(1,2)的基础上进行的操作。
所以,这里就用到了这样一条规则:更新数据都是先读后写的,而这个读,只能读当前的值,称为“当前读” 。

在更新的时候,当前读拿到的数据是 (1,2),更新后生成了新版本的数据 (1,3),这个新版本的 row trx_id 是 101。
所以,在执行事务 B 查询语句的时候,一看自己的版本号是 101,最新数据的版本号也是101,是自己的更新,可以直接使用,所以查询得到的 k 的值是 3。

其实,除了 update 语句外,select 语句如果加锁,也是当前读。
如:事务 A 的查询语句 select * from t where id=1 修改一下,加上 lock in share mode 或 for update

mysql> select k from t where id=1 lock in share mode;
mysql> select k from t where id=1 for update;

再往前一步,假设事务 C 不是马上提交的,而是变成了下面的事务 C’,会怎么样呢?

MySQL事务的实现原理_第16张图片

事务 C’的不同是,更新后并没有马上提交,在它提交前,事务 B 的更新语句先发起了。前面说过了,虽然事务 C’还没提交,但是 (1,2) 这个版本也已经生成了,并且是当前的最新版本。那么,事务 B 的更新语句会怎么处理呢?
这时候,我们在上一篇文章中提到的“两阶段锁协议”就要上场了。事务 C’没提交,也就是说 (1,2) 这个版本上的写锁还没释放。而事务 B 是当前读,必须要读最新版本,而且必须加锁,因此就被锁住了,必须等到事务 C’释放这个锁,才能继续它的当前读。

4.3 隔离级别

可重复读的核心就是一致性读;而事务更新数据的时候,只能用当前读。如果当前的记录的行锁被其他事务占用的话,就需要进入锁等待。

而读提交的逻辑和可重复读的逻辑类似,它们最主要的区别是:

① 在可重复读隔离级别下,只需要在事务开始的时候创建readview,之后事务里的其他查询都共用这个readview;

② 在读提交隔离级别下,每一个语句执行前都会重新算出一个新的readview

下面是读已提交时的状态图,可以看到这两个查询语句的创建 read view 数组的时机发生了变化,就是图中的 read view 框。
MySQL事务的实现原理_第17张图片

这时,事务 A 的查询语句的 read view 数组是在执行这个语句的时候创建的,时序上 (1,2)、(1,3)的生成时间都在创建这个视图数组的时刻之前。

但是,在这个时刻:(1,3) 还没提交,属于情况 1,不可见;(1,2) 提交了,属于情况 3,可见。 所以,这时候事务 A 查询语句返回的是 k=2 。

5. MVCC是如何解决幻读的?

可重复读不能避免幻读的产生。幻读,指的是当某个事务在读取某个范围内的记录时,另外一个事务又在该范围内插入了新行,当之前的事务再次读取该范围的记录时,会产生幻行。InnoDB存储引擎通过多版本并发控制(MVCC)解决了幻读问题。
我们先来看一个可重复读隔离级别(RR)下的实例:

MySQL事务的实现原理_第18张图片

分析以上实例:
理论上,在t3时刻,事务B插入了一条符合事务A查询条件的记录并提交了事务,那么事务A在t2和t4时刻的查询应该是不一样的,但是实际结果确是:事务A前后查询结果一致。

实际上,这是MVCC的功劳。MVCC的实现,是通过保存数据在某个时间点的快照来实现的。也就是说,在某个时间点事务开启时,其看到的数据是该时间点之前已经提交的数据的快照内容,这就保证了事务执行期间看到的数据时一致的。

分析“RR级别避免幻读”图示中的事务:
事务A在t2时刻获取到快照a,此快照将持续到t4时刻事务A提交事务。
事务B在t3时刻插入一条数据,但事务A的快照a是基于t2时刻的快照,所以事务A并不能获取到事务B插入的数据。

在MySQL中,通过多版本并发控制(MVCC)去避免幻读的问题,但是只是在select的时候可以避免幻读,update之后再select还是可能会出现幻读现象,因为update时会使用当前读,加锁的形式(加的是next key 锁解决幻读)。

在快照读读情况下,mysql通过mvcc来避免幻读。
在当前读读情况下,mysql通过next-key来避免幻读。
select * from t where a=1;属于快照读
select * from t where a=1 lock in share mode;属于当前读

一致性锁定读和一致性非锁定读的概念:

在一个事务中查询数据时,普通的SELECT语句不会对查询的数据进行加锁,其他事务仍可以对查询的数据执行更新和删除操作。因此,InnoDB提供了两种类型的锁定读来保证额外的安全性:

SELECT ... LOCK IN SHARE MODE

SELECT ... FOR UPDATE

SELECT ... LOCK IN SHARE MODE: 对读取的行添加S锁,其他事物可以对这些行添加S锁,若添加X锁,则会被阻塞。

SELECT ... FOR UPDATE: 会对查询的行及相关联的索引记录加X锁,其他事务请求的S锁或X锁都会被阻塞。 当事务提交或回滚后,通过这两个语句添加的锁都会被释放。 注意:只有在自动提交被禁用时,SELECT FOR UPDATE才可以锁定行,若开启自动提交,则匹配的行不会被锁定。

一致性非锁定读(consistent nonlocking read) 是指InnoDB存储引擎通过多版本控制(MVVC)读取当前数据库中行数据的方式。如果读取的行正在执行DELETE或UPDATE操作,这时读取操作不会因此去等待行上锁的释放。相反地,InnoDB会去读取行的一个快照。所以,非锁定读机制大大提高了数据库的并发性。

一致性非锁定读是InnoDB默认的读取方式,即读取不会占用和等待行上的锁。在事务隔离级别READ COMMITTEDREPEATABLE READ下,InnoDB使用一致性非锁定读。

然而,对于快照数据的定义却不同。在READ COMMITTED事务隔离级别下,一致性非锁定读总是读取被锁定行的最新一份快照数据。而在REPEATABLE READ事务隔离级别下,则读取事务开始时的行数据版本

6. MySQL InnoDB的锁

6.1 共享锁和独占锁

InnoDB 实现了标准的行级锁,包括两种:共享锁(简称 s 锁)、排它锁(简称 x 锁)

  • 共享锁:允许持锁事务读取一行
  • 排它锁:允许持锁事务更新或者删除一行

如果事务t1 持有行 r 的读锁,那么另一个事务t2 请求 r 的锁时,会做如下处理:

  • t2 请求读锁立即被允许,结果 t1和t2 都持有 r 行的 读锁
  • t2 请求写锁不能被立即允许

如果 t1持有 r 的写锁,那么t2 请求 r 的读、写 锁都不能被立即允许,t2 必须等待t1释放写锁才行。

6.2 意向锁

**InnoDB 支持多粒度的锁,允许表级锁和行级锁共存。**一个类似于 LOCK TABLES … WRITE 的语句会获得这个表的写锁。为了实现多粒度锁,InnoDB 使用了意向锁(简称 I 锁)。I 锁是表明一个事务要获得针对一行记录的某种锁(写锁或读锁)的对应表的表级锁,有两种:

  • 意向排它锁(简称 IX 锁)表明一个事务意图在某个表中设置某些行的写锁
  • 意向共享锁(简称 IS 锁)表明一个事务意图在某个表中设置某些行的读锁

例如, SELECT … LOCK IN SHARE MODE 设置一个 IS 锁, SELECT … FOR UPDATE 设置一个 IX 锁。意向锁的原则:

  • 一个事务必须先持有该表上的意向读锁或者更强的锁才能持有该表中某行的读锁
  • 一个事务必须先持有该表上的 意向写锁才能持有该表中某行的写锁

6.3 三种锁算法

使用唯一索引的时候用不上间隙锁。例如,id 列有唯一索引,下面的语句只是用索引记录锁(针对id=100的行)不管其他会话是否在前面的间隙中插入行。

SELECT * FROM child WHERE id = 100;

如果id列没有索引或者是非唯一索引,那么这条语句的确会锁住前面的间隙。

InnoDB有三种行锁的算法:

① Record Lock(记录锁):单个行记录上的锁。记录锁针对索引记录。如:SELECT c1 FROM t WHERE c1 = 10 FOR UPDATE; 阻止其他所有事务插入、修改或者删除 t.c1 是 10 的行。

② Gap Lock(间隙锁):锁定一个范围,但不包括记录本身。GAP锁的目的,是为了防止同一事务的两次当前读,出现幻读的情况。间隙锁(gap)是索引记录之间上的锁,或者说第一个索引记录之前或最后一个索引记录之后的间隔上的锁。例如,SELECT c1 FROM t WHERE c1 BETWEEN 10 and 20 FOR UPDATE; 阻止其他事务插入 t.c1 = 15 的记录,不管是否已经有这种值在本列中,因为这个范围内的所有值都被上锁了。

记录锁是加在索引上的锁,间隙锁是加在索引之间的。

③ Next-Key Lock:(记录锁+间隙锁),锁定一个范围,并且锁定记录本身。对于行的查询,都是采用该方法,主要目的是解决幻读的问题。举个例子10,20,30,那么该索引可能被Next-Key Locking的区间为:

MySQL事务的实现原理_第19张图片

不是所有索引都会加上Next-key Lock的,这里有一种特殊的情况,在查询的列是唯一索引(包含主键索引)的情况下,Next-key Lock会降级为Record Lock

下面的例子很好:

CREATE TABLE test (
    x INT,
    y INT,
    PRIMARY KEY(x),     // x是主键索引
    KEY(y)    			// y是普通索引
);
INSERT INTO test select 3, 2;
INSERT INTO test select 5, 3;
INSERT INTO test select 7, 6;
INSERT INTO test select 10, 8;

我们现在会话A中执行如下查询语句:

SELECT * FROM test WHERE y = 3 FOR UPDATE

对于主键索引x :

MySQL事务的实现原理_第20张图片

对于辅助索引y:

MySQL事务的实现原理_第21张图片

Gap Lock的作用:是为了阻止多个事务将记录插入到同一个范围内,设计它的目的是用来解决Phontom Problem(幻读问题)。在MySQL默认的隔离级别(Repeatable Read)下,InnoDB就是使用它来解决幻读问题。

幻读:是指在同一事务下,连续执行两次同样的SQL语句可能导致不同的结果,第二次的SQL可能会返回之前不存在的行,也就是第一次执行和第二次执行期间有其他事务往里插入了新的行。

MVCC是解决读写并行的幻读,而next key lock 是解决写写并行的幻读。

InnoDB 是采用 Next-key 锁机制,解决幻读。

当我们想要插入球员艾利克斯·伦(身高2.16米)的时候,事务B会超时,无法插入该数据。这是因为采用了 Next-Key 锁,会将 height>2.08的范围都进行锁定,就无法插入符合这个范围的数据了。然后事务A重新进行条件范围的查询,就不会出现幻读的情况。

MySQL事务的实现原理_第22张图片

你可能感兴趣的:(MySQL高级)