MySQL 事务

  • 你是怎么理解事务的?
  • 你了解事务的四大特性吗?
  • 事务有哪几种隔离级别?分别解决了什么问题?
  • 你了解快照吗?

引例

在 MySQL 中,谈到事务,大家肯定都不陌生了。

想象一下,你今天要给朋友转账一笔钱,操作很简单,对吧?

首先,你登录银行APP,然后,输入金额和朋友的账户,确认无误后点击“转账”,结果看到屏幕上显示“转账成功”。这时候,你的账户余额减少了,朋友的账户余额增加了,一切看起来都没问题。

但是,如果在你点击“转账”后,银行系统突然崩溃了,或者网络出现问题,导致转账操作没有完成,你能接受余额已经减少,却发现钱没到账吗?或者,系统崩溃后再恢复,发现账户余额变化了,但这笔转账却没有被正确记录?显然,这样的事情是无法接受的,因为它会导致账户数据的不一致。

这时候,银行系统就必须确保,无论发生什么情况,要么整个转账操作完全成功,要么完全不做任何改动,避免出现半成功、半失败的尴尬局面。为了达到这个目标,银行就需要一种机制来保证转账中的所有操作“要么都成功,要么都不成功”。

在数据库中,这个机制就是 事务 。事务就像是银行转账的操作流程,确保一组操作要么全部完成,要么全部不完成。事务 帮助我们在处理数据时,确保每个操作都能安全可靠地完成,避免出现数据不一致的问题。

接下来,我们就以 InnoDB 引擎为例,来具体了解一下关于事务的内容。

事务的四大特性

提到事务的时候,我们一般会想到事务的四大特性(ACID):

原子性 (Atomicity)

事务是一个不可分割的最小操作单元,要么全部执行,要么全部不执行。换句话说,事务中的所有操作要么全部成功,要么全部失败,没有中间状态。

就像银行转账一样,如果你要从账户A转账到账户B,银行会保证两个步骤(扣款和存款)要么同时成功,要么同时失败。如果中间有任何一个步骤失败,整个转账就会被撤销,不会让余额只扣掉而没有存入。

一致性 (Consistency)

事务必须使数据库从一个一致性状态转换到另一个一致性状态。换句话说,事务开始前和结束后,数据库的状态必须是合法的,遵循所有数据库规则、约束和触发器。

假设你有100元,你要转账200元给朋友。假设没有事务的一致性检查,如果系统允许你转账200元,但在扣除你账户中的100元后,系统崩溃了。那么,数据库的状态会变得不一致:你的账户余额是-100元,这就是不合法的,而你朋友的账户余额并没有增加200元,也就是转账并没有成功。这时,数据库的状态就不再一致了。

隔离性 (Isolation)

多个事务并发执行时,一个事务的执行不应受到其他事务的干扰。事务的隔离性确保一个事务的中间状态不会被其他事务看到。

假设你和朋友都去同一家餐厅吃饭,虽然你们同时点餐,但餐厅系统保证你的订单和你朋友的订单不会混在一起,虽然你们同时进行,但互不影响。换句话说,你的订单中的修改(比如加菜)在你朋友完成订单之前是不可见的。

持久性 (Durability)

一旦事务提交,它对数据库的改变就是永久性的,即使系统崩溃,也不会丢失。也就是说,事务成功提交之后,其结果会被永久保存在数据库中。

你完成了银行转账并看到“转账成功”提示,无论银行系统之后是否发生崩溃或者停机,你的账户和你朋友的账户的变动都会被永久记录。系统恢复后,银行会重新读取并保存这些变动。

事务中可能会出现的问题

回顾完四大特性之后,我们再来继续往下看。

我们知道,在同一个数据库中,如果有多个事务同时进行,那么可能就会出现问题。比如,脏读、不可重复读、幻读。

我们先来介绍一下这三种现象。

脏读

一个事务读取了另一个事务尚未提交的数据。如果另一个事务最终回滚,当前事务读取的数据将是无效的或“脏”的。

假设有两个事务,T1T2,它们操作同一条记录 balance (余额)。

  • T1:事务开始,读取 balance 的值为 100。
  • T2:事务开始,修改 balance 为 50,但还没有提交。
  • T1:读取到 balance 的值为 50。
  • T2:回滚, balance 恢复为 100。

在这个过程中,T1 读取到了 T2 尚未提交的、更改后的余额值 50,但是 T2 最终回滚了,导致 T1 读取到了无效的数据,这就是 脏读

不可重复读

在一个事务中,同一次查询,但多次读取到的数据不同,原因是其他事务修改了这些数据,并且该事务没有隔离这些修改。

假设有两个事务,T1T2,它们操作同一条记录 balance (余额)。

  • T1:事务开始,读取 balance 的值为 100。
  • T2:事务开始,修改 balance 为 50,并提交。
  • T1:再次读取 balance ,此时 balance 的值为 50,而之前它读到的是 100。

在这个过程中,T1 第一次读取的数据是 balance = 100 ,但当它再次读取时,看到的是 balance = 50 ,而这个变化是由 T2 提交后引起的。这就是 不可重复读,因为同一事务内多次读取数据时,结果不同。

幻读

当一个事务读取一组数据时,另一个事务在此期间插入、更新或删除了这些数据,导致当前事务读取的数据集发生变化。

假设有两个事务,T1T2,它们操作同一表 users(用户表),并以 age > 30 为条件查询。

  • T1:事务开始,查询 select * from users where age > 30 ,结果返回 2 条记录(假设为 ID 1 和 ID 2)。
  • T2:事务开始,插入一条新记录 intsert into users (id, age) values (3, 35),并提交。
  • T1:再次查询 select * from users where age > 30 ,结果返回 3 条记录(ID 1、ID 2 和 ID 3)。

在这个过程中,T1 执行了两次相同的查询,但第二次查询的结果包含了 T2 插入的记录,这就是 幻读,因为 T1 期望两次查询看到相同的记录,但数据集发生了变化,就好像出现了幻象一样。

事务中的隔离级别

数据库为了解决上述三个问题,就引入了“隔离级别”的概念。

需要知道的是,如果隔离级别越高,那么效率就会越低。所以,我们需要在隔离级别和效率之间,选取一个平衡点。

在 SQL 中,标准的隔离级别,分为以下四种,从低到高分别是:

读未提交 (Read Uncommitted)

事务可以读取其他事务尚未提交的数据,也就是可以读取“脏数据”。

可能导致脏读、不可重复读、幻读等问题。

比如,你正在写一篇文章,其他人可以看到你未保存的草稿内容,甚至在你最终提交前,其他人可能就修改了你的草稿。这个过程就是“脏读”——别人可以看到你未完成的工作。

读提交 (Read Committed)

事务只能读取其他事务已经提交的数据。也就是说,事务只能看到其他事务提交后的数据。

避免了脏读问题,但仍然可能出现不可重复读和幻读的问题。

比如,你在写一个文章的草稿,别人只能看到你最终提交的版本,而不能看到未提交的内容。

可重复读 (Repeatable Read)

事务在开始时读取的数据,会在整个事务期间保持一致,即事务内的多次读取操作会看到相同的数据。即使其他事务修改了数据,也不会影响当前事务的读取。

避免了脏读和不可重复读,但仍可能会出现幻读。

比如,你在写文章时,每次打开草稿文件时,都看到上次保存的内容,不管其他人是否编辑了这篇文章。不过,如果其他人新增了内容,虽然你看到的内容是同样的,但新增的内容对你来说就是“幻读”。

串行化 (Serializable)

事务通过强制加锁来确保所有事务按照顺序串行执行。每个事务在执行时会对相关数据加锁,直到事务完成,其他事务才能访问这些数据。

这样就完全避免了脏读、不可重复读和幻读问题。它是最严格的隔离级别,能完全避免并发问题,但性能开销大,效率低。

比如,你在编辑文章时,每次都必须等待前一个人完成编辑,提交草稿后,你才能开始编辑。这样就避免了其他人对同一内容的修改,但这种方式效率较低,因为你不能同时进行其他操作。

实例分析

如果只讲定义的话,会显得有些枯燥,所以我们来看一个具体的例子,再结合定义,就很好理解啦。

来看一个表格,表格中两个并发事务,从上往下,是按照顺序执行的:

#### 启动事务A #### 启动事务B
查询之后,得到一个值100
查询之后,得到一个值100
修改值,将 100 改为 200
再次查询,得到值 V1
提交事务 B
再次查询,得到值 V2
提交事务 A
再次查询,得到值 V3

我们可以看到的是事务A对同一个值,进行了多次查询,而事务B则对这个值进行了修改。

但是,在不同的隔离级别下,事务A查询到的值,是不一样的。下面我们就来仔细看看:

读未提交 隔离级别下,事务 A 看到的 V1200,因为此隔离级别下允许它读取到事务 B 尚未提交的脏数据,所以 V2V3 也都是 200 了。

读提交 隔离级别下,事务 A 看到的 V1100,因为事务 A 只能读取已经提交的数据,事务 B 还未提交其修改。V2V3200,因为事务 B 已经提交更新。

可重复读 隔离级别下,事务 A 看到的 V1100,因为事务 A 已经读过这个值,可重复读 保证事务 A 在整个事务中读取到的数据的一致性,所以 V2 也是 100。当事务 A 提交之后,就能看到最新的数据了,所以 V3200

串行化 隔离级别下,事务 A 看到的 V1100,因为事务 B 在修改的时候,会上锁。直到事务 A 提交之后,事务 B 才能继续执行。所以 V2100V3200

隔离级别的实现

那这些隔离级别是如何实现的呢?

实际上,读提交可重复读隔离级别的实现是通过 ReadView 来实现数据快照的。在 InnoDB 存储引擎中,ReadViewMVCC(多版本并发控制) 是实现这些隔离级别的核心机制。

我们先来了解一下ReadView。

ReadView 是 InnoDB 在执行查询时创建的一个数据快照,用于确保一个事务在执行查询时能够读取到一个一致的视图,而不会受到其他事务并发修改数据的影响。ReadView 通过记录特定事务的 视图 来保证事务一致性。

读提交 隔离级别下,每个查询都会在查询开始时创建一个新的 ReadView

ReadView 确保查询在执行时,读取的是 已提交的数据,即保证查询不会读取到其他事务未提交的脏数据。

但这种方式也允许在事务内多次读取同一行数据时返回不同的值,因为每个查询的 ReadView 是在查询开始时创建的,且查询结束时可能其他事务已经提交了修改。

可重复读 隔离级别下,ReadView 会被固定下来,这意味着,在同一个事务中,所有查询操作都会使用相同的 ReadView,从而保证多次查询的结果是一致的。

比如在我们之前提到的例子中,事务 A 在第一次读取数据时,就创建一个 ReadView,并且在整个事务中,都使用这个 ReadView 。即,事务 A 在整个执行过程中,看到的数据是与事务开始时的一致的,避免了不可重复读现象。

对于读未提交隔离级别,因为可以读到未提交事务修改的数据,所以直接读取最新的数据就好。

而对于串行化隔离级别,则是通过加读写锁来实现的。

什么是MVCC

MVCC 即多版本并发控制(Multi-Version Concurrency Control)。

MVCC 的主要作用是在数据库并发访问的情况下,实现不同事务对同一数据的并发读取和修改,同时保证数据的一致性和隔离性。

MVCC 会为数据库中的每一行数据保存多个版本。当一个数据被修改时,MVCC 不会直接覆盖原来的数据,而是创建一个新的版本,并记录下修改这个版本的事务 ID。这样,不同的事务在读取数据时,可以根据自己的事务 ID 和一些规则来确定应该读取哪个版本的数据。

在数据库中,假设一个值从 1 被按顺序改成了 2、3、4,那么 MVCC 是这样工作的:

当这个值最初为 1 时,MVCC 会记录下这个版本的数据值为 1,此时可能还没有事务 ID 或者有一个初始的事务 ID(假设为 T0)。

当这个值被改成 2 时,MVCC 不会覆盖值为 1 的版本,而是创建一个新的版本,数据值为 2,并记录下修改这个版本的事务 ID(假设为 T1)。

接着,当值被改成 3 时,同样 MVCC 会创建值为 3 的版本,同时记录下事务 ID(假设为 T2)。

最后,当值被改成 4 时,创建值为 4 的版本和对应的事务 ID(假设为 T3)。

MVCC 所做的这些记录,会组成一个回滚日志。如果想把 4 修改为 1,按照这个回滚日志进行回滚就可以了。

既然提到了回滚日志,我们就结合它来谈谈长事务。

长事务指的是执行时间较长的数据库事务。在一般情况下,我们是不建议使用长事务的。

为什么呢?

长事务会持续生成回滚日志。回滚日志用于在事务需要回滚或者数据库崩溃后进行恢复时,能够将数据恢复到事务开始前的状态。随着长事务的持续运行,回滚日志会不断累积,占用大量的磁盘空间。

比如,在一个大型电商平台的库存管理系统中,如果有一个长事务一直在更新库存数量,同时又不断有新的订单产生,那么这个长事务的回滚日志就会越来越多。如果不及时处理,就会占用大量的存储空间。

ReadView在MVCC中是如何工作的

分别了解了ReadVie和MVCC之后,我们再来看看这两者是如何协作的。

当一个事务在“可重复读”隔离级别下启动时,会创建一个 ReadView。这个 ReadView 主要包含以下几个关键部分:

  1. 活跃事务列表:记录了当前正在执行但还未提交的事务 ID。例如,在一个学校的成绩管理系统中,有多个老师同时在录入学生成绩,事务 T1 是老师 A 录入成绩的事务,事务 T2 是老师 B 录入成绩的事务。当事务 T3 启动时,ReadView 会记录下 T1 和 T2 的事务 ID,因为它们此时是活跃事务。
  2. 最小事务 ID:活跃事务列表中的最小事务 ID。比如在上述例子中,如果 T1 的事务 ID 比 T2 小,那么最小事务 ID 就是 T1 的事务 ID。
  3. 最大事务 ID:系统分配给下一个事务的事务 ID。假设当前最大事务 ID 是 100,那么下一个启动的事务的事务 ID 就是 101。

当事务去读取数据时,会根据 ReadView 来判断数据版本的可见性。如果一个数据版本的事务 ID 不在 ReadView 中的活跃事务列表中,并且这个数据版本的事务 ID 小于等于 ReadView 中的最小事务 ID,那么这个数据版本对于当前事务是可见的。

举个例子,在一个电商的库存管理系统中,事务 T4 开始查询商品的库存数量。此时创建了 ReadView,记录了活跃事务 T5 和 T6。商品库存有多个版本,假设初始库存为 100,事务 T7 修改库存为 90 并提交,事务 T8 修改库存为 80 并提交。但由于事务 T4 是在“可重复读”隔离级别下,并且有 ReadView 的限制,只有那些事务 ID 小于等于 ReadView 中最小事务 ID 且不在活跃事务列表中的数据版本才可见。如果事务 T7 和 T8 的事务 ID 都大于 ReadView 中的最小事务 ID,那么事务 T4 只能看到初始库存 100。

可重复读的使用场景

假设我们有一个非常繁忙的电商平台,每天都有大量的订单需要处理。这个电商平台的库存管理系统使用 MySQL 数据库来记录各种商品的库存数量。

现在有一个事务 T1,它负责处理一个客户的订单出库操作。当这个事务开始时,它首先查询商品 A 的库存数量。假设此时商品 A 的库存数量为 100 件。

在事务 T1 还在进行中的时候,另一个事务 T2 可能是一个供应商的入库操作,或者是另一个客户的订单导致商品 A 的库存发生了变化。

如果此时数据库的隔离级别不是 “可重复读”,那么当事务 T1 再次查询商品 A 的库存数量时,可能会得到一个不同的结果。比如,由于事务 T2 的影响,库存数量变成了 120 件或者 80 件。

但是,如果数据库的隔离级别设置为 “可重复读”,那么在事务 T1 执行期间,无论其他事务如何修改商品 A 的库存,事务 T1 始终能看到事务开始时的库存数量,也就是 100 件。这样就确保了在处理这个订单出库操作时,不会因为其他事务的干扰而出现错误。

总结

这篇文章里,我们分别了解了事务的四大特性、三大问题、四大隔离级别,以及ReadView 和 MVCC,并且分析了原理,如果你把这些内容都掌握的话,你肯定就会对事务有一个很好的理解啦。

发表评论

后才能评论