事务概念
事务就是一组DML语句组成,这些语句在逻辑上存在相关性,这一组DML语句要么全部成功,要么全部失败,是一个整体。MySQL提供一种机制,保证我们达到这样的效果。事务还规定不同的客户端看到的数据是不相同的。 事务就是要做的或所做的事情,主要用于处理操作量大,复杂度高的数据。假设一种场景:你毕业了,学校的教务系统后台 MySQL 中,不在需要你的数据,要删除你的所有信息,那么要删除你的基本信息(姓名,电话,籍贯等)的同时,也删除和你有关的其他信息,比如:你的各科成绩,你在校表现,甚至你在论坛发过的文章等。这样,就需要多条 MySQL 语句构成,那么所有这些操作合起来,就构成了一个事务。 一个 MySQL 数据库,可不止你一个事务在运行,同一时刻,甚至有大量的请求被包装成事务,在向 MySQL 服务器发起事务处理请求。而每条事务至少一条 SQL ,最多很多 SQL ,这样如果大家都访问同样的表数据,在不加保护的情况,就绝对会出现问题。甚至,因为事务由多条 SQL 构成。
一个完整的事务,绝对不是简单的 sql 集合,还需要满足如下四个属性:
原子性:一个事务(transaction)中的所有操作,要么全部完成,要么全部不完成,不会结束在中间某个环节。事务在执行过程中发生错误,会被回滚(Rollback)到事务开始前的状态,就像这个事务从来没有执行过一样。 一致性:在事务开始之前和事务结束以后,数据库的完整性没有被破坏。这表示写入的资料必须完全符合所有的预设规则,这包含资料的精确度、串联性以及后续数据库可以自发性地完成预定的工作。 隔离性:数据库允许多个并发事务同时对其数据进行读写和修改的能力,隔离性可以防止多个事务并发执行时由于交叉执行而导致数据的不一致。事务隔离分为不同级别,包括读未提交( Read uncommitted )、读提交( read committed )、可重复读( repeatable read )和串行化( Serializable ); 持久性:事务处理结束后,对数据的修改就是永久的,即便系统故障也不会丢失。
上面四个属性,可以简称为 ACID 。
原子性(Atomicity,或称不可分割性); 一致性(Consistency); 隔离性(Isolation,又称独立性); 持久性(Durability)。
为什么会出现事务?
事务被 MySQL 编写者设计出来,本质是为了当应用程序访问数据库的时候,事务能够简化我们的编程模型,不需要我们去考虑各种各样的潜在错误和并发问题。 可以想一下当我们使用事务时,要么提交,要么回滚,我们不会去考虑网络异常了,服务器宕机了,同时更改一个数据怎么办,因此事务本质上是为了应用层服务的,而不是伴随着数据库系统天生就有的。
事务的版本支持
通过show engines命令可以查看数据库引擎。
Engine: 表示存储引擎的名称。 Support: 表示服务器对存储引擎的支持级别,YES表示支持,NO表示不支持,DEFAULT表示数据库默认使用的存储引擎,DISABLED表示支持引擎但已将其禁用。 Comment: 表示存储引擎的简要说明。 Transactions: 表示存储引擎是否支持事务,可以看到InnoDB存储引擎支持事务,而MyISAM存储引擎不支持事务。 XA: 表示存储引擎是否支持XA事务。 Savepoints: 表示存储引擎是否支持保存点。
事务提交方式
事务的提交方式常见的有两种:
通过show命令查看autocommit全局变量,可以查看事务的自动提交是否被打开。
我们也可以用用 SET 来改变 MySQL 的自动提交模式。
事务常见操作方式
为了便于演示,我们将MySQL的隔离级别设置为读未提交,把隔离级别设置的比较低,方便看到实验现象。
注意,设置全局隔离级别后当前会话的隔离级别不会改变,只会影响后续与MySQL新建立的连接,因此需要重启终端才能看到会话的隔离级别被成功设置。
创建测试表
正常演示-证明事物的开始与回滚
启动两个终端,左终端使用begin或start transaction命令启动一个事务,右终端查看银行用户表中的信息。
左终端中的事务向表中插入一条记录,由于我们将隔离级别设置成了读未提交,因此在左终端中的事务使用commit提交之前,在右终端中就能查看到事务向表中插入的记录。
左终端中的事务使用savepoint命令创建一个保存点,然后继续向表中插入一条记录,这时在右终端中也能看到新插入的这条记录。
左终端中的事务使用rollback命令回滚到保存点,这时右终端在查看表中数据时就看不到刚才插入的第二条记录了。
左终端中的事务使用rollback命令回滚到事务最开始,这时右终端在查看表中数据时就看不到任何记录了。
使用begin或start transaction命令,可以启动一个事务。 使用savepoint 保存点命令,可以在事务中创建指定名称的保存点。 使用rollback to 保存点命令,可以让事务回滚到指定保存点。 使用rollback命令,可以直接让事务回滚到最开始。 使用commit命令,可以提交事务,提交事务后就不能回滚了。
非正常演示1 - 证明未commit,客户端崩溃,MySQL自动会回滚(隔离级别设置为读未提交)
左端插入一条记录,由于隔离级别设置为读未提交,右端就会在左端未commit之前查看到这条记录,此时我们在左端使用Ctrl + \ 异常终止MySQL,我们会发现终端A崩溃后数据自动回滚。
非正常演示2 - 证明commit了,客户端崩溃,MySQL数据不会在受影响,已经持久化
左端插入一条记录,然后进行commit,此时我们在左端使用Ctrl + \ 异常终止MySQL,我们会发现终端A崩溃后在终端B依然可以查看到刚刚插入的数据。
非正常演示3 - 对比试验。证明begin操作会自动更改提交方式,不会受MySQL是否自动提交影响
首先我们来查看事务的提交方式,此时事务提交方式为自动提交,用户表中有一条记录。
在左终端中启动一个事务并向表中新插入一条记录,由于隔离级别是读未提交,因此在右终端中能够查询到新插入的这条记录。
如果左终端中的事务在提交之前与MySQL断开连接,那么MySQL依旧会自动让事务回滚到最开始,这时右终端中就看不到之前新插入的记录了。
也就是说,使用begin或start transaction命令启动的事务,都必须要使用commit命令手动提交,数据才会被持久化,与是否设置autocommit无关。
非正常演示4 - 证明单条 SQL 与事务的关系
实际全局变量autocommit是否被设置影响的是单条SQL语句,InnoDB中的每一条SQL都会默认被封装成事务。 autocommit为ON,则单条SQL语句执行后会自动被提交,如果为OFF,则SQL语句执行后需要使用commit进行手动提交
首先我们来查看事务的提交方式,此时事务提交方式为自动提交,用户表中有一条记录,在左终端中启动一个事务并向表中新插入一条记录,由于隔离级别是读未提交,因此在右终端中能够查询到新插入的这条记录。
但就算左终端在执行单条SQL后不使用commit进行提交,而直接与MySQL断开连接,这时右终端仍然可以看到之前新插入的记录了,因为单条SQL在执行后被自动提交持久化了。
此时我们将autocommit设置为手动提交,再向表中插入一条记录,直接退出MySQL就会发现这时右终端中就看不到之前新插入的记录了,因为这时单条SQL执行后需要使用commit手动提交后才会持久化,在commit之前与MySQL断开连接则会自动进行回滚操作。
结论:
只要输入begin或者start transaction,事务便必须要通过commit提交,才会持久化,与是否设置set autocommit无关。 事务可以手动回滚,同时,当操作异常,MySQL会自动回滚。 对于 InnoDB 每一条 SQL 语言都默认封装成事务,自动提交。(select有特殊情况,因为MySQL 有 MVCC )。 从上面的例子,我们能看到事务本身的原子性(回滚),持久性(commit)。
事务操作注意事项:
如果没有设置保存点,也可以回滚,只能回滚到事务的开始。直接使用 rollback(前提是事务还没有提交)。 如果一个事务被提交了(commit),则不可以回退(rollback)。 可以选择回退到哪个保存点。 InnoDB 支持事务, MyISAM 不支持事务。 开始事务可以使 start transaction 或者 begin。
事务隔离级别
如何理解隔离性
MySQL服务可能会同时被多个客户端进程(线程)访问,访问的方式以事务方式进行; 一个事务可能由多条SQL构成,也就意味着,任何一个事务,都有执行前,执行中,执行后的阶段。而所谓的原子性,其实就是让用户层,要么看到执行前,要么看到执行后。执行中出现问题,可以随时回滚,所以单个事务,对用户表现出来的特性,就是原子性; 但是毕竟所有事务都要有个执行过程,那么在多个事务各自执行多个SQL的时候,就还是有可能会出现互相影响的情况。比如:多个事务同时访问同一张表,甚至同一行数据; 数据库中,为了保证事务执行过程中尽量不受干扰,就有了一个重要特征:隔离性; 数据库中,允许事务受不同程度的干扰,就有了一种重要特征:隔离级别。
隔离级别
读未提交【Read Uncommitted】 : 在该隔离级别,所有的事务都可以看到其他事务没有提交的执行结果。(实际生产中不可能使用这种隔离级别的),但是相当于没有任何隔离性,也会有很多并发问题,如脏读,幻读,不可重复读等,我们上面为了做实验方便,用的就是这个隔离性;读提交【Read Committed】 :该隔离级别是大多数数据库的默认的隔离级别(不是 MySQL 默认的)。它满足了隔离的简单定义:一个事务只能看到其他的已经提交的事务所做的改变。这种隔离级别会引起不可重复读,即一个事务执行时,如果多次 select, 可能得到不同的结果;可重复读【Repeatable Read】 : 这是 MySQL 默认的隔离级别,它确保同一个事务,在执行中,多次读取操作数据时,会看到同样的数据行。但是会有幻读问题;串行化【Serializable】 : 这是事务的最高隔离级别,它通过强制事务排序,使之不可能相互冲突,从而解决了幻读的问题。它在每个读的数据行上面加上共享锁,但是可能会导致超时和锁竞争。
隔离级别如何实现:隔离,基本都是通过锁实现的,不同的隔离级别,锁的使用是不同的。常见有,表锁,行锁,读锁,写锁,间隙锁(GAP),Next-Key锁(GAP+行锁)等。
查看与设置隔离级别
通过select @@global.tx_isolation
命令,可以查看全局隔离级别。
通过select @@session.tx_isolation
或者是select @@tx_isolation
命令,可以查看当前会话的隔离级别。
设置会话隔离级别
通过set session transaction isolation level 隔离级别
命令,可以设置当前会话的隔离级别。
注意:设置会话的隔离级别只会影响当前会话,新起的会话依旧采用全局隔离级。
设置全局隔离级别
通过set global transaction isolation level 隔离级别
命令,可以设置全局隔离级别。
注意: 设置全局隔离级别会影响后续的新会话,但当前会话的隔离级别没有发生变化,如果要让当前会话的隔离级别也改变,则需要重启会话。
读未提交(Read Uncommitted)
启动两个客户端,将隔离级别设置为读未提交,然后查看表中信息。
此时我们在左边客户端对信息进行修改,并不提交,在右边客户端查看时会发现数据已经更新了。
读未提交是事务的最低隔离级别,几乎没有加锁,虽然效率高,但是问题比较多,所以严重不建议使用。 一个事务在执行过程中,读取到另一个执行中的事务所做的修改,但是该事务还没有进行提交,这种现象叫做脏读。
读提交(Read Committed)
启动两个终端,将隔离级别设置为读提交,然后查看表中信息。
此时我们在左终端向表中添加数据,右终端查看信息时会发现数据并没有更新。
然后将左端数据提交,就会发现在右端就可以看见数据了。
一个事务在执行过程中,两个相同的select查询得到了不同的数据,这种现象叫做不可重复读。
可重复读(Repeatable Read)
启动两个终端,将隔离级别设置为可重复读,然后查看表中信息。
左端插入一条数据,右端查看数据,会发现数据并没有进行更新。
我们将左端数据提交,右端此时还是看不见更新后的数据。
只有我们将右端也进行提交,此时数据才会更新出来。
在可重复读隔离级别下,一个事务在执行过程中,相同的select查询得到的是相同的数据,这就是所谓的可重复读。 一般的数据库在可重复读隔离级别下,update数据是满足可重复读的,但insert数据会存在幻读问题,因为隔离性是通过对数据加锁完成的,而新插入的数据原本是不存在的,因此一般的加锁无法屏蔽这类问题。 一个事务在执行过程中,相同的select查询得到了新的数据,如同出现了幻觉,这种现象叫做幻读。
串行化(serializable)
启动两个终端,将事件隔离级别设置为串行化,查看表中数据。
但如果这两个事务中有一个事务要对表进行写操作,那么这个事务就会立即被阻塞。
直到访问这张表的其他事务都提交后,这个被阻塞的事务才会被唤醒,然后才能对表进行修改操作。
串行化是事务的最高隔离级别,多个事务同时进行读操作时加的是共享锁,因此可以并发执行读操作,但一旦需要进行写操作,就会进行串行化,效率很低,几乎不会使用。
隔离级别总结
其中隔离级别越严格,安全性越高,但数据库的并发性能也就越低,往往需要在两者之间找一个平衡点; 不可重复读的重点是修改和删除:同样的条件,你读取过的数据,再次读取出来发现值不一样了; 幻读的重点在于新增:同样的条件,第1次和第2次读出来的记录数不一样; 说明: mysql 默认的隔离级别是可重复读,一般情况下不要修改; 上面的例子可以看出,事务也有长短事务这样的概念。事务间互相影响,指的是事务在并行执行的时候,即都没有commit的时候,影响会比较大。
对MySQL中的隔离级别总结如下:
隔离级别 脏读 不可重复读 幻读 加锁读 读未提交(read uncommitted) √ √ √ 不加锁 读已提交(read committed) X √ √ 不加锁 可重复读(repeatable read) X X X 不加锁 可串行化(serializable) X X X 加锁
一致性(Consistency)
事务执行的结果,必须使数据库从一个一致性状态,变到另一个一致性状态。当数据库只包含事务成功提交的结果时,数据库处于一致性状态。如果系统运行发生中断,某个事务尚未完成而被迫中断,而改未完成的事务对数据库所做的修改已被写入数据库,此时数据库就处于一种不正确(不一致)的状态。因此一致性是通过原子性来保证的; 其实一致性和用户的业务逻辑强相关,一般MySQL提供技术支持,但是一致性还是要用户业务逻辑做支撑,也就是,一致性,是由用户决定的; 而技术上,通过AID保证C。
多版本并发控制
数据库并发的场景
数据库并发的场景有三种:
读-读 :不存在任何问题,也不需要并发控制; 读-写 :有线程安全问题,可能会造成事务隔离性问题,可能遇到脏读,幻读,不可重复读; 写-写 :有线程安全问题,可能会存在更新丢失问题,比如第一类更新丢失,第二类更新丢失。
写-写并发场景下的第一类更新丢失又叫做回滚丢失,即一个事务的回滚把另一个已经提交的事务更新的数据覆盖了,第二类更新丢失又叫做覆盖丢失,即一个事务的提交把另一个已经提交的事务更新的数据覆盖了。
多版本并发控制
多版本并发控制( MVCC )是一种用来解决 读-写冲突 的无锁并发控制,为事务分配单向增长的事务ID,为每个修改保存一个版本,版本与事务ID关联,读操作只读该事务开始前的数据库的快照。 所以 MVCC 可以为数据库解决以下问题:
在并发读写数据库时,可以做到在读操作时不用阻塞写操作,写操作也不用阻塞读操作,提高了数据库并发读写的性能; 同时还可以解决脏读,幻读,不可重复读等事务隔离问题,但不能解决更新丢失问题。
理解 MVCC 需要知道三个前提知识:
3个记录隐藏字段; undo 日志; Read View。
3个记录隐藏列字段
DB_TRX_ID :6 byte,最近修改( 修改/插入 )事务ID,记录创建这条记录/最后一次修改该记录的事务ID; DB_ROLL_PTR : 7 byte,回滚指针,指向这条记录的上一个版本(简单理解成,指向历史版本就行,这些数据一般在 undo log 中) DB_ROW_ID : 6 byte,隐含的自增ID(隐藏主键),如果数据表没有主键, InnoDB 会自动以 DB_ROW_ID 产生一个聚簇索引; 补充:实际还有一个删除flag隐藏字段, 既记录被更新或删除并不代表真的删除,而是删除flag变了。
示例
创建一个学生表,表中包含学生的姓名和年龄。
当向表中插入一条记录后,该记录不仅包含name和age字段,还包含三个隐藏字段。
我们目前并不知道创建该记录的事务ID,隐式主键,我们就默认设置成null,1。第一条记录也没有其他版本,我们设置回滚指针为null。
undo日志
MySQL的三大日志如下:
redo log:重做日志,用于MySQL崩溃后进行数据恢复,保证数据的持久性。 bin log:逻辑日志,用于主从数据备份时进行数据同步,保证数据的一致性。 undo log:回滚日志,用于对已经执行的操作进行回滚,保证事务的原子性。
MySQL会为上述三大日志开辟对应的缓冲区,用于存储日志相关的信息,必要时会将缓冲区中的数据刷新到磁盘。
MVCC的实现主要依赖三大日志中的undo log,记录的历史版本就是存储在undo log对应的缓冲区中的。
模拟MVCC
现在有一个事务10,对student表中记录进行修改(update):将name(张三)改成name(李四)。
事务10,因为要修改,所以要先给该记录加行锁; 修改前,现将改行记录拷贝到undo log中,所以,undo log中就有了一行副本数据。(原理就是写时拷贝) 所以现在 MySQL 中有两行同样的记录。现在修改原始记录中的name,改成 ‘李四’。并且修改原始记录的隐藏字段 DB_TRX_ID 为当前 事务10 的ID, 我们默认从 10 开始,之后递增。而原始记录的回滚指针 DB_ROLL_PTR 列,里面写入undo log中副本数据的地址,从而指向副本记录,既表示我的上一个版本就是它; 事务10提交,释放锁。
此时,最新的记录是’李四‘那条记录。
现在又有一个事务11,对student表中记录进行修改(update):将age(28)改成age(38)。
事务11,因为也要修改,所以要先给该记录加行锁。 修改前,现将改行记录拷贝到undo log中,所以,undo log中就又有了一行副本数据。此时,新的副本,我们采用头插方式,插入undo log。 现在修改原始记录中的age,改成 38。并且修改原始记录的隐藏字段 DB_TRX_ID 为当前 事务11 的ID。而原始记录的回滚指针 DB_ROLL_PTR 列,里面写入undo log中副本数据的地址,从而指向副本记录,既表示我的上一个版本就是它。 事务11提交,释放锁。
此时我们就有了一个基于链表记录的历史版本链,而undo log中的一个个的历史版本就称为一个个的快照。
所谓的回滚实际就是用undo log中的历史数据覆盖当前数据,而所谓的创建保存点就可以理解成是给某些版本做了标记,让我们可以直接用这些版本数据来覆盖当前数据。
insert和delete的记录如何维护版本链?
删除记录并不是真的把数据删除了,而是先将该记录拷贝一份放入undo log中,然后将该记录的删除flag隐藏字段设置为1,这样回滚后该记录的删除flag隐藏字段就又变回0了,相当于删除的数据又恢复了。 新插入的记录是没有历史版本的,但是一般为了回滚操作,新插入的记录也需要拷贝一份放入undo log中,只不过被拷贝到undo log中的记录的删除flag隐藏字段被设置为1,这样回滚后就相当于新插入的数据就被删除了。
也就是说,增加、删除和修改数据都是可以形成版本链的。
当前读 VS 快照读
当前读:读取最新的记录,就叫做当前读。 快照读:读取历史版本,就叫做快照读。
事务在进行增删查改的时候,并不是都需要进行加锁保护:
事务对数据进行增删改的时候,操作的都是最新记录,即当前读,需要进行加锁保护。 事务在进行select查询的时候,既可能是当前读也可能是快照读,如果是当前读,那也需要进行加锁保护,但如果是快照读,那就不需要加锁,因为历史版本不会被修改,也就是可以并发执行,提高了效率,这也就是MVCC的意义所在。
而select查询时应该进行当前读还是快照读,则是由隔离级别决定的,在读未提交和串行化隔离级别下,进行的都是当前读,而在读提交和可重复读隔离级别下,既可能进行当前读也可能进行快照读。
undo log中的版本链何时才会被清除?
在undo log中形成的版本链不仅仅是为了进行回滚操作,其他事务在执行过程中也可能读取版本链中的某个版本,也就是快照读。 因此,只有当某条记录的最新版本已经修改并提交,并且此时没有其他事务与该记录的历史版本有关了,这时该记录在undo log中的版本链才可以被清除。
注意:
对于新插入的记录来说,没有其他事务会访问它的历史版本,因此新插入的记录在提交后就可以将undo log中的版本链清除了。 因此版本链在undo log中可能会存在很长时间,尤其是有其他事务和这个版本链相关联的时候,但这也没有坏处,这说明它是一个热数据。
Read View
事务在进行快照读操作时会生成读视图Read View,在该事务执行快照读的那一刻,会生成数据库系统当前的一个快照,记录并维护系统当前活跃的事务ID。 Read View在MySQL源码中就是一个类,本质是用来进行可见性判断的,当事务对某个记录执行快照读的时候,对该记录创建一个Read View,根据这个Read View来判断,当前事务能够看到该记录的哪个版本的数据。
class ReadView {
private :
trx_id_t m_low_limit_id;
trx_id_t m_up_limit_id;
trx_id_t m_creator_trx_id;
ids_t m_ids;
trx_id_t m_low_limit_no;
bool m_closed;
} ;
其中:
m_ids: 一张列表,记录Read View生成时刻,系统中活跃的事务ID。 m_up_limit_id: 记录m_ids列表中事务ID最小的ID。 m_low_limit_id: 记录Read View生成时刻,系统尚未分配的下一个事务ID。 m_creator_trx_id: 记录创建该Read View的事务的事务ID。
由于事务ID是单向增长的,因此根据Read View中的m_up_limit_id和m_low_limit_id,可以将事务ID分为三个部分:
事务ID小于m_up_limit_id的事务,一定是生成Read View时已经提交的事务,因为m_up_limit_id是生成Read View时刻系统中活跃事务ID中的最小ID,因此事务ID比它小的事务在生成Read View时一定已经提交了。 事务ID大于等于m_low_limit_id的事务,一定是生成Read View时还没有启动的事务,因为m_low_limit_id是生成Read View时刻,系统尚未分配的下一个事务ID。 事务ID位于m_up_limit_id和m_low_limit_id之间的事务,在生成Read View时可能正处于活跃状态,也可能已经提交了,这时需要通过判断事务ID是否存在于m_ids中来判断该事务是否已经提交。
RR与RC的本质区别
首先我们创建两个终端,事务隔离界别设置为可重复读。
两端各自启动一个事务,先在右端查看一下表的信息。
刺客在左端对数据进行修改并提交,右端查看数据发现并没有进行更新。
在右终端中使用select ... lock in share mode
命令进行当前读,可以看到表中的数据确实是被修改了,只是右终端中的事务看不到而已。
如果修改一下SQL的执行顺序,在两个终端各自启动一个事务后,直接让左终端中的事务对表中的信息进行修改并提交,然后再让右终端中的事务进行查看,这时右终端中的事务就直接看到了修改后的数据。
在右终端中使用select … lock in share mode命令进行当前读,可以看到刚才读取到的确实是最新的数据。
上面两次实验的唯一区别在于,右终端中的事务在左终端中的事务修改数据之前是否进行过快照读。 由于RR级别下要求事务内每次读取到的结果必须是相同的,因此事务首次进行快照读的地方,决定了该事务后续快照读结果的能力。
RR与RC的本质区别
正是Read View生成时机的不同,从而造成RC,RR级别下快照读的结果的不同; 在RR级别下的某个事务的对某条记录的第一次快照读会创建一个快照及Read View, 将当前系统活跃的其他事务记录起来 此后在调用快照读的时候,还是使用的是同一个Read View,所以只要当前事务在其他事务提交更新之前使用过快照读,那么之后的快照读使用的都是同一个Read View,所以对之后的修改不可见; 即RR级别下,快照读生成Read View时,Read View会记录此时所有其他活动事务的快照,这些事务的修改对于当前事务都是不可见的。而早于Read View创建的事务所做的修改均是可见; 而在RC级别下的,事务中,每次快照读都会新生成一个快照和Read View, 这就是我们在RC级别下的事务中可以看到别的事务提交的更新的原因; 总之在RC隔离级别下,是每个快照读都会生成并获取最新的Read View;而在RR隔离级别下,则是同一个事务中的第一个快照读才会创建Read View, 之后的快照读获取的都是同一个Read View; 正是RC每次快照读,都会形成Read View,所以,RC才会有不可重复读问题。