MySQL - 基础概念篇 - 深入理解事务和锁

MySQL - 基础概念篇 - 深入理解事务和锁

这个问题我们之前讨论过,在 MySQL- 锁 处理并发 的开头我们就对这两个概念进行了区分。
隔离只是隔离读,写的时候是不隔离的,写的时候读写的都是别的事务提交的最新的值,而且如果别的事务正在更新(持有行锁),当前事务的更新语句还得阻塞等待获取行锁。因为不读最新值的话,之前对同一行数据的修改就会被覆盖掉,所以,更新必须是当前读,而不是快照读
而且在同一个事务中,执行更新之后再查询这个被更新的值,就是 update 语句根据当前值更新之后的值,而不再是在事务开始时的快照里保存的值,这是为了保证事务中的数据一致性

接下来我们来分析这个过程,主要是分析视图
MySQL 的视图

  1. 查询语句虚拟表 (view)
    1. InnoDB 实现 MVCC 时的一致性读视图 (consisitent read view),作用是事务执行期间用来定义“我能看到什么数据”。这个视图没有物理结构,就是通过高低水位,数据版本号,undo 日记来进行判断数据可见不可见,达到 MVCC 目的
      这两个视图的区别是,前者读取的是最新的数据,后者读取的是某一个时刻的快照

在可重复读隔离级别下,事务在启动的时候就“拍了个快照”。注意,这个快照是基于整库的。为什么可以这么快,因为实际上我们并不需要把整个库拷贝一遍

InnoDB 里面每个事务有一个唯一的事务 ID,叫作 transaction id。它是在事务开始的时候向 InnoDB 的事务系统申请的,是按申请顺序严格递增的。也就是说通过对比事务 ID,我们就能判断那个事务先创建哪个后创建。(注意仅能对比事务的创建时间,不能判断事务的提交时间)
而每行数据也都是有多个版本的。每次事务更新数据的时候,都会生成一个新的数据版本,并且把 transaction id 赋值给这个数据版本的类似于事务 ID 字段的东西,记为 row trx_id。同时,旧的数据版本要保留,并且在新的数据版本中,能够有信息可以直接拿到它(比如指针,这个指针,就是 undo log)。
也就是说,数据表中的一行记录,其实可能有多个版本 (row),每个版本有自己的 row trx_id。
最终从上一个版本可以找到过去的版本,且每一个版本是哪个事务改的都可以确定
通过版本号将一行数据连接成一个版本链,然后各个事务通过其应该读取的事务 ID 的范围(低水位 - 高水位)和数据行的版本链中的事务 ID 进行对比,拿到它该读的最高版本的数据

一行数据从新版本到老版本的链接,就是 undo log;老版本的数据并不是物理上真实存在的,而是每次需要的时候根据当前版本和 undo log 计算出来的。

明白了多版本和 row trx_id 的概念后,我们再来想一下,InnoDB 是怎么定义那个“100G”的快照的。

首先,(可重复地级别的)事务在启动的时候,需要确定当前可重复读的数据的版本到底是哪个版本。

在实现上, InnoDB 为每个事务构造了一个数组,用来保存这个事务启动瞬间,当前正在“活跃”的所有事务 ID。“活跃”指的就是,启动了但还没提交。(活跃的事务可能是很久之前创建的但是还没提交的事务。)
数组里面事务 ID 的最小值记为低水位,当前系统里面已经创建过的事务 ID 的最大值加 1 记为高水位。
这个视图数组和高水位,就组成了当前事务的一致性读视图(read-view)。
注意这个概念定义,当前活跃事务数组和高水位,确定了一个一致性视图

这很好理解,还没提交的事务中最低的事务之前的事务,都已经提交,因此对当前事务可见,当前已经创建 ID 最大的事务以后的事务,开始都不一定开始了,肯定没提交,所以肯定是不可见的
重点就在中间这一段,中间这一段有的提交了有的还没提交,如何确定呢?
很简单,在低水位和高水位之间的事务,如果是未完成视图(在活跃事务数组里面),则不可见,否则可见。

我们在确定一个事务到底用一个数据的那个版本的值的时候,都是从当前值往前遍历,直到遍历到当前事务可见的事务提交的版本为止。就是最高的版本。

此外,还有个特殊情况,本事务中修改的数据,即使未提交的数据也可以在本事务的后面部分读取到,这个在本篇文章的最开头也提到过

例如,开启一个事务之后的启动瞬间的事务 id :1,2,3,4,5,6,7,8,9; 其中活跃的事务 id 组成视图数组:[3 , 4 , 5]; 低水位:3 高水位为:9 + 1 = 10; 针对一个数据版本的 row trx_id 的几种情况:
1、row trx_id < 3 时,表明这个版本是已提交的事物生成的,可见;
2、row trx_id > 10 时,表明这个版本是将来启动的事物生成的,不可见;
3、3 <= row trx_id < 10 时,如果 row trx_id 是 3,4,5 则不可见,否则可见,就这么简单。

所以,事务开始的时候,实际上并没有进行额外的创建快照的操作,只是统计了当前还未执行完成的事务存到数组里面并查找当前最大的事务 ID,就只做了这两件事。

简化之后,判断一个事务的更新对当前视图是否可见。
在可重复读的隔离级别中,一个数据版本,对于一个事务视图来说,除了自己的更新总是可见以外,有三种情况:
版本未提交,不可见;
版本已提交,但是是在视图创建后提交的,不可见;(因为可重复读级别只在事务开始的时候创建一致性读视图)
版本已提交,而且是在视图创建前提交的,可见。

更新数据都是先读后写的,而这个读,只能读当前的值,称为“当前读”(current read)。
然后在 select 语句中强制进行当前读读取最新的值
把事务 A 的查询语句 select * from t where id=1 修改一下,加上 lock in share mode 或 for update,就都可以读到版本号是 101 的数据,返回的 k 的值是 3。下面这两个 select 语句,就是分别加了读锁(S 锁,共享锁)和写锁(X 锁,排他锁)。
mysql> select k from t where id=1 lock in share mode;
mysql> select k from t where id=1 for update;
注意,select for update 添加的是一个排它锁,在不同的场景下有可能添加行锁也可能添加表锁

再来看读已提交的隔离级别,
读已提交就是在每一次读数据之前计算一次当前未结束的事务数组和高水位线程,即确定一致性视图
因此,他会读取 查询前已提交的事务修改。

关于事务和 mvcc 的联系
InnoDB 的行数据有多个版本,每个数据版本有自己的 row trx_id,每个事务或者语句有自己的一致性视图。普通查询语句是一致性读,一致性读会根据 row trx_id 和一致性视图确定数据版本的可见性。
对于可重复读,查询只承认在事务启动前就已经提交完成的数据;
对于读提交,查询只承认在语句启动前就已经提交完成的数据;
而当前读,总是读取已经提交完成的最新版本。

MySQL8.0 已经把表结构放到 InnoDB 字典里,表结构支持可重复读。在 mysqldump 过程中修改表结构并不会导致程序终止了。已测!

在可重复读场景下,执行先查字段再根据查询结果更新相同字段的操作,有可能会失败,原因是,执行查询操作的时候,读取的是事务开始的快照,而且这个快照也不会包含当前还未提交的事务,而 update 语句的读是当前读,因此,select 语句的结果实际上不具备参考性,因为它查出的不是最新值,比如,a 事务开启时,在 a 的查询语句执行之后,b 事务开启然后更新了对应的字段然后提交了,然后 a 再开始更新,这样,a 更新的时候,select 的结果将不再准确,更新可能失败。因此,我们需要检查 updates 语句的影响行数,如果为 0,则自动重试一定次数(比如 10 次,不要无限重试),重新查询,然后根据查询的值更新。
其实上面这个场景可以通过执行 select for update 解决,但是这样会比较影响并发的性能,所以,一般我们建议 select 语句不加锁,而是通过 update 语句添加条件,在条件不满足的时候不更新,更新行数为 0,以便在更新行数为 0 点时候进行重试。

什么叫主从延迟,从主库同步到从库的时候
从主库同步过来的 binlog 阻塞,就会造成主从延迟
长事务就会造成主从延迟

事务的 MVCC 有时候也会带来其他问题,比如 count(*) 的时候的性能问题。具体请看
《MySQL- 实践篇 - 为什么 count(*) 这么慢》

如果表里已经有一行数据,比如(name=3,age=20)
现在我执行更新语句,update table_name set age=20 where name =3
此时,依然是会进行更新,会加写锁,事务提交之后再释放锁。
可以通过两个事务来实验,1 个事务照题目那样 update 但先不 commit,另 1 个事务也去尝试 update 同一行数据,如果被阻塞了,说明是会加写锁的。 确实是会阻塞的。
为什么 mysql 会选择这种策略,应该也是跟事务有个,因为在事务的生命周期中,有可能在其它的事务中已经将这个值改了,那么其实在当前事务中,你应该再改回来,而不是从 MVCC 的快照中判断值没变所以不做更改。
但是如果我执行 update table_name set age=20 where name =3 and age = 20 那么这个语句很明显是不会执行的,
我们在一个更加复杂的场景里测试这个现象
sessiona,先查 select * from table_name where name=3,查完之后,另一个 sessionb 开始更新 update table_name set age=40 where name =3,然后 session a,执行 update table_name set age=40 where name =3,然后 session a 再查 select * from table_name where name=3,session a 会返回 3,40,因为,session a 中的更新语句在(当前读)看到 session b 的修改之后,再次执行了更新,所以这个结果对 session a 可见,所以最后输出 3,40,如果在 session b 执行 update table_name set age=40 where name =3 之后,session a 执行的是 update table_name set age=40 where name =3 and age = 40,那么这个 update 语句就不会执行,session a,对最新的结果就不可见,最终第二次查询输出的就还是 3,20
上面我们的验证结果都是在 binlog_format=statement 格式下进行的。如果是 binlog_format=row 并且 binlog_row_image=FULL 的时候,由于 MySQL 需要在 binlog 里面记录所有的字段,所以在读数据的时候就会把所有数据都读出来了。上面的例子的执行结果会不一样
真的很神奇