6.MySQL事务

108 阅读17分钟

一、事务介绍

1.1 事务定义

概念:关系数据库中,事务是由一个或一组SQL语句组成的逻辑处理单元

1.2 事务属性(ACID)

  • A (Atomicity) 原子性:整个事务中的所有操作,要么全部完成,要么全部不完成,不可能停滞在中间某个环节。事务在执行过程中发生错误,会被回滚(Rollback)到事务开始前的状态,就像这个事务从来没有执行过一样

  • C (Consistency) 一致性:在事务开始之前和事务结束以后,数据库的完整性约束没有被破坏

  • I (Isolation)隔离性:一个事务的执行不能其它事务干扰。即一个事务内部的操作及使用的数据对其它并发事务是隔离的,并发执行的各个事务之间不能互相干扰

  • D (Durability) 持久性:在事务完成以后,该事务所对数据库所作的更改便持久的保存在数据库之中,并不会被回滚

1.3 事务隔离机制

1.3.1 并发事务可能会带来的问题?

  • 更新丢失(Lost Update): 事务A和事务B选择同一行,然后基于最初选定的值更新该行时,由于两个事务都不知道彼此的存在,就会发生丢失更新问题

  • 脏读(Dirty Reads):事务A读取了事务B更新的数据,然后B回滚操作,那么A读取到的数据是脏数据

  • 不可重复读(Non-Repeatable Reads):事务 A 多次读取同一数据,事务B在事务A多次读取的过程中,对数据作了更新并提交,导致事务A多次读取同一数据时,结果不一致。(读取某行数据不一样)

  • 幻读(Phantom Reads):幻读与不可重复读类似。它发生在一个事务A读取了几行数据,接着另一个并发事务B插入了一些数据时。在随后的查询中,事务A就会发现多了一些原本不存在的记录,就好像发生了幻觉一样,所以称为幻读。(读取行记录不一样)

解决方案

  • 第一种情况更新丢失通常是应该完全避免的。防止数据更新并不能单靠数据库事务控制器来解决,也需要应用程序对要更新的数据加必要的锁来解决。因此防止更新丢失是应用的责任。

  • 脏读、幻读、不可重复读,都是数据库读一致性问题,需要数据库提供一定的事务隔离机制来解决:

    1).加锁:读取数据前,对其加锁阻止其它事务对数据进行修改

    2).多版本并发(MVCC):通过一定机制生成一个数据请求时间点的一致性数据快照(snapshot),并用这个快照来提供一定级别的一致性读取。从用户角度来看,好像是数据库可以提供同一数据的多个版本

1.3.2 隔离级别

概述:MySQL是一个服务器/客户端架构的软件,对于同一个服务器来说,可以有若干个客户端与之连接,每个客户端与服务器连接上后,就可以称之为一个会话(session)。我们可以同时在不同会话里输入各种语句,这些语句可以作为事务的一部分进行处理。不同的会话可以同时发送请求,也就是说服务器可能同时处理多个事务,这样就会导致不同事务可能同时访问到相同记录

前文提及事务一个特性隔离性,理论上在某个事务对某个数据进行访问时,其它事务应该进行排队,当该事务提交后,其他事务才可以继续访问这个数据,但是这种设计对性能影响太大,所以就提出了隔离级别的设想。通过牺牲一定隔离性达到特高系统并发处理事务的能力。

1.3.2.1 读未提交(READ UNCOMMITTED)

定义:如果一个事务读到了另一个未提交事务修改过的数据数据,那么这种隔离级别就是未提交读

image.png

session A 和 session B各开启一个事务,session B先将id为1的记录更新为'关羽',然后session A去读取id为1的行记录也为关羽,但是如果session B如果进行回滚,那么session A中相当于读到了一个不存在的数据。这种现象称为脏读,脏读违背了现实世界的业务含义,这种隔离级别属于十分不安全的

1.3.2.2 读已提交(READ COMMITTED)

定义:如果一个事务只能读到另一个已经提交事务修改过的数据,且其它事务每对该数据进行修改提交后,该事务都能查询得到最新值,这种隔离级别称为读已提交。

image.png

图中可知,因为session B中事务尚未提交,所以session A中事务查询得到结果仍为刘备。当session B在第五步提交事务后,session A再查询得到值为关羽。这种现象我们称为不可重复读。

1.3.2.3 可重复读(REPEATABLE READ)

定义:在一些业务场景中,一个事务只能读到另一个已经提交的事务修改过的数据,但是第一次读过某条记录后,即使其它事务修改了该记录的值并提交,该事务之后再读该条记录时,读到的仍是第一次读到的值,而不是每次都读到不同的数据。那么这种隔离级别就称为可重复读。

image.png

图中可看出Session A中的事务第一次读取id为1的记录时,列c的值为刘备,之后虽然session B中隐式提交了多个事务,每个事务都修改了这条记录,但是session A中的事务读到的列c的值仍然为刘备,与第一次读取值是相同的

1.3.2.4 串行化(SERIALIZABLE)

定义:以上三种隔离级别都允许对同一记录进行读-读,读-写,写-读的并发操作,如果我们不允许读-写,写-读的并发操作,可以使用SERIALIZABLE隔离级别

image.png

如图当Session B中事务更新了id为1的记录后,之后Session A中的事务再去访问这条记录时被卡住了,直到Session B中事务提交后,Session A中事务才可以获取到查询结果。

事务隔离级别读数据一致性脏读不可重复读幻读
读未提交(read-uncommitted)最低级别,只能保证不读取物理上损坏的数据
读已提交(read-committed)语句级
可重复读(repeatable-read)事务级
串行化(serializable)最高级别,事务级

事务隔离级别和数据访问并发性是对立的,事务隔离级别越高并发性就越差。所以要根据具体应用确定合适的事务隔离级别。

MySQL InnoDB存储引擎的默认支持隔离级别是REPEATABLE-READ(可重读)。可以通过SELECT @@tx_isolation命令查看。默认隔离级别使用Next-Key Lock算法,可以避免幻读产生,所以说InnoDB存储引擎默认隔离界别已经可以完全保证事务隔离性要求。

因为隔离级别越低,事务请求锁越少,所以大部分数据库系统隔离级别都是READ-COMMITTED(读已提交);但是InnoDB默认使用REPEATABLE-READ(可重复读)并不会有任何性能损失。

二、MVCC多版本并发控制

2.1 概述

概述:MySQL的大多数事务性存储引擎实现都不是简单的行级锁。基于提升并发性考虑,一般都实现了多版本并发控制(MVCC).

MVCC可以认为是行级锁的一个变种,但它很多情况下避免了加锁操作,因此开销更低。虽然实现机制有所不同,但大都实现了非阻塞的读操作,写操作也是只锁定必要行

对于使用InnoDB存储引擎的表来说,它的聚集索引记录中都包含两个必要的隐藏列(row_id并不是的,创建的表中有主键或者非NULL唯一键时都不会包含row_id列)

  • trx_id:每次对某条聚集索引记录改动时,都会把对应的事务id赋值给trx_id隐藏列
  • roll_pointer:每次对某条聚集索引记录进行改动时,都会把旧的版本写入到undo日志,然后这个隐藏列就相当于一个指针,可以通过它来找到该记录修改前的信息。

2.2 MVCC原理分析

我们现在有张表t,只包含一条记录

mysql> SELECT * FROM t;
+----+--------+
| id | c      |
+----+--------+
|  1 | 刘备   |
+----+--------+
1 row in set (0.01 sec)

假设插入该记录事务id为80,那么刺客该条记录示意图如下: image.png 假设之后两个id分别为100、200的事务对这条记录进行update操作,流程如下:

image.png 每次对记录改动,都会记录条undo日志,每条undo日志也都有一个roll_pointer属性(INSERT操作对应的undo日志没有该属性,因为该记录没有更早的版本),可以讲这些undo日志都连起来,串成一个链表,所以现在情况如下图所示:

image.png 对该记录每次更新后,都会将旧值放到一条undo日志中,就算是该记录的一个旧版本,随着更新次数的增多,所有版本都会被roll_pointer属性连接成一个链表,我们把这个链表称为版本链,版本链的头结点就是当前记录最新值。另外每个版本中还包含生成该版本时对应的事务id.

2.3 ReadView

定义:ReadView解决的核心问题就是,需要判断一下版本链中哪些版本是当前事务可见的。

  • READ UNCOMMITTED:对于READ UNCOMMITTED隔离级别事务来说,直接读取记录的最新版本即可
  • SERIALIZABLE:该隔离界别事务使用加锁方式来访问行记录,性能低
  • READ COMMITTEDREPEATABLE READ:该隔离级别需要用到版本链解决版本链中事务可见性问题

ReadView中主要包含当前系统还有哪些活跃的读写事务,把他们的事务id放到一个列表m_ids中,这样在访问某条记录时,只需要按照以下步骤即可判断某个版本是否可见:

  1. 如果被访问版本的trx_id属性值小于m_ids列表中最小的事务id,表明生成该版本的事务在生成ReadView前已经提交,所以该版本可以被当前事务访问
  2. 如果被访问版本的trx_id属性值大于m_ids列表中最大的事务id,表明生成该版本的事务在生成ReadView后才生成,所以该版本不可以被当前事务访问
  3. 如果被访问版本的trx_id属性值在m_ids列表中最大的事务id和最小的事务id之间,就需要判断一下trx_id属性值是不是在m_ids列表中。如果在,说明创建ReadView时生成该版本的事务还是活跃的,该版本不可以被访问;如果不在,说明创建ReadView时生成该版本的事务已经被提交,该版本可以被访问。

如果某个版本数据对当前事务不可见的话,就顺着版本链找到下一个版本的数据,继续按照上述步骤判断可见性直到版本链中最后一个版本,如果最后一个版本也不可见的话就意味着该条记录对该事务不可见,查询结果就不包含该记录。

MySQL中,READ COMMITTED和REPEATABLE READ隔离级别很大的一个区别就是生成ReadView时机不同。

2.3.1 READ COMMITTED 生成ReadView

READ COMMITTED每次读取数据前都生成一个ReadView

假如现在系统里有两个id分别为100、200的事务在执行:

# Transaction 100
BEGIN;

UPDATE t SET c = '关羽' WHERE id = 1;

UPDATE t SET c = '张飞' WHERE id = 1;
# Transaction 200
BEGIN;

# 更新了一些别的表的记录
...

此刻表t中id为1的记录得到的版本链如下所示: image.png

假设现在有一个使用READ_COMMITTED隔离级别的事务开始执行

# 使用READ COMMITTED隔离级别的事务
BEGIN;

# SELECT1:Transaction 100200未提交
SELECT * FROM t WHERE id = 1; # 得到的列c的值为'刘备'

这个SELECT的执行过程如下:

  • 执行SELECT语句时会先生成一个ReadView,ReadView的m_ids列表内容是[100,200]
  • 然后从版本链中挑选可见记录,从图中可以看到,最新版本列c的内容是张飞,该版本的trx_id值为100,在m_ids列表内,所以不符合可见性要求根据roll_pointer跳到下一版本
  • 下一版本列c内容是关羽,该版本trx_id值也为100,也在m_ids列表内,也不符合要求,继续跳到下一版本
  • 下一版本列c内容是刘备,该版本trx_id值为80,小于m_ids列表中最小事务id 100,所以这个版本符合要求,返回给用户的版本就是这条记录

之后我们把事务id为100的事务提交一下:

# Transaction 100
BEGIN;

UPDATE t SET c = '关羽' WHERE id = 1;

UPDATE t SET c = '张飞' WHERE id = 1;

COMMIT;

然后再把事务id为200的事务中更新下表t中id为1的记录:

# Transaction 200
BEGIN;

# 更新了一些别的表的记录
...

UPDATE t SET c = '赵云' WHERE id = 1;

UPDATE t SET c = '诸葛亮' WHERE id = 1;

此刻表t中id为1的记录版本链如下图所示:

image.png 然后到刚才使用READ COMMITTED隔离级别的事务中继续查找这个id为1的记录如下:

# 使用READ COMMITTED隔离级别的事务
BEGIN;

# SELECT1:Transaction 100200均未提交
SELECT * FROM t WHERE id = 1; # 得到的列c的值为'刘备'

# SELECT2:Transaction 100提交,Transaction 200未提交
SELECT * FROM t WHERE id = 1; # 得到的列c的值为'张飞'

select2执行过程如下:

  • 在执行SELECT语句时会先生成一个ReadView,ReadView的m_ids列表的内容就是[200],因为事务id为100的事务已经提交了,所以生成快照时就没有它了。
  • 然后从版本链中挑选可见的记录,从图中可以看出,最新版本的列c的内容是诸葛亮,该版本trx_id值为200,在m_ids列表内,所以不符合可见性要求,根据roll_pointer跳到下一个版本
  • 下一个版本的列c的内容是赵云,该版本的trx_id值为200,也在m_ids列表内,所以也不符合要求,继续跳到下一版本
  • 下一版本列c内容为张飞,该版本的trx_id值为100,比m_ids列表中最小事务id 200还要小,所以该版本符合要求,最后返回该条记录。

总结:使用READ COMMITTED隔离级别的事务在每次查询开始时都会生成一个独立的ReadView

2.3.2 REPEATABLE READ 生成 ReadView

定义:对于使用REPEATABLE READ隔离级别的事务来说,只会在第一次执行查询语句时生成一个ReadView,之后的查询就不会重复生成了。示例如下:

假如现在系统有两个id分别为100、200的事务在执行:

# Transaction 100
BEGIN;

UPDATE t SET c = '关羽' WHERE id = 1;

UPDATE t SET c = '张飞' WHERE id = 1;
# Transaction 200
BEGIN;

# 更新了一些别的表的记录
...

此时表t中id为1的记录得到版本链如下所示: image.png

假设现在有个使用REPEATABLE READ隔离级别的事务开始执行:

# 使用REPEATABLE READ隔离级别的事务
BEGIN;

# SELECT1:Transaction 100200未提交
SELECT * FROM t WHERE id = 1; # 得到的列c的值为'刘备'

这个SELECT1的执行过程如下:

  • 在执行SELECT语句时会先生成一个ReadView,ReadView的m_ids列表内容就是[100,200]
  • 然后从版本链中挑选可见记录,从图中可看出,最新版本链c的内容是张飞,该版本的trx_id值为100,在m_ids列表内,所以不符合可见性要求,根据roll_pointer跳到下一版本。
  • 下一版本的列c的内容是关羽,该版本的trx_id值也为100,也在m_ids列表内,所以也不符合要求,继续跳到下一版本
  • 下一版本的列c的内容是刘备,该版本的trx_id值为80,小于m_ids列表中最小的事务id 100,所以这个版本是符合要求的,最后返回给用户的版本就是这条列c为刘备的记录。

之后我们把事务id为100的事务提交下:

# Transaction 100
BEGIN;

UPDATE t SET c = '关羽' WHERE id = 1;

UPDATE t SET c = '张飞' WHERE id = 1;

COMMIT;

然后再到事务id为200的事务中更新下表t中id为1的记录:

# Transaction 100
BEGIN;

UPDATE t SET c = '关羽' WHERE id = 1;

UPDATE t SET c = '张飞' WHERE id = 1;

COMMIT;

此刻表t中id为1的记录版本链就长这样: image.png

然后再到刚才使用REPEATABLE READ隔离级别的事务中继续查找这个id为1的记录如下:

# 使用REPEATABLE READ隔离级别的事务
BEGIN;

# SELECT1:Transaction 100200均未提交
SELECT * FROM t WHERE id = 1; # 得到的列c的值为'刘备'

# SELECT2:Transaction 100提交,Transaction 200未提交
SELECT * FROM t WHERE id = 1; # 得到的列c的值仍为'刘备'

这个SELECT2的执行过程如下:

  • 因为之前已经生成过ReadView了,所以此时直接复用之前的ReadView,之前的ReadView中的m_ids列表就是[100, 200]
  • 然后从版本链中挑选可见记录,从图中可以看出,最新版本的列c的内容是诸葛亮,该版本的trx_id值为200,在m_ids列表内,所以不符合可见性要求,根据roll_pointer跳到下一个版本
  • 下一版本列c的内容是赵云,该版本的trx_id值为200,也在m_ids列表内,所以也不符合要求,继续跳到下一版本
  • 下一版本列c的内容是张飞,该版本的trx_id值为100,而m_ids列表中是包含值为100的事务id的,所以该版本也不符合要求,同时下一个列c的内容是关羽的版本也不符合要求。继续跳到下一版本
  • 下一版本列c的内容是刘备,该版本的trx_id值为80,80小于m_ids列表中最小事务id 100,所以这个版本是符合要求的,最后返回给用户的也是该条记录

也就是说两次SELECT查询得到结果是重复的,记录的列c值都是刘备,这就是可重复读的含义。如果我们之后再把事务id为200的记录提交了,之后再到刚才使用REPEATABLE READ隔离级别的事务中继续查找这个id为1的记录,得到的结果还是刘备

2.3.3 总结

所谓的MVCC(Multi-Version Concurrency Control ,多版本并发控制)指的就是在使用READ COMMITTDREPEATABLE READ这两种隔离级别的事务在执行普通的SEELCT操作时访问记录的版本链的过程,这样子可以使不同事务的读-写写-读操作并发执行,从而提升系统性能。READ COMMITTDREPEATABLE READ这两个隔离级别的一个很大不同就是生成ReadView的时机不同,READ COMMITTD在每一次进行普通SELECT操作前都会生成一个ReadView,而REPEATABLE READ只在第一次进行普通SELECT操作前生成一个ReadView,之后的查询操作都重复这个ReadView就好了

参考: