你真的懂 MySQL 的一致性读和当前读的区别吗?

引言

本文章将逐步拆分讲解 ReadView,深入理解当前读和一致性读的区别。使用查询语句和更新语句逐步分析 MySQL 是如何进行事务的多版本并发控制的。我们以一个问题开始我们的讨论吧

begin/start transaction 命令代表了事务的开启吗?换句话说指向该命令的时候,会生成一个 ReadView 吗?

正文

首先需要解释的就是 begin/start transaction 命令并不是一个事务的起点,在执行到它们之后的第一个操作的 InnoDB 表的语句,事务才真正启动。如果想要马上启动一个事务,需要使用 start transaction with consitent snapshot 命令。这里将的视图都是一致性视图:consistent read view,而不是 View 视图虚拟表

  1. begin/start transaction一致性视图是在第执行第一个快照读语句时创建的
  2. start transaction with consitent snapshot一致性视图是在执行该命令时创建的

接下来我们将 read view 拆开来看,进一步理解 MVCC,也就是"快照"在 MVCC 里是怎么工作的?

在可重复读隔离级别下,事务在启动的时候就"拍了个快照"。注意,这个快照是基于整库的。InnoDB 里面每个事务有一个唯一的事务 ID,叫作 transaction id。它是在事务开始的时候向 InnoDB 的事务系统申请的,是按申请顺序严格递增的 而每行数据也都是有多个版本的。每次事务更新数据的时候,都会生成一个新的数据版本,并且把 transaction id 赋值给这个数据版本的事务 ID,记为 row trx_id 。同时,旧的数据版本要保留,并且在新的数据版本中,能够有信息可以直接拿到它。也就是说,数据表中的一行记录,其实可能有多个版本 (row),每个版本有自己的 row trx_id,下图就是 undo log 引用链

其实在 InnoDB 的存储中,每一个行数据就是上图 V4 表格中的内容,包含了 trx_id 和 roll_pointeri 字段。而后面虚线连接的三个表格其实就是 undo log 中的内容。此外 V3,V2,V1 版本在数据库中并不是物理真是存在的,而是每次需要这个版本的数据时,去根据 roll_pointer 指针计算。例如 V3 版本的数据就是通过 V4 依次执行 U3 和 U2 命令计算而来的

所以,一个事务启动的时候,只需要声明当前事务 ID 就能够知道哪些数据是否可以读取。以当前事务启动为准,如果一个数据版本是在当前事务之前生成的,那么就认为可读。如果是当前事务之后才生成的,就说明不能够读这个版本,需要沿着 roll_pointer 指针找到之前的版本

我们继续理解一致性视图 ReadView。每一次生成 ReadView 时,都会记录下图中的四个值。其中活跃的事务 ID 列表就是指已经启动的但还未提交的事务

数据版本的可见性规则就是基于 trx_id 和 ReadView 的对比结果完成的,具体的流程如下

  1. 如果 trx_id 是当前的事务,那么就可以访问
  2. 如果事务 id 是活跃 id 之前就已经提交的事务,那么就可以访问
  3. 如果事务 id 是当前事务之后才开启的事务,那么就不可以访问
  4. 如果事务 id 处于活跃 id 集合中,有两种情况
  • 4.1 活跃 id 已经提交,可以访问
  • 4.2 活跃 id 未提交,不可以访问

通过时间节点(trx_id 的序号)来看就是下图的效果

读到这里,你就明白了"所有数据都有多个版本"的这个特性,以及为什么 MySQL 怎么实现"秒级创建快照"的能力

下面我们进一步用示例理解上面的流程,并进一步理解 Repeatable Read 和 Read Commit

当事务 104 执行第一个 select name from person where id = 1;,会生成一个 ReadView,如下

此时 undo log 的版本链如下

当查询的时候,会拿着 V2 版本的 trx_id,也就是 102 去和 ReadView 中的内容进行判断(逻辑上面讲过)

  1. 102 是否是create_trx_id?不是,继续判断
  2. 102 是否小于 101?不是,继续判断
  3. 102 是否大于 104?不是,继续判断
  4. 102 是否在m_ids中?不在,继续判断,只剩下最后一种可能
  5. 102 不在m_ids中,并且事务员已经提交 ==> 可以访问

当事务 104 执行第二个 select name from person where id = 1;,根据不同的隔离级别有不同的处理方式

  1. RC 级别,此时会在生成一个新的 ReadView。那么此时的 ReadView 是不是就和第一次不一样了(比如这一次生成的 ReadView 中,m_ids只有 101 了)?那整个查询的结果是不是可能就不一样了,这里就不再继续演示
  2. RR 级别,会继续沿用第一次生成的 ReadView,所以判断的结果是一样的!

说完了读,我们继续讲更新数据的原则,在下面的例子中(事务 C 采用的是自动提交模式),最后事务 A 查询的结果是 3,那么根据一致性读,结果似乎不正确?

在事务 B 的 update 语句中,事务 B 的活动是先生成的,之后事务 C 才提交,不是应该看不见 (1,2) 吗,怎么能算出 (1,3) 来?

其实如果事务 B 在更新之前查询一次数据,这个查询返回的 k 的值确实是 1。

但是,当它要去更新数据的时候,就不能再在历史版本上更新了,否则事务 C 的更新就丢失了。因此,事务 B 此时的 set k=k+1 是在(1,2)的基础上进行的操作

所以,这里就用到了这样一条规则:

更新数据都是先读后写的,而这个读,只能读当前的值,称为"当前读"(current read)

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

当前读(Current Read),除了 update 语句外,select 语句如果加锁,也是当前读

所以,如果把事务 A 的查询语句 select * from t where id=1 修改一下,加上 lock in share mode 或 for update,也都可以读到版本号是 101 的数据,返回的 k 的值是 3。下面这两个 select 语句,就是分别加了读锁(S 锁,共享锁)和写锁(X 锁,排他锁)

sql 复制代码
select k from t where id=1 lock in share mode;
select k from t where id=1 for update;

如果将事务 C 修改为下面的情况,事务 C 不是立马提交的,那么事务 B 又是如何处理 update 的?

这就要进一步提到"两阶段锁"。事务 C 没提交,也就是说 (1,2) 这个版本上的写锁还没释放。而事务 B 是当前读,必须要读最新版本,而且必须加锁,因此就被锁住了,必须等到事务 C 释放这个锁,才能继续它的当前读

截止到现在,我们已经把一致性读(Consistent Read)和当前读(Current Read)以及行锁串起来了

总结来说就是:

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

本篇文章参考了《极客时间 MySQL45讲》和其他资料进行的笔记归纳总结,希望能帮助你更好的理解

相关推荐
楽码2 分钟前
安装和编写grpc协议文件
服务器·后端·grpc
码农之王4 分钟前
(二)TypeScript前置编译配置
前端·后端·typescript
一眼万年0412 分钟前
Kafka LogManager 深度解析
后端·kafka
天行健的回响13 分钟前
一次多线程改造实践:基于ExecutorService + CompletionService的并发处理优化
后端
盖世英雄酱5813637 分钟前
🚀不改SQL,也能让SQL的执行效率提升100倍
java·数据库·后端
前端小巷子40 分钟前
IndexedDB:浏览器端的强大数据库
前端·javascript·面试
陈随易1 小时前
Bun v1.2.16发布,内存优化,兼容提升,体验增强
前端·后端·程序员
GetcharZp1 小时前
「Golang黑科技」RobotGo自动化神器,鼠标键盘控制、屏幕截图、全局监听全解析!
后端·go
程序员岳焱1 小时前
Java 与 MySQL 性能优化:Linux服务器上MySQL性能指标解读与监控方法
linux·后端·mysql