likes
comments
collection
share

MySQL 事务隔离级别和 ACID 特性

作者站长头像
站长
· 阅读数 56

前言

在上家单位的时候,有一次领导 Review 代码的时候,说一个同事写的代码可能会有问题,说此时如果 A 事务修改了这条数据并且在当前 B 事务提交前提交,那么下面 B 事务的代码就读到错误数据了。例如这样

MySQL 事务隔离级别和 ACID 特性

让他加 X锁 控制下。由于我之前用的隔离级别一直都是 REPEATABLE-READ,于是我仔细思考这肯定不会有问题啊。然后刚想反驳领导的我还是忍住了,先动手查看下数据库当前的事务隔离级别

show transaction_isolation; -- PostgreSQL 的查看方式
select @@tx_isolation;      -- MySQL 的查看方式,8.0 版本之后 是 select @@transaction_isolation

上家单位用的数据库是 PostgreSQL ,查询之后发现线上是 Read Commited,难怪会质疑我!因为我之前用的 MySQL 都是默认用 Read Repeatable 级别的,所以这里想当然了。由此复习一下 MySQL 的事务相关知识。

本篇文章的学习基于 MySQL 8.0.31 InnoDB 存储引擎

事务简介 & ACID

很多人谈到事务都会和 ACID 四个特性绑定在一起,这没有错,但值得注意的是本质上它们并不是绑定起来的。事务就是一组命令单元,在数据库系统中,一个事务是指:由一系列数据库操作组成的一个完整的逻辑过程。

只是理想中一个正确的事务应该满足这四个特性,达到满足我们的业务需求。以下是维基百科给出的 ACID 的含义

  • 原子性(Atomicity):一个事务中的所有操作,或者全部完成,或者全部不完成,不会结束在中间某个环节。事务在执行过程中发生错误,会被回滚到事务开始前的状态,就像这个事务从来没有执行过一样。即,事务不可分割、不可约简。

  • 一致性(Consistency):在事务开始之前和事务结束以后,数据库的完整性没有被破坏。这表示写入的数据必须完全符合所有的预设约束、触发器、级联回滚等。

  • 隔离性(Isolation):数据库允许多个并发事务同时对其数据进行读写和修改的能力,隔离性可以防止多个事务并发执行时由于交叉执行而导致数据的不一致。事务隔离分为不同级别,包括未提交读(Read Uncommitted)、提交读(Read Committed)、可重复读(Repeatable Read)和串行化(Serializable)。

  • 持久性(Durability):事务处理结束后,对数据的修改就是永久的,即便系统故障也不会丢失。

注意,一致性是目标,其他三个都是手段,也就是说原子性、隔离性、持久性都是我们保证一致性的手段。

其实呢这些东西要背下来就没什么意思了,但是理解呢,很多地方说的都不是很准确,这也导致很多人会往错误方向去理解(我上大学的时候就理解的很难受)。

比如说一致性,这里我是把维基百科的解释粘过来了,对于一致性通常是我们针对业务场景,人为的去设置的一个数据约束前提。以经典的银行转账为例,我们说 AB 转了 100,最后应该是 A100B100,这是我们提前约定好了,AB 余额的总量是不变的,这是前提。

再说持久性,这里所谓的对数据的修改是永久的,是指当前这次已经提交的事务对数据的修改是永久的,我们不能再通过这个已经提交的事务去修改数据了,但是你通过其他事务再进行一次 update 一样能更新掉数据,但这是一次新的事务了。

ACID 四个特性的实现

持久性

在说这个之前我们得知道 MySQL 它是如何更新数据的,由于数据在磁盘上,我们想对它更新,就得先把数据加载到内存中,在内存中修改,完了之后再写到磁盘。

大致的过程是把数据所在的页加载到内存中的 Buffer Pool,所有的更新都在这里进行,这里的修改会以一定的频率被刷到磁盘。这个动作官方称为 checkpoint ,会有不同的情况去触发。由于它存在一定的频率触发,也就是说可能会出现 Buffer Pool 里面我们修改完毕,但是还没有写入到磁盘的时候 MySQL 服务崩溃或者机器宕机。一旦出现这种情况,事务所做的更改并没有真正写入到磁盘。

为了解决这个问题,我们最简单粗暴的方式就是可以控制事务提交之前,将事务所有的修改必须全部更新到磁盘,然后再提交事务。但是这样做的话会有一些问题:

  • 刷新磁盘工作量远大于修改量

    我们知道 InnoDB 是以页为单位进行磁盘 IO 的,一个页里面数据太多了,也就是说即使我们修改了某一个行的数据,也必须要连带将一整个页(16KB)的数据都写回磁盘,太浪费资源了。

  • 存在随机 IO

    一个事务里面的语句可能会涉及到很多数据页,这些数据页在物理磁盘上可能不是连续的空间,这就意味着事务提交时要进行很多随机 IO随机 IO 是比顺序 IO 要慢的,尤其是以前的机械硬盘。

这两个问题会导致事务的性能低下,所以对此 MySQL 有更好的方案。

Redo log

为了解决这个问题, MySQL 给出了一个方案,我们的初衷是要让我们已经提交的事务能够对数据进行永久性的更改,那么我们可以实时的记录下来我们到底改了哪些内容,即使 MySQL 服务在最终刷磁盘的过程中挂了,我们也能从我们的记录中去进行恢复。(是不是像 RedisAOF?)

所以 MySQL 使用了一个叫做 重做日志文件(Redo log) 的方案,我们一个事务中的所有修改内容都会被记录到一个日志文件中,这样即使出现上面所说的宕机情况,我们也能通过重放 Redo log 来恢复这个事务的修改。

这里可能我们会疑惑 Redo log 的写入不也是磁盘交互吗?的确它也是磁盘交互,但是对于这个交互我们只需要记录我们改动的数据,包括 表空间id页偏移量行id 等,相比一整个页数据写入磁盘来说,这样刷入磁盘的数据要少的多的多。事务的效率也就提高了。

InnoDB 采用了 Write AheadLog(WAL)策略Force Logat Commit 机制实现事务级别下数据的持久性(了解即可)。

  • WAL 要求数据的变更写入到磁盘前,首先必须将内存中的日志写入到磁盘;
  • Force log at commit 要求当一个事务提交时,所有产生的日志都必须刷新到磁盘上,如果日志刷新成功后,缓冲池中的数据刷新到磁盘前数据库发生了宕机,那么重启时,数据库可以从日志中 恢复数据。

总之,我们只要知道 Redo log 写入磁盘成功之后才能算事务真正提交了即可。

原子性、一致性

可能出现的情况是事务执行过程中遇到报错等情况回滚,我们要保证事务回滚后数据能恢复到原先的样子。为了实现原子性、一致性,MySQL 在更新(增删改)数据前会将一些数据记录到回滚日志(Undo log) 文件中

  • 对于插入的数据, Undo Log 会记录这条数据的主键,如果回滚了,后面会进行 DELETE 数据和索引。
  • 对于删除数据,Undo Log 会记录这条数据的所有内容,回滚的时候会原样插入
  • 对于更新数据,Undo Log 会记录这条数据所修改得值的原先旧值,在回滚的时候更新为原先的旧值。

Undo Log 的主要功能除了回滚之外,还参与 MVCC 的实现,这个下一篇介绍锁的文章会详细介绍。

Redo log、Undo log 图解

MySQL 事务隔离级别和 ACID 特性

隔离性

MySQL 数据库中,隔离性是通过各种锁以及 MVCC 多版本并发控制 的方式来实现的,下篇文章会详细介绍。

事务隔离级别

为了尽量满足上述四个特性所以数据库厂商才定义了事务的几个隔离级别

读未提交(Read Uncommited)

简称 RU,这种隔离级别的含义是一个事务可以读取另一个事务还未提交的更改。

这种隔离级别的问题很大,所以使用的也少。当 事务 A 做出任何修改, 事务 B 都能查询到,那么假如 A 最后回滚的话,B 读取的就是错误的数据,产生 脏读。它违背了一致性、隔离性。

注意 老版本字段名是 tx_isolationMySQL 8.0 之后的版本这个变量名字是 transaction_isolation

时刻会话A会话 B
1SET @@session.transaction_isolation='READ-UNCOMMITTED';SET @@session.transaction_isolation='READ-UNCOMMITTED';
2BEGINBEGIN
3select status from t_loan where id = 50357;#从数据库读取状态
4update t_loan set status = 'PAID_OFF' where id = 50357;
5select status from t_loan where id = 50357; #读到未提交事务 A 修改的 PAID_OFF 状态

读已提交 (Read Commited)

简称 RC,这种隔离级别的含义是一个事务只能读取到另一个已经提交的事务所做的更改。

它避免了脏读的发生。这看起来似乎是一个比较合理的隔离级别,所以也是除 MySQL 以外几乎所有的关系型数据库的默认隔离级别。但是这种隔离级别会产生不可重复读的问题,例如下面的案例,事务 B 在一个事务中对于同一个数据读取到了两种不同的状态,它违背了 隔离性。

时刻会话A会话 B
1SET @@session.transaction_isolation='READ-COMMITED';SET @@session.transaction_isolation='READ-COMMITED';
2BEGINBEGIN
3select status from t_loan where id = 50357;
4update t_loan set status = 'PAID_OFF' where id = 50357;
5COMMIT;
6select status from t_loan where id = 50357; #和第三时刻读到不同的状态

事务 A第 6 时刻第 3 时刻 对于同一条数据的查询,查询到了两种状态。 有问题吗?仔细想想好像也合理对吧,因为你都提交事务了,意味着持久化保存了,那其他事务读取到是应该的。但是这对于 事务 A 来说,在一个事务中读取同一条数据读到了两种不同的状态,可能会造成业务逻辑上的问题。

可重复读 (Repeatable Read)

简称 RR,这种隔离级别的含义是在一个事务中读取不到其他无论是未提交事务还是已提交事务所做的行数据更新操作。

但是这种情况下标准的 SQL 产品 会造成幻读。什么是幻读?事务 A 按某个条件读取了一些数据,这个过程中 事务 B 插入了一些符合条件的数据并且提交了事务,然后 事务 A 再次查询发现多查了几条数据,好像出现了幻觉。

时刻会话A会话 B
1SET @@session.transaction_isolation='REPEATABLE-READ';SET @@session.transaction_isolation='REPEATABLE-READ';
2BEGINBEGIN
3select status from t_loan where id > 50357 and id < 50457;
4insert into t_loan values((50358,...,...),(50359,...,...));
5COMMIT;
6select status from t_loan where id > 50357 and id < 50457; #和第三时刻查询的结果相比多了几条数据

注意插入、删除操作和更新不一样,很多数据库厂商这种隔离级别能够在当前事务里面屏蔽掉其他事务对行数据的更新,但是无法屏蔽其他事务的插入结果。不过 MySQL 使用 MVCC 和间隙锁解决了幻读的问题,这个下篇文章会介绍。

这里我们可能会发现幻读其实属于不可重复读的一中特殊情况,不可重复读是针对数据的更新操作,幻读主要是针对数据的新增/删除操作给出的定义。

在这个隔离级别下对于更新操作而言,虽然读取不到已提交事务对行数据的更新,但是在业务代码层面也可能会出现问题。看下面的案例

时刻会话A会话 B
1SET @@session.transaction_isolation='REPEATABLE-READ';SET @@session.transaction_isolation='REPEATABLE-READ';
2BEGINBEGIN
3select status from t_loan where id = 50357;
4update t_loan set status = 'PAID_OFF' where id = 50357;
5COMMIT;
6select status from t_loan where id = 50357; #和第三时刻读相同的状态

实际业务中,我们可能会根据 status 字段的不同值去处理不同的业务逻辑,由于实际上 status 已经被更改了,由于隔离级别的原因我们读取到的还是当前 事务 A 开始时读取的 status 旧的版本值,会导致本应该处理的业务逻辑未进行处理。这种通常还是需要我们对目标数据进行显示加锁,锁定读。

串行化 (Serializable)

最高的隔离级别,所有的事务依次逐个执行,这样事务之间就完全不可能产生干扰。完全满足 ACID ,也就是说,该级别可以防止脏读、不可重复读以及幻读。但是事务一个个串行无疑在应对并发业务的时候有很大压力。正常项目中很少使用。

总结

综上可知,常用的隔离级别是 READ-COMMITEDREPEATABLE-READ,虽然 REPEATABLE-READ 的隔离级别比较高,但是很多业务场景中我们还是不得不在业务代码层面进行显示的对数据库加 X锁

结语

本篇文章简单复习 MySQL 的事务隔离级别和 ACID 特性,下篇文章会复习 MySQL 的锁知识和 MVCC 多版本并发控制。

如果这篇文章对你有帮助,记得点赞加关注!你的支持就是我继续创作的动力!

转载自:https://juejin.cn/post/7373295552270467099
评论
请登录