MySQL 事务隔离

事务用来保证一组数据库操作,要么一起成功,要么一起失败。不存在一部分操作成功,另一部分操作失败的情况。

最常见的例子就是银行的转账。当小明需要给小红转 100 元时,首先需要检查小明的账户是否有足够的余额,然后将小红的余额增加 100,更新小明的余额。在这个例子中,整个操作必须同时成功或者失败,不能出现小红的余额增加了 100,小明的余额却没有减少 100 的情况。

MySQL 的事务支持是在引擎层实现的。MySQL 支持多引擎,可以根据需要选择不同的引擎,但是 MySQL 原生的引擎 MyISAM 是不支持事务操作的,因此在之后被 Innodb 所替代了。

在 MySQL 谈到事务相关的概念时,没有特殊说明的情况下,我们都是在以 InnoDB 引擎为例,这也是新版本 MySQL 的默认引擎。

隔离性与隔离级别

ACID

事务具有四个特性,分别是原子性(Atomicity ),一致性(Consistency),隔离性(Isolation)以及持久性(Durability)

  • 原子性(Atomicity):在 MySQL 中,事务被视为单一的不可分割的单位,所有操作要么全做,要么全不做。MySQL 使用回滚(ROLLBACK)操作保证事务的原子性,在事务执行过程中如果发生错误,所做的全部更改将被撤销,数据库将返回到事务开始时的状态。
  • 一致性(Consistency):在 MySQL 中,一致性意味着只有合法的数据能被写入数据库,否则,事务应当会被回滚。例如,定义了数据的整合性约束(如外键约束),在更新操作中不满足约束的数据将不被写入。
  • 隔离性(Isolation):在 MySQL 中,多个事务可以并发执行,但每个事务都被视为在一个独立的“沙箱”中进行,保障了事务间不互相干扰。
  • 持久性(Durability):在 MySQL 中,一旦事务完成(即,被提交了),对数据库的更改就是永久性的,下次数据库启动时,数据仍然存在,这就是持久性。这是通过将事务记录在磁盘上的重做日志(Redo Log)中实现的。即使在系统出现故障的情况下也可以通过重做操作(Redo)恢复数据

由于事务执行是互不干扰的,因此在多个事务执行时,可能会出现下面的三种读取异常:

  • 脏读(Dirty Read):一个事务读取了其他事务未提交的修改。如果这些修改最后被回滚(即未被提交),那么读取这些数据的事务就会得到错误的数据。脏读可能导致数据不一致的问题。
  • 不可重复读(Non-Repeatable Read):在同一事务中,某个数据被多次读取,但每次得到的结果都不同。这通常是因为在两次读操作之间,另一个具有写权限的事务更改或删除了这个数据并进行了提交,导致同一事务的两次读取返回的结果不一致。这种现象称为不可重复读。
  • 幻读(Phantom Read):在同一事务中,执行完相同的查询操作,第一次和后续的查询结果不一致,即后续的查询操作多了一些行或者少了一些行。这通常发生在另一个并发事务在两次查询期间插入或删除了一些行造成的。这种现象称为幻读。

为了解决上面的这些问题,我们需要设置数据库的事务隔离级别。SQL 标准定义了四种隔离级别,从低到高分别为:

  • **读未提交 (Read Uncommitted)**:一个事务还没提交时,它做的变更就能被别的事务看到

  • **读已提交 (Read Committed)**:一个事务提交之后,它做的变更才会被其他事务看到

  • 可重复读 (Repeatable Read) :一个事务执行过程中看到的数据,总是跟这个事务在启动时看到的数据是一致的。在可重复读隔离级别下,未提交变更对其他事务也是不可见的

  • **串行化 (Serializable)**:对于同一行记录,“写”会加“写锁”,“读”会加“读锁”。当出现读写锁冲突的时候,后访问的事务必须等前一个事务执行完成,才能继续执行

这四个级别能够逐级避免脏读、不可重复读、幻读三种现象,但隔离级别越高,并发性能越低。在实际开发中需要根据业务需求来选择合适的隔离级别。

在 MySQL 的 InnoDB 存储引擎中,默认的隔离级别是 “可重复读”,这个级别下,能有效的防止脏读和不可重复读,但无法防止幻读。要避免幻读需要将隔离级别设置为 “串行化”。

在这四个等级中,读已提交与可重复读容易让人弄混。我们借助一个例子来说明它们的区别。

首先我们创建一张用于测试表

1
2
CREATE TABLE T(var int);
INSERT INTO T(var) VALUE (1);

然后我们按照时间顺序启动两个事务

事务 A 事务 B
启动事务, 查询得到 1 启动事务
查询得到值 1
将 1 改为 2
查询得到值 V1
提交事务 B
查询得到值 V2
提交事务 B
查询得到值 V3

在不同的隔离级别下,事务 A 查询到的值 V1,V2, V3 是不同的

  • 读未提交:V1,V2,V3 的值都是 2,此时虽然 B 还没有提交,但是 A 已经能看到 B 修改的值了
  • 读提交:V1 = 1,V2 = 2,V3=2,事务 B 只有完成提交,才能被 A 看到
  • 可重复读:V1=1, V2=1,V3=2,V2 还是 1 的原因是因为事务在执行期间看到的数据前后必须是一致的
  • 串行化:B 在更新 数据时会被锁住,直到 A 的事务提交后 B 才可以继续执行。因此 V1=1, V2=1,V3=2

事务隔离的实现

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

在可重复读的级别下,这个视图是在事务开始时创建,在整个事务期间都是使用这个视图。在读提交的隔离级别下,这个视图时是在每个 SQL 执行时创建的。在读未提交的级别下,会直接返回最新的数据记录,而没有视图的存在。在串行化的级别下,则是通过直接加锁来避免并发访问。

在 MySQL 中,每条记录在更新时都会记录一条回滚记录。记录上的最新值,通过回滚操作可以返回到上一个状态的值。

假设一个值 1 按顺序被改成了 2,3,4,那么就会在数据库中形成一条类似下面的记录:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19


view C view B view C
│ │ │
│ │ │
│ │ │
┌────────┴───────────────┴─────────────────────────┐ │
│ ▼ ▼ │ │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ ┌────▼─────┐
│ │ │ │ │ │ │ │ │ │
│ │ 2 -> 1 │◄────┤ 3 -> 2 │◄────┤ 4 -> 3 │◄─┼──┤ cur->4 │
│ │ │ │ │ │ │ │ │ │
│ └──────────┘ └──────────┘ └──────────┘ │ └──────────┘
│ │
│ │
│ rollback segment │
│ │
│ │
└──────────────────────────────────────────────────┘

当前值是 4,但是在查询这条记录的时候,不同时刻启动的事务会有不同的视图。就像图中看到的,在视图 A、B、C 里面,这一个记录的值分别是 1、2、4,同一条记录在系统中可以存在多个版本,就是数据库的多版本并发控制(MVCC)。对于 read-view A,要得到 1,就必须将当前值依次执行图中所有的回滚操作得到,也就是执行 4 ->3, 3->2, 2->1。

如果此时有另一个事务将 4 改成了 5,那么与当前的视图也不会产生冲突,只是会产生一个新的回滚记录。

回滚日志在系统判断没有事务使用时被删除。没有事务使用的判断依据是没有比回滚日志更早的事务存在了。

由于这个特性,如果我们在数据操作时开启了长事务,那么数据库中就会存在很老的事务视图。由于这个事务可能随时访问数据,因此数据库必须为它保留回滚日志,这就导致了大量的存储空间被占用。除此之外,长事务还会导致长时间的锁占用,从而拖累整个数据库。

事务的启动方式

在 MySQL 数据库中,事务有以下的两种启动方式:

  1. 使用 begin 或者 start transaction 来显示的启动事务,提交使用 commit,回滚则使用 rollback;
  2. 通过 set autocommit=0 命令关闭线程的自动提交。在这种情况下,如果你只执行一个 select 语句,这个事务就启动了,而且并不会自动提交。这个事务持续存在直到你主动执行 commit 或 rollback 语句,或者断开连接。

第二种启动方式处理不当就会导致长事务。因此最好使用 set autocommit=1 开启自动提交,并在需要使用事务时显示的开启事务。


MySQL 事务隔离
https://blog.zhangliangliang.cc/post/mysql-transaction-isolation.html
作者
Bobby Zhang
发布于
2023年12月28日
许可协议