概览

本文主要在于宏观的理解 MySQL 中事务、隔离性、锁、MVCC 的关系,这三部分知识在我看来是联系在一起的,需要结合在一起思考。

省流:

事务的概念

转账过程具体到程序里会有一系列的操作,比如查询余额、做加减法、更新余额等,这些操作必须保证是一体的,不然等程序查完之后,还没做减法之前,你这 100 块钱,完全可以借着这个时间差再查一次,然后再给另外一个朋友转账,如果银行这么整,不就乱了么?这时就要用到“事务”这个概念了。

简单来说,事务就是要保证一组数据库操作,要么全部成功,要么全部失败。在 MySQL 中,事务支持是在引擎层实现的。你现在知道,MySQL 是一个支持多引擎的系统,但并不是所有的引擎都支持事务。比如 MySQL 原生的 MyISAM 引擎就不支持事务,这也是 MyISAM 被 InnoDB 取代的重要原因之一。

MySQL 的事务具有如下特性,我们简称为 ACID

  • 原子性:一个事务中的所有操作,要么全部完成,要么全部失败。
  • 一致性:数据库的完整性不会因为事务的执行而受到破坏。
  • 隔离性:防止多个事务并发执行时由于交叉执行而导致数据的不一致。
  • 持久性:事务处理结束后对数据的修改是永久的,即便系统故障也不会丢失。

其中原子性是基础,隔离性是手段,持久性是保障,一致性是目标。

下面我将主要说明事务的“隔离性”。

并发事务中可能出现的问题

当数据库上有多个事务同时执行的时候(并发事务),就可能出现脏读(dirty read)、不可重复读(non-repeatable read)和幻读(phantom read)的问题,影响数据的一致性。

如上图所示,脏读现象就是一个事务读到了另一个事务未提交之前修改的数据。

不可重复读现象是在一个事务里面多次读取同一个数据,但是前后数据结果不一致

幻读现象是在一个事务里,多次查询某一条件的数据,出现了前后结果不一样

不可重复读和幻读的主要区别是数据改变的类型。不可重复读关注的是同一条记录的内容被修改了,幻读关注的是查询的结果集行数变多或变少了(有“幻影”行出现)。

隔离级别

为了解决上述问题,就有了“隔离级别”的概念。

事务隔离级别有四种,分别是:

  • 读未提交:一个事务还没提交时,它做的变更就能被别的事务看到。
  • 读已提交:一个事务提交之后,它做的变更才会被其他事务看到。
  • 可重复读:一个事务执行过程中看到的数据,总是跟这个事务在启动时看到的数据是一致的。当然在可重复读隔离级别下,未提交变更对其他事务也是不可见的。
  • 串行化:对于同一行记录,“写”会加“写锁”,“读”会加“读锁”。当出现读写锁冲突的时候,后访问的事务必须等前一个事务执行完成,才能继续执行。

通过其定义就可以很明显的知道:

  • 读未提交是最低的隔离级别,不可以解决上述三个问题
  • 读已提交可以解决脏读问题,不过无法解决不可重复读和幻读
  • 可重复读和串行化对上述三个问题都可以解决

隔离级别由高到低:

要知道,隔离得越严实,效率就会越低。因此很多时候,我们都要在二者之间寻找一个平衡点。

分析下图,请问在不同的隔离级别下,事务 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。

通过如下命令,可以查看 MySQL(8.0+) 默认的隔离级别

SELECT @@global.transaction_isolation, @@session.transaction_isolation;

  • REPEATABLE-READ 可重复读

理解了事务的隔离级别后,我们再来看看事务隔离具体是怎么实现的。

MySQL 的锁

事务隔离的实现离不开 MySQL 中的

锁的粒度

根据锁的粒度,从大到小可以分为:

  • 全局锁:锁住的对象是数据库实例;主要用在全库备份的时候,由于在加锁期间,所有对数据库的写入操作都会被阻塞,所以生产环境下基本使用 mysqldump 命令进行备份。
  • 表锁:锁住的对象是数据库表;开销小,并发低。
  • 行锁(InnoDB 支持):锁住的对象是表的单行数据,开销大,并发高。

锁的实现方式

锁的实现理念主要分为乐观/悲观:

  • 悲观锁:认为冲突一定会发生,所以是先上锁再操作;适合写多读少的场景。
  • 乐观锁:认为冲突不会发生,所以是直接操作再判断;主要实现方式为版本号法和 CAS 机制;适合读多写少的场景。

锁的类型

锁自带的两种基本类型:共享锁和排他锁。

共享锁(S 锁 / 读锁)的作用是允许多个事务同时读取同一资源。它的核心理念是“我读的时候,你也可以读”。在并发场景下,共享锁实现了读读共享,这意味着多个事务可以同时持有资源的读锁进行读取操作,但它与写操作是互斥的,即读写互斥。在 SQL 中,通常通过 SELECT ... LOCK IN SHARE MODE 来实现显式的共享锁。

排他锁(X 锁 / 写锁)的作用是独占资源,确保在数据修改过程中的唯一性,其核心理念是“我写的时候,谁都别动!”。排他锁是一种强力的锁定机制,它与任何其他锁都是互斥的。这意味着它不仅阻止其他事务对资源的写入操作(写写互斥),也阻止其他事务的读取操作(读写互斥)。在 SQL 中,通常通过 SELECT ... FOR UPDATE 或数据修改操作(如 UPDATEDELETE)来隐式或显式地实现排他锁。

MySQL 锁有哪些实现?

在知道 MySQL 中锁的粒度、实现方式和类型后,我们再来看看 MySQL 的锁有哪些实现。

意向锁 (Intention Lock) 是 InnoDB 存储引擎特有的表级锁;意向锁本身是加在表上的,但它的目的是表达事务即将或已经在表中的某些行上加了行锁,当其他事务想加表锁时,无需遍历所有行检查有无行锁,只需检查表上是否存在意向锁,极大提升效率;当事务获取行锁时,InnoDB 自动为其加上意向锁。

记录锁(Record Lock)是 InnoDB 存储引擎实现的行级锁的一种,它的特点是精准打击,只锁一行,目标是锁定索引记录本身。记录锁会在事务通过唯一索引(主键、唯一键)进行等值查询并需要加锁时(例如使用 SELECT * FROM t WHERE id = 10 FOR UPDATE;)被触发,它的效果是只会锁定 id=10 这一行不影响表中其他行的读写操作,从而实现了数据库的最大并发度。

记录锁只锁定一行,可很多时候是我们会对一个范围的数据进行查询与修改,所以 MySQL 实现了间隙锁临键锁

  • 间隙锁 (Gap Lock):锁定索引值 10 和 20 之间的区间,不锁记录本身,其作用是防止间隙中插入新数据
  • 临键锁 (Next-Key Lock):记录锁 + 间隙锁的组合,锁定索引记录 30 及其之前的间隙;主要目的是在REPEATABLE READ 隔离级别下,防止幻读的发生,准确来说是保证别人写的时候造不出幻影(MVCC 是读的时候不会产生幻影)。

在对 MySQL 的锁有了一定的了解后,我们再回到一开始的问题 “事务隔离的实现”。

事务隔离的实现

事务隔离级别的实现一定都是要锁的吗?

答:其实并不是,MySQL 的一个核心技术点 MVCC(多版本并发控制) 就是事务隔离级别(一些)无锁的实现方式

该节内容在哔哩哔哩 面试官:说说什么是MVCC,事务隔离级别实现原理是什么? 中讲解的非常清楚,强烈推荐观看。

我在本节的内容主要是对其视频进行总结。

MVCC

有三个关键点:

  1. 第一个关键点:“隐藏列”,数据表中看不到的 3 列数据,当前事务 ID、旧数据存储指针、没有主键 ID 就会维护一个 ROW_ID。
  2. 第二个关键点:“undolog”,历史数据的存储位置,单/多事务历史数据都存储在 undolog 中,事务从 undolog 找到对应历史数据。
  3. 第三个关键点:“ReadView”,提供如何找历史数据的解决办法。

利用 undolog 实现多个版本的数据存储,然后结合 ReadView 进行 CAS 比较查找。

最后:

  • 读未提交没有实现 MVCC 和锁
  • 读已提交的实现是 MVCC,每次查询创建 ReadView 读取数据
  • 可重复读的实现是 MVCC + 临键锁,同样的查询只会获取第一次创建 ReadView 时读取的数据
  • 串行化的实现是表锁(视频)?查了下,目前是行的读写锁

参考