跳过正文

MySQL 事务

·1189 字·6 分钟
目录
mysql - 这篇文章属于一个选集。
§ 5: 本文

MySQL 事务有什么特性
#

考察事务的 ACID 特性。

  • 原子性(Atomicity):一个事务中的所有操作,要么全部完成,要么全部不完成,不会结束在中间某个环节,而且事务在执行过程中发生错误,会被回滚到事务开始前的状态,就像这个事务从来没有执行过一样。
  • 一致性(Consistency):是指事务操作前和操作后,数据满足完整性约束,数据库保持一致性状态。
  • 隔离性(Isolation):数据库允许多个并发事务同时对其数据进行读写和修改的能力,隔离性可以防止多个事务并发执行时由于交叉执行而导致数据的不一致。
  • 持久性(Durability):事务处理结束后,对数据的修改就是永久的,即便系统故障也不会丢失。

回答
#

MySQL 事务有 ACID 四大特性,分别是原子性、一致性、隔离性、持久性

  • 原子性的意思是事务中的所有操作要么全部完成,要么全部不完成,不会结束在中间某个环节,原子性是由 undo log 日志保证的;
  • 一致性的意思是事务执行前后,数据库的状态必须保持一致性,一致性是由通过持久性+原子性+隔离性这三个共同保证的;
  • 隔离性的意思是许多个事务并发读写数据库,可以防止多个事务并发读写同一个数据的时候,导致数据不一致问题的发生,隔离性是由 MVCC 和锁保证的;
  • 持久性的意思是保证事务完成后对数据的修改就是永久的,不会因为系统故障而丢失,持久性是由 redo log 日志保证的;

ACID 特性在项目中是如何体现的?
#

在 ACID 这四个特性中,一致性(Consistency,C)是最终目的,而 原子性(Atomicity,A)、隔离性(Isolation,I)和持久性(Durability,D)都是为了实现和保障一致性而存在的手段。AID => C

回答
#

  • A 特性:当事务在运行过程中,一旦事务失败,就利用 Undo Log 将数据反向恢复到事务开始前的样子。
  • I 特性: MySQL 通过 MVCC(多版本并发控制)锁机制(行锁、间隙锁) 实现。读操作读取历史版本数据,写操作加锁互斥。
  • D 特性: MySQL 通过 Redo Log(重做日志) 实现,采用 WAL(Write-Ahead Logging)机制。事务提交时,先将修改记录快速顺序写入 Redo Log 磁盘文件,只要 Redo Log 写入成功,即使断电,重启后 MySQL 也能根据日志将数据恢复。
  • C 特性:一致性是 ACID 的最终目的。它既依赖于开发者的业务代码逻辑,也依赖数据库的约束以及 AID 的共同保障。

MySQL 事务和 Redis 事务有何区别?
#

Redis事务没保证原子性和持久性。

  • 原子性:Redis 事务没有回滚功能,没办法实现跟MySQL事务一样的原子性,就是没办法保证事务执行期间,要不全部失败,要不全部成功,如果Redis事务执行过程中,中间有命令是错误的,不会停止执行和回滚,这时候事务的执行会出现半成功的状态。

  • 持久性:如果 Redis 使用了 RDB 模式,那么,在一个事务执行后,而下一次的 RDB 快照还未执行前,如果发生了实例宕机,这种情况下,事务修改的数据也是不能保证持久化的。如果 Redis 采用了 AOF 模式,因为 AOF 模式的三种配置选项 no、everysec 和 always 都会存在数据丢失的情况,所以,事务的持久性属性也还是得不到保证。所以,不管 Redis 采用什么持久化模式,事务的持久性属性是得不到保证的。

    使用 AOF = always 时,先把写命令追加到 aof buffer 中,下一次进入事件循环循环后,再将 buffer 写到磁盘上。结合 while 循环处方法的调用顺序,可以看出确实是这样的。那么也就是说,这次写到磁盘上的内容是上一个事件循环产生的。即使设置为always,也会丢失一个循环的数据。

回答
#

MySQL事务能够实现 ACID 四大特性,而Redis事务没保证原子性和持久性。

Redis 事务没有回滚功能,没办法实现跟MySQL事务一样的原子性,就是没办法保证事务执行期间,要不全部失败,要不全部成功,Redis 事务执行过程中,如果中途有命令执行出错了,不会停止和回滚,而是继续执行,那么就可能出现半成功的状态。

Redis 不管是 AOF 模式,还是 RDB 快照,都没办法保证数据不丢失,所以 Redis 事务不具有持久性。

MySQL 的事务隔离级别有哪些,分别解决了哪些问题?
#

MySQL 共有四个隔离级别如下:

  • 读未提交(read uncommitted),指一个事务还没提交时,它做的变更就能被其他事务看到;
  • 读提交(read committed),指一个事务提交之后,它做的变更才能被其他事务看到;
  • 可重复读(repeatable read),指一个事务执行过程中看到的数据,一直跟这个事务启动时看到的数据是一致的,MySQL InnoDB 引擎的默认隔离级别;
  • 串行化(serializable );会对记录加上读写锁,在多个事务对这条记录进行读写操作时,如果发生了读写冲突的时候,后访问的事务必须等前一个事务执行完成,才能继续执行;

按隔离水平高低排序如下:

image-20260427164831795

针对不同的隔离级别,并发事务时可能发生的现象也会不同。

image-20260427164848504

脏读、不可重复读、幻读的意思:

  • 脏读是指一个事务读取了另一个事务还未提交的数据,如果另一个事务回滚,则读取的数据是无效的。脏读可能导致数据的不一致性。
  • 不可重复读是指一个事务多次读取同一条记录,但是在此期间另一个事务修改了该记录,导致前后读取的数据不一致。不可重复读可能导致数据的不一致性。
  • 幻读是指一个事务多次执行同一个查询,但是在此期间另一个事务插入了符合该查询条件的新数据,导致前后查询的结果不一致。幻读可能导致数据的不完整性。

回答
#

MySQL 默认隔离级别是可重复读,除此之外, MySQL 还支持读未提交、读提交、串行化 这三个隔离级别。

我了解到事务并发问题存在脏读、不可重复读、幻读这三种,不同的隔离级别,解决的问题也各不同的。

  • 读未提交一个问题都没有解决。
  • 读已提交避免了脏读问题,但是还存在不可重复读和幻读这两个问题。
  • 可重复读避免了脏读和不可重复读的问题,不过对于幻读问题是很大程度上避免了,没有完全避免。
  • 串行化是所有问题都可以避免,但是事务的并发性能是最差的。

事务隔离详解
#

隔离级别详解
#

初始时:

mysql> create table T(c int) engine=InnoDB; 

insert into T(c) values(1);

以如下两个事务进行介绍:

image-20260427174259713

来看看在不同的隔离级别下,事务 A 会有哪些不同的返回结果,也就是图里面 V1、V2、V3 的返回值分别是什么。

  • 若隔离级别是“读未提交”, 则 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。

在实现上,数据库里面会创建一个视图,访问的时候以视图的逻辑结果为准

  • 在“可重复读”隔离级别下,这个视图是在事务启动时创建的,整个事务存在期间都用这个视图。
  • 在“读提交”隔离级别下,这个视图是在每个 SQL 语句开始执行的时候创建的。
  • 在“读未提交”隔离级别下直接返回记录上的最新值,没有视图概念;
  • 而“串行化”隔离级别下直接用加锁的方式来避免并行访问。

事务隔离详解
#

以如下例子进行讲解,

下面是一个只有两行的表的初始化语句。

mysql> CREATE TABLE `t` (
  `id` int(11) NOT NULL,
  `k` int(11) DEFAULT NULL,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB;
insert into t(id, k) values(1,1),(2,2);
image-20260427190745395

begin/start transaction 命令并不是一个事务的起点,在执行到它们之后的第一个操作 InnoDB 表的语句,事务才真正启动。如果你想要马上启动一个事务,可以使用 start transaction with consistent snapshot 这个命令。

快照的具体实现

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

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

也就是说,数据表中的一行记录,其实可能有多个版本 (row),每个版本有自己的 row trx_id。

如下图所示,就是一个记录被多个事务连续更新后的状态。

image-20260427191244397

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

而图中的虚线箭头就是 Undo Log,而 V1、V2、V3 并不是物理上真实存在的,而是每次需要的时候根据当前版本和 undo log 计算出来的。比如,需要 V2 的时候,就是通过 V4 依次执行 U3、U2 算出来。

而生成快照:

按照可重复读的定义,一个事务启动的时候,能够看到所有已经提交的事务结果。但是之后,这个事务执行期间,其他事务的更新对它不可见。

因此,一个事务只需要在启动的时候声明说,“以我启动的时刻为准,如果一个数据版本是在我启动之前生成的,就认;如果是我启动以后才生成的,我就不认,我必须要找到它的上一个版本”。

当然,如果“上一个版本”也不可见,那就得继续往前找。还有,如果是这个事务自己更新的数据,它自己还是要认的。

在实现上, InnoDB 为每个事务构造了一个数组,用来保存这个事务启动瞬间,当前正在“活跃”的所有事务 ID。“活跃”指的就是,启动了但还没提交。

数组里面事务 ID 的最小值记为低水位,当前系统里面已经创建过的事务 ID 的最大值加 1 记为高水位。

这个视图数组和高水位,就组成了当前事务的一致性视图(read-view)。

而数据版本的可见性规则,就是基于数据的 row trx_id 和这个一致性视图的对比结果得到的。

这个视图数组把所有的 row trx_id 分成了几种不同的情况。

image-20260427191724162

这样,对于当前事务的启动瞬间来说,一个数据版本的 row trx_id,有以下几种可能:

  • 如果落在绿色部分,表示这个版本是已提交的事务或者是当前事务自己生成的,这个数据是可见的;
  • 如果落在红色部分,表示这个版本是由将来启动的事务生成的,是肯定不可见的;
  • 如果落在黄色部分,那就包括两种情况
    • a.若 row trx_id 在数组中,表示这个版本是由还没提交的事务生成的,不可见;
    • b. 若 row trx_id 不在数组中,表示这个版本是已经提交了的事务生成的,可见。

比如,对于之前记录数据来说,如果有一个事务,它的低水位是 18,那么当它访问这一行数据时,就会从 V4 通过 U3 计算出 V3,所以在它看来,这一行的值是 11。

有了这个声明后,系统里面随后发生的更新,就跟这个事务看到的内容无关了。因为之后的更新,生成的版本一定属于上面的 2 或者 3(a) 的情况,而对它来说,这些新的数据版本是不存在的,所以这个事务的快照,就是“静态”的了。

InnoDB 利用了“所有数据都有多个版本”的这个特性,实现了“秒级创建快照”的能力。

以之前的示例,分析下事务 A 的语句返回的结果。

这里,我们不妨做如下假设:

  • 事务 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]。

为了简化分析,我先把其他干扰语句去掉,只画出跟事务 A 查询逻辑有关的操作:

image-20260427192332060

从图中可以看到,第一个有效更新是事务 C,把数据从 (1,1) 改成了 (1,2)。这时候,这个数据的最新版本的 row trx_id 是 102,而 90 这个版本已经成为了历史版本。

第二个有效更新是事务 B【更新是当前读(后续讲解),所以能读到 (1,2) 】,把数据从 (1,2) 改成了 (1,3)。这时候,这个数据的最新版本(即 row trx_id)是 101,而 102 又成为了历史版本。

在事务 A 查询的时候,其实事务 B 还没有提交,但是它生成的 (1,3) 这个版本已经变成当前版本了。但这个版本对事务 A 必须是不可见的,否则就变成脏读了。

好,现在事务 A 要来读数据了,它的视图数组是 [99,100]。当然了,读数据都是从当前版本读起的。所以,事务 A 查询语句的读数据流程是这样的:

  • 找到 (1,3) 的时候,判断出 row trx_id=101,比高水位大,处于红色区域,不可见;
  • 接着,找到上一个历史版本,一看 row trx_id=102,比高水位大,处于红色区域,不可见;
  • 再往前找,终于找到了(1,1),它的 row trx_id=90,比低水位小,处于绿色区域,可见。

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

这个判断规则是从代码逻辑直接转译过来的,但是正如你所见,用于人肉分析可见性很麻烦。

所以,我来给你翻译一下。一个数据版本,对于一个事务视图来说,除了自己的更新总是可见以外,有三种情况:

  1. 版本未提交,不可见;
  2. 版本已提交,但是是在视图创建后提交的,不可见;
  3. 版本已提交,而且是在视图创建前提交的,可见。

现在,我们用这个规则来判断A最后的查询结果,事务 A 的查询语句的视图数组是在事务 A 启动的时候生成的,这时候:

  • (1,3) 还没提交,属于情况 1,不可见;
  • (1,2) 虽然提交了,但是是在视图数组创建之后提交的,属于情况 2,不可见;
  • (1,1) 是在视图数组创建之前提交的,可见。

更新逻辑
#

事务 B 的 update 语句,如果按照一致性读,好像结果不对哦?

事务 B 的视图数组是先生成的,之后事务 C 才提交,不是应该看不见 (1,2) 吗,怎么能算出 (1,3) 来?

image-20260427193856486

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

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

所以,这里就用到了这样一条规则:更新数据都是先读后写的,而这个读,只能读当前的值,称为“当前读”(current read)。

因此,在更新的时候,当前读拿到的数据是 (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,也都可以读到版本号是 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;

变式
#

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

image-20260427194106372

事务 A、B、C’ 的执行流程

事务 C’ 的不同是,更新后并没有马上提交,在它提交前,事务 B 的更新语句先发起了。前面说过了,虽然事务 C’ 还没提交,但是 (1,2) 这个版本也已经生成了,并且是当前的最新版本。那么,事务 B 的更新语句会怎么处理呢?

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

image-20260427194756074

事务 B 更新逻辑图(配合事务 C’)

这里,A 得到的数据是 1,而 B 的数据是 3。

读提交场景
#

在读提交隔离级别下,事务 A 和事务 B 的查询语句查到的 k,分别应该是多少呢?

这里需要说明一下,“start transaction with consistent snapshot; ”的意思是从这个语句开始,创建一个持续整个事务的一致性快照。所以,在读提交隔离级别下,这个用法就没意义了,等效于普通的 start transaction。

下面是读提交时的状态图,可以看到这两个查询语句的创建视图数组的时机发生了变化,就是图中的 read view 框。(注意:这里,我们用的还是事务 C 的逻辑直接提交,而不是事务 C’)

image-20260427195702418

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

  • (1,3) 还没提交,属于情况 1,不可见;
  • (1,2) 提交了,属于情况 3,可见。

所以,这时候事务 A 查询语句返回的是 k=2。

显然地,事务 B 查询结果 k=3。

串行化隔离级别是通过什么实现的?
#

串行化隔离级别是安全性最高的隔离级别,但是也是性能最差的隔离级别,读、写操作都采用加行级别锁的方式来解决脏读、不可重复读、幻读的问题,现实中基本不会用到串行化隔离级别,因为性能太差了,没有MVCC机制,读写操作没办法并发。

回答
#

串行化隔离级别所有SQL都会加行级锁,包括普通的 select 查询,都会加 S 型的 next-key 锁。其他事务就没办法对这些已经加锁的记录进行增删改操作了,从而避免了脏读、不可重复读和幻读现象,性能是隔离级别中最差的,没有MVCC机制,读写操作没办法并发。

脏读和幻读有什么区别?
#

脏读是一个事务读到了另一个未提交事务修改过的数据。

image-20260427165126107

在一个事务内多次查询某个符合查询条件的「记录数量」,如果出现前后两次查询到的记录数量不一样的情况,就意味着发生了「幻读」现象。

image-20260427165145426

回答
#

  • 脏读是一个事务读到了另一个未提交事务修改过的数据,如果另外一个事务回滚了,刚才读到的数据就与数据库里的数据不一致了。
  • 幻读是前后两次的查询的结果集的数量是不同,比如,如果 select 执行了两次,但第二次返回了第一次没有返回的行数据,则该行是“幻像”行。

MySQL 的默认隔离级别是什么?怎么实现的?
#

回答
#

MySQL默认的隔离级别是可重复读

select 查询是通过 MVCC 实现的,在 MVCC 实现中,每条记录都会保存多个版本,每个版本都有一个版本号,事务在读取数据时,会根据事务开始时的版本号来读取数据,从而保证了事务的隔离性。可重复读隔离级别是在开启事务后,执行一条 select 语句的时候, 会生成一个 Read View,后续事务查询数据的时候都在复用 Read View,所以保证了事务期间多次读到的数据都是一致的。

介绍一下 MVCC
#

回答
#

MVCC 是多版本并发控制,是通过记录历史版本数据,解决读写并发冲突问题,避免了读数据时加锁,提高了事务的并发性能。

MySQL将历史数据存储在 undo log 中,结构逻辑上类似一个链表,MySQL数据行上有两个隐藏列,一个是事务ID,一个就是指向 undo log 的指针。

事务开启后,执行第一条 select 语句的时候,会创建 ReadView ,ReadView 记录了当前未提交的事务,通过与历史数据的事务 ID 比较,就可以根据可见性规则进行判断,判断这条记录是否可见,如果可见就直接将这个数据返回给客客户端,如果不可见就继续往undo log 版本链查找第一个可见的数据。

MVCC的如何判断行记录对某一个事务是否可见
#

Read View 有四个重要的字段:

image-20260427165431528
  • m_ids:指的是在创建 Read View 时,当前数据库中「活跃事务」的事务 id 列表,注意是一个列表,“活跃事务”指的就是,启动了但还没提交的事务。
  • min_trx_id:指的是在创建 Read View 时,当前数据库中「活跃事务」中事务 id 最小的事务,也就是 m_ids 的最小值。
  • max_trx_id:这个并不是 m_ids 的最大值,而是创建 Read View 时当前数据库中应该给下一个事务的 id 值,也就是全局事务中最大的事务 id 值 + 1;
  • creator_trx_id:指的是创建该 Read View 的事务的事务 id。

聚簇索引记录中都包含下面两个隐藏列:

image-20260427165539737
  • trx_id,当一个事务对某条聚簇索引记录进行改动时,就会把该事务的事务 id 记录在 trx_id 隐藏列里;
  • roll_pointer,每次对某条聚簇索引记录进行改动时,都会把旧版本的记录写入到 undo 日志中,然后这个隐藏列是个指针,指向每一个旧版本记录,于是就可以通过它找到修改前的记录。

在创建 Read View 后,我们可以将记录中的 trx_id 划分这三种情况:

image-20260427165622815

一个事务去访问记录的时候,除了自己的更新记录总是可见之外,还有这几种情况:

  • 如果记录的 trx_id 值小于 Read View 中的 min_trx_id 值,表示这个版本的记录是在创建 Read View 前已经提交的事务生成的,所以该版本的记录对当前事务可见。
  • 如果记录的 trx_id 值大于等于 Read View 中的 max_trx_id 值,表示这个版本的记录是在创建 Read View 后才启动的事务生成的,所以该版本的记录对当前事务不可见。
  • 如果记录的 trx_id 值在 Read View 的 min_trx_id 和 max_trx_id 之间,需要判断 trx_id 是否在 m_ids 列表中:
    • 如果记录的 trx_id 在 m_ids 列表中,表示生成该版本记录的活跃事务依然活跃着(还没提交事务),所以该版本的记录对当前事务不可见。
    • 如果记录的 trx_id 不在 m_ids列表中,表示生成该版本记录的活跃事务已经被提交,所以该版本的记录对当前事务可见。

回答
#

我们**每一条记录都有两个隐藏列,一个是事务 id,一个是指向历史数据 undo log 的指针,然后 Read View 有四个字段,分别是创建 Read View 的事务 id、活跃事务 id 列表、活跃事务 id 列表中最小的 id、下一个事务的 id。**主要有这几种判断规则:

  • 如果记录的事务 id 小于活跃事务 id 列表中最小的 id,就说明该记录是在创建 Read View 前就生成好了,所以该记录是当前事务是可见的。
  • 如果记录的事务 id 大于下一个事务的 id,就说明该记录是在创建 Read View 后才生成的,所以该记录是当前事务是不可见的。
  • 如果记录隐藏列的事务 id 在最小的 id 和下一个事务的 id 之间,这时候就需要看记录的事务 id 是否在活跃事务 id 列表中:
    • 如果记录的事务 id 在活跃事务 id 列表中,说明修改该记录的事务还没提交,所以该记录是不可见的。
    • 如果记录的事务 id 不在活跃事务 id 列表中,说明修改该记录的事务已经提交了,那么该记录就是可见。

读已提交和可重复读隔离级别实现 MVCC 的区别?
#

生成 readview 的时机不同

回答
#

读已提交和可重复读隔离级别都是由 MVCC 实现的,它们的区别在于创建 Read View 的时机不同

  • 读已提交隔离级别在事务开启后,每次执行 select 都会生成一个新的 Read View,所以每次 select 都能看到其他事务最近提交的数据。
  • 可重复读隔离级别在事务开启后,执行第一条 select 时生成一个 Read View,然后整个事务期间都在复用用这个 Read View,所以一个事务执行过程中看到的数据,一直跟这个事务启动时看到的数据是一致的。

为什么互联网公司用读已提交隔离级别?
#

读已提交并发性能更高,因为读已提交没有间隙锁,只有记录锁,而可重复读是会有记录锁和间隙锁,所以读已提交隔离级别发生死锁的概率比较小。

回答
#

读已提交的并发性能更好,因为读已提交没有间隙锁,只有记录锁,发生死锁的概率比较低。然后互联网业务对于幻读和不可重复读的问题都是能接受的,所以为了降低死锁的概率,提高事务的并发性能,都会选择使用读已提交隔离级别。

可重复读隔离级别是如何解决不可重复读的?
#

分两种查询来回答

  • 快照读,靠 MVCC 解决不可重复读
  • 当前读,靠行级锁中的记录锁解决不可重复读

回答
#

MySQL 提供了两种查询方式,一种是快照读,就是普通 select 语句,另外一种是当前读,比如 select for update 语句。不同的查询方式,解决不可重复读问题的方式是不一样的。

针对快照读的话,是通过 MVCC 机制来解决的,在可重复读隔离级别下, 第一次select查询的时候,会生成 readview,在第二次执行select查询的时候,会复用这个readview,这样前后两次查询的记录都是一样的,不会读到其他事务更新的操作,这样就不会发生不可重复读的问题了。

针对当前读的话,是靠行级锁中的记录锁来实现的,在可重复读隔离级别下,第一次 select for update 语句查询的时候,会对记录加next-key 锁,这个锁包含记录锁,这时候如果其他事务更新了加了锁的记录,都会被阻塞住,这样就不会发生不可重复读的问题了。

可重复读隔离级别是怎么解决幻读的?
#

分两种查询来回答

  • 快照读,靠MVCC解决幻读
  • 当前读,靠行级锁中的间隙锁解决幻读

回答
#

MySQL 提供了两种查询方式,一种是快照读,就是普通 select 语句,另外一种是当前读,比如 select for update 语句。不同的查询方式,解决幻读问题的方式是不一样的。

针对快照读的话,是通过 MVCC 机制来解决的,在可重复读隔离级别下, 第一次select查询的时候,会生成 readview,在第二次执行select查询的时候,会复用这个readview,这样前后两次查询的结果集都是一样的,不会读到其他事务新插入的记录,这样就不会发生幻读的问题了。

针对当前读的话,是靠行级锁中的间隙锁来实现的,在可重复读隔离级别下,第一次 select for update 语句查询的时候,会对记录加next-key 锁,这个锁包含间隙锁,这时候如果其他事务往这个间隙插入新记录的话,都会被阻塞住,这样就不会发生幻读的问题了。

可重复读隔离级别为什么不能完全避免幻读?什么情况下出现幻读?
#

可重复读隔离级别场景下。

发生幻读的第一个场景:

image-20260427170014319
  • 数据库表不存在 id=5 的记录,事务 a 执行第一次查询的时候,读不到该记录,接着事务 b 插入了 id=5 的新记录。
  • 事务 a 的更新语句更新了事务 b 刚插入的 id =5 的这条记录,由于更新操作是当前读,所以事务 a 的更新操作能读到 id=5 的记录并进行更新,更新的时候,会把id=5 这条记录隐藏列的事务 id 变为事务 a id,代表是事务 a 修改的。
  • 然后事务 a 第二次查询的时候,发现 id=5 的事务 id,跟本事务的 id 是一样的,就会认为是可见的,那么就能读到id=5 的数据了, 此时也就发生了幻读现象。

发生幻读的第二个场景。

  • T1 时刻:事务 A 先执行「快照读语句」:select * from t_test where id > 100 得到了 3 条记录。
  • T2 时刻:事务 B 往插入一个 id= 200 的记录并提交;
  • T3 时刻:事务 A 再执行「当前读语句」 select * from t_test where id > 100 for update 就会得到 4 条记录,此时也发生了幻读现象。

回答
#

在可重复读隔离级别场景下,当先快照读再当前读的场景下可能会出现幻读的问题。

比如说这个场景,事务 A 通过快照读的方式查询 id = 5 的记录,此时数据库没有这条记录,然后事务 B 向这张表中新插入了一条 id = 5 的记录并提交了事务。接着,事务 A 对 id = 5 这条记录进行了更新操作,在这个时刻,这条新记录隐藏列中的事务id就变成了事务 A 的事务 id,这时候事务 A 再使用 select 语句去查询这条记录时就可以看到这条记录了,这里事务 A 前后两次查询的结果集合数不一样了,于是就发生了幻读。

我还知道另外一个场景,事务 A 通过快照读的方式查询 id 大于 100 的记录,假设这时候有 1 条记录,然后事务 B 插入了 id = 200 的记录并提交了事务,接着事务 A 通过当前读的方式查询 id 大于 100 的记录,这时候就会得到 2 条记录,事务 A 前后两次查询的结果集合数不一样了,就发生了幻读。

我觉得上面这两种发生幻读的场景,也是可以避免的,就是尽量在开启事务之后,马上执行 select … for update 语句,因为它会对记录加临键锁(next-key 锁),这样就可以避免其他事务插入一条新记录,就避免了幻读的问题。

可重复读隔离级别,MVCC完全解决了不可重复读问题吗?
#

不可重复读,代表前后两次查询的记录的值不一样了。

比如表里现在有 id=1,value=1 的记录。

  • 事务 a,先执行 select,查询到 id=1 的 value 是 1
  • 事务 b,更新 id=1的 value为 2,然后提交事务。
  • 事务 a,执行 select for update,当前读,然后就读到 id = 1,value=2 的记录了,意味着发生了不可重复读。

回答
#

如果前后两次查询都是快照读,就是普通的 select 的话,那就不会产生不可重复读的问题的。但是如果第一次查询是快照读,第二次查询是当前读,那么就可能会发生不可重复读的问题。

一个事务里有特别多SQL的弊端?
#

一个事务如果有特别多SQL,那么这个事务就会被称为长事务(大事务)。

长事务有什么影响?

  • 锁定数据过多,容易造成大量的死锁和锁超时:锁是事务提交的时候才释放的,那么长事务会导致锁持久的时间过长,很容易导致大量的死锁和锁超时的问题
  • **回滚记录占用大量存储空间,事务回滚时间长:**每条记录在更新的时候都会同时记录一条回滚操作,会产生 undo 日志,而 undo 日志是事务提交并且没有事务依赖的时候才会被清理,那么长事务就会导致 undo 日志堆积很多,占用存储空间,也会导致回滚的时间过长。
  • **执行时间长,容易造成主从延迟:**因为主库上必须等事务执行完成才会写入binlog,再传给备库。所以,如果一个主库上的语句执行10分钟,那这个事务很可能就会导致从库延迟10分钟
  • 并发情况下数据库连接池容易被撑爆:在长事务中,连接可能会被持续打开,这会占用数据库连接池的资源,可能导致连接池被占满

怎么查找数据库中的长事务?

可以在 information_schema 库的 innodb_trx 这个表中查询长事务,比如下面这个语句,用于查找持续时间超过 60s 的事务。

select * from information_schema.innodb_trx 
where TIME_TO_SEC(timediff(now(),trx_started))>60

回答
#

  • 锁是事务提交的时候才释放的,那么长事务会导致锁持久的时间过长,容易导致大量的死锁和锁超时的问题
  • 执行事务中每条增删改SQL会产生 undo 日志,那么长事务就会导致 undo 日志堆积很多,占用存储空间,也会导致回滚的时间过长
  • 长事务执行时间长,容易造成主从延迟,如果一个主库上的语句执行10分钟,那这个事务很可能就会导致从库延迟10分钟
  • 在长事务中,连接可能会被持续打开,这会占用数据库连接池的资源,可能导致连接池被占满
凉柠
作者
凉柠
专注于 Kubernetes、分布式系统与 AI Agent 架构探索。
mysql - 这篇文章属于一个选集。
§ 5: 本文