文章目录
在操作系统,存在进程并发的问题。同样在MySQL中,也存在一个表的并发操作,所以也需要对操作约束,保证数据的一致性,这就是事务的作用。
一、什么是事务
事务就是一组DML
(数据操纵语言,包括增删改查)语句组成,这些语句在逻辑上存在相关性,这一组DML
语句要么全部成功,要么全部失败,是一个整体。MySQL
提供一种机制,保证我们达到这样的效果。
我们之前单独的一条DML
语句就是一个事务。
为什么会出现事务
事务被 MySQL 编写者设计出来,本质是为了当应用程序访问数据库的时候,事务能够简化我们的编程模型,不需要我们去考虑各种各样的潜在错误和并发问题。可以想一下当我们使用事务时,要么提交,要么回滚,我们不会去考虑网络异常了,服务器宕机了,同时更改一个数据怎么办对吧?
因此事务本质上是为了应用层服务的,而不是伴随着数据库系统天生就有的。
1.1 事务的属性
在真实环境下,不止一个事务在运行,一个表可能被多个客户端操作,为了保证数据的安全,需要给事务设置如下四个基本属性。
- 原子性: 一个事务(transaction)中的所有操作,要么全部完成,要么全部不完成,不会结束在中间某个环节。类比OS中的原子性。
事务在执行过程中发生错误,会被回滚(Rollback)到事务开始前的状态,就像这个事务从来没有执行过一样。 - 一致性: 在事务开始之前和事务结束以后,数据库的完整性没有被破坏。
- 隔离性: 数据库允许多个并发事务同时对其数据进行读写和修改的能力,隔离性可以防止多个事务并发执行时由于交叉执行而导致数据的不一致。
事务隔离分为不同级别,包括读未提交( Read uncommitted )、读提交( read committed )、可重复读( repeatable read )和串行化( Serializable ) - 持久性: 事务处理结束后,对数据的修改就是永久的,即便系统故障也不会丢失。
上面四个属性,可以简称为 ACID 。
- 原子性(Atomicity,或称不可分割性)
- 一致性(Consistency)
- 隔离性(Isolation,又称独立性)
- 持久性(Durability)。
1.2 事务的版本支持
在 MySQL 中只有使用了 Innodb 数据库引擎的数据库或表才支持事务, MyISAM 不支持。
1.3 事务的提交方式
事务的提交方式常见的有两种:
- 自动提交
- 手动提交
查看事务提交方式:show variables like 'autocommit';
mysql> show variables like 'autocommit';
+---------------+-------+
| Variable_name | Value |
+---------------+-------+
| autocommit | ON | -- 默认为打开状态
+---------------+-------+
1 row in set (0.01 sec)
改变事务自动提交:set autocommit = 0; // 0是关闭,1是打开
mysql> set autocommit = 0;
Query OK, 0 rows affected (0.00 sec)
mysql> show variables like 'autocommit';
+---------------+-------+
| Variable_name | Value |
+---------------+-------+
| autocommit | OFF |
+---------------+-------+
1 row in set (0.00 sec)
注意:
- 如果手动开始的一个事务,需要手动提交(
commit
),与系统的自动提交是否打开无关。 - 也就是说自动提交适用于,以前写的单独操作语句。
1.4 事务的常见操作
- 开始一个事务:
begin
/start transaction;
- 创建一个保存(回滚)点:
savepoint+保存点名字
; - 回滚操作:
rollback to 保存点名字;
,如果直接rollback则是直接回滚到事物的最开始。 - 提交事务:
commit;
- 查看事务的隔离级别:
select @@tx_isolation;
1.4.1 什么是回滚
在一个事务中,当我们想要撤销之前的操作,回到初始的状态就是回滚。
创建一个grade
表,初始如下。
mysql> select * from grade;
+----+--------+-------+
| id | name | grade |
+----+--------+-------+
| 1 | 张三 | A |
| 2 | 李四 | B |
| 3 | 王五 | D |
+----+--------+-------+
3 rows in set (0.00 sec)
mysql> start transaction; -- 开始一个事务
Query OK, 0 rows affected (0.01 sec)
mysql> insert into grade values(4, '赵六', 'A');
Query OK, 1 row affected (0.00 sec)
mysql> savepoint s1; -- 保存一个回滚点
Query OK, 0 rows affected (0.00 sec)
mysql> insert into grade values(5, '田七', 'C');
Query OK, 1 row affected (0.00 sec)
mysql> select * from grade;
+----+--------+-------+
| id | name | grade |
+----+--------+-------+
| 1 | 张三 | A |
| 2 | 李四 | B |
| 3 | 王五 | D |
| 4 | 赵六 | A |
| 5 | 田七 | C |
+----+--------+-------+
5 rows in set (0.00 sec)
mysql> rollback to s1; -- 回滚到s1
Query OK, 0 rows affected (0.00 sec)
mysql> select * from grade;
+----+--------+-------+
| id | name | grade |
+----+--------+-------+
| 1 | 张三 | A |
| 2 | 李四 | B |
| 3 | 王五 | D |
| 4 | 赵六 | A |
+----+--------+-------+
4 rows in set (0.00 sec)
mysql> rollback; -- 回滚到最事务开始
Query OK, 0 rows affected (0.00 sec)
mysql> select * from grade;
+----+--------+-------+
| id | name | grade |
+----+--------+-------+
| 1 | 张三 | A |
| 2 | 李四 | B |
| 3 | 王五 | D |
+----+--------+-------+
3 rows in set (0.00 sec)
1.4.2 不同场景的事务回滚
- 事务未提交,客户端崩溃,MySQL自动会回滚到事务开始。
mysql> insert into grade values(4, '赵六', 'A');
Query OK, 1 row affected (0.00 sec)
mysql> insert into grade values(5, '田七', 'C');
Query OK, 1 row affected (0.00 sec)
mysql> Aborted -- 退出mysql客户端,未提交
mysql> select * from grade; -- 重新查看
+----+--------+-------+
| id | name | grade |
+----+--------+-------+
| 1 | 张三 | A |
| 2 | 李四 | B |
| 3 | 王五 | D |
+----+--------+-------+
3 rows in set (0.00 sec)
- 证明
commit
了,客户端崩溃,修改的数据正常,已经持久化
mysql> begin;
Query OK, 0 rows affected (0.00 sec)
mysql> insert into grade values(4, '赵六', 'A');
Query OK, 1 row affected (0.00 sec)
mysql> insert into grade values(5, '田七', 'C');
Query OK, 1 row affected (0.00 sec)
mysql> commit; -- 主动提交
Query OK, 0 rows affected (0.00 sec)
mysql> Aborted
-------------------------------------
mysql> select * from grade; -- 重新打开查看
+----+--------+-------+
| id | name | grade |
+----+--------+-------+
| 1 | 张三 | A |
| 2 | 李四 | B |
| 3 | 王五 | D |
| 4 | 赵六 | A |
| 5 | 田七 | C |
+----+--------+-------+
5 rows in set (0.00 sec)
- 验证系统自动提交的有效性
mysql> set autocommit = 1;
Query OK, 0 rows affected (0.00 sec)
mysql> show variables like 'autocommit';
+---------------+-------+
| Variable_name | Value |
+---------------+-------+
| autocommit | ON |
+---------------+-------+
1 row in set (0.00 sec)
mysql> begin;
Query OK, 0 rows affected (0.00 sec)
mysql> insert into grade values(6, '66', 'C');
Query OK, 1 row affected (0.00 sec)
mysql> insert into grade values(7, '77', 'D');
Query OK, 1 row affected (0.00 sec)
mysql> Aborted
------------------------------
mysql> select * from grade;
+----+--------+-------+
| id | name | grade |
+----+--------+-------+
| 1 | 张三 | A |
| 2 | 李四 | B |
| 3 | 王五 | D |
| 4 | 赵六 | A |
| 5 | 田七 | C |
+----+--------+-------+
5 rows in set (0.00 sec)
所以手动开启的一个事务,系统是不会主动自动提交的,这符合真实场景,客户端崩溃之前的数据的真实性未知。
- 单挑
SQL
语句会遵循系统自动提交的,因为之前这么写的时候是成功的。
1.4.3 结论
- 只要输入
begin
或者start transaction
开始一个事务,便必须要通过commit
提交,才会持久化,与是否设置autocommit
无关。 - 事务可以手动回滚,回滚到设置的保存点,或者默认回滚到开始事务的时候,回滚的前提是没有提交。
- 当操作异常,
MySQL
会自动回滚。 - 对于
InnoDB
每一条SQL
语言都默认封装成事务,自动提交。(select
有特殊情况,因为MySQL
有MVCC
)。 - 从上面的例子,我们能看到事务本身的原子性(
rollback
),持久性(commit
)。
二、事务的隔离级别
- 事务的原子性是对应用层来说的,对应事务本身,仍然有执行的前后顺序。
MySQL
服务可能同时被多个客户端访问,那么对表的修改就存在竞争和互斥。- 事务的隔离性就是将多个同时对某一张表访问的事务分割开来,保证事务并行的高效和数据的安全。
2.1 隔离级别
为了给用户层提供不同场景下的隔离强度(事务直接互相干扰的程度),MySQL提供了四个隔离级别。
- 读未提交
read uncommitted
所有的事务,都可以看到其它事务,没有提交的执行结果。相当于没有任何隔离性,带来的后果就是脏读(读取未提交数据)、幻读(前后多次读取,数据总量不一致)、不可重复读等问题(前后多次读取,数据内容不一致)。 - 读提交
read committed
一个事务只能看到其它已经提交的事务。这种隔离级别解决了脏读,但是幻读和不可重复读没解决。 - 可重复读
repeatable read
InnoDB
默认隔离级别。确保一个事务在执行中,多次读取数据时,会看到同样的数据(比如,事务A提交,事务B在执行中,不能看到A提交的内容,即在任意时刻,select看到的内容都是一样的)。 - 串行化
serializable
这是事务的最高隔离级别,它通过强制事务排序,使之不可能相互冲突,从而解决了幻读的问题。它在每个读的数据行上面加上共享锁。但是可能会导致超时和锁竞争(这种隔离级别太极端,实际生产基本不使用)。
隔离,基本都是通过锁实现的,不同的隔离级别,锁的使用是不同的。常见有,表锁,行锁,读锁,写锁,间隙锁(GAP),Next-Key锁(GAP+行锁)等。
2.2 脏读、不可重复读、幻读
- 脏读:就是读取到其他事务未提交的数据
- 不可重复读:同一个事务内,同样的读取,在不同的时间段,读取到了不同的数据。
- 幻读:在可重复读中,该sql第一次读取到数据后,就将这些数据加锁,其它事务无法修改这些数据,就可以实现可重复读了。但这种方法却无法锁住insert的数据(因为隔离性实现是对数据加锁完成的,而insert待插入的数据因为并不存在,那么一般加锁无法屏蔽这类问题),所以当事务A先前读取了数据,或者修改了全部数据,事务B还是可以insert数据提交,这时事务A就会发现莫名其妙多了一条之前没有的数据,这就是幻读,不能通过行锁来避免。MySQL在RR级别的时候,是解决了幻读问题的(解决的方式是用Next-Key锁(GAP+行锁)解决的。
但不可重复读重点在于update和delete,而幻读的重点在于insert。
2.3 查看和设置隔离级别
修改全局隔离级别以后,其他客户端要重启才能生效。
- 查看全局隔离级别:
select @@global.tx_isolation;
- 查看当前会话隔离级别:
select @@session.tx_isolation;
- 设置全局隔离级别,另外一个对话也会被影响:
set global transaction isolation level 隔离级别名称;
- 设置当前会话隔离级别,只会影响当前会话:
set session transaction isolation level 隔离级别名称;
mysql> select @@global.tx_isolation;
+-----------------------+
| @@global.tx_isolation |
+-----------------------+
| REPEATABLE-READ | -- 默认为可重复读
+-----------------------+
1 row in set, 1 warning (0.00 sec)
mysql> set global transaction isolation level read uncommitted;
Query OK, 0 rows affected (0.00 sec)
mysql> select @@global.tx_isolation;
+-----------------------+
| @@global.tx_isolation |
+-----------------------+
| READ-UNCOMMITTED |
+-----------------------+
1 row in set, 1 warning (0.00 sec)
隔离级别 | 脏读 | 不可重复读 | 幻读 | 加锁读 |
---|---|---|---|---|
读未提交(read uncommitted) | 未解决 | 未解决 | 未解决 | 不加锁 |
读提交(read committed) | 解决 | 未解决 | 未解决 | 不加锁 |
可重复读(repeatable read) | 解决 | 解决 | 未解决(部分数据库解决,如MySQL) | 不加锁 |
串行化(serializable) 解决 | 解决 | 解决 | 加锁 |
串行化级别:
- 只允许一个事务在执行修改操作
- 虽然读也是加锁的,但是可以多个事务读(一个事务读完,另一个事务再读)。
三、多版本并发控制( MVCC )
数据库并发的场景有三种:
- 读-读 :不存在任何问题,也不需要并发控制。
- 读-写 :有线程安全问题,可能会造成事务隔离性问题,可能遇到脏读,幻读,不可重复读。
- 写-写 :有线程安全问题,可能会存在更新丢失问题,比如第一类更新丢失,第二类更新丢失。
丢失更新:就是两个事务在并发下同时进行更新,后一个事务的更新覆盖了前一个事务更新的情况,丢失更新是数据没有保证一致性导致的。
3.1 解决读写冲突
多版本并发控制( MVCC )是一种用来解决 读-写冲突 的无锁并发控制
- 为事务分配单向增长的事务ID,为每个修改保存一个版本,版本与事务ID关联,读操作只读该事务开始前的数据库的快照(类似一个备份)。 事务ID决定了事务开始先后顺序。
所以 MVCC 可以为数据库解决以下问题
- 在并发读写数据库时,可以做到在读操作时不用阻塞写操作,写操作也不用阻塞读操作,提高了数据库并发读写的性能
- 同时还可以解决脏读,幻读,不可重复读等事务隔离问题,但不能解决更新丢失问题
MVCC需要三个关键的前提条件:
- 3个记录隐藏字段
- undo 日志
- Read View
3.2 三个隐藏字段
MySQL每个表都具有隐藏字段:
DB_TRX_ID
:6 byte,最近修改( 修改/插入 )事务ID,记录创建这条记录/最后一次修改该记录的事务IDDB_ROLL_PTR
: 7 byte,回滚指针,指向这条记录的上一个版本(简单理解成,指向历史版本就行,这些数据一般在undo log
中)DB_ROW_ID
: 6 byte,隐含的自增ID(隐藏主键),如果数据表没有主键, InnoDB 会自动以DB_ROW_ID
产生一个聚簇索引
补充:实际还有一个删除flag
隐藏字段, 既记录被更新或删除并不代表真的删除,而是删除flag
被改变了。
3.3 undo 日志
MySQL 将来是以服务进程的方式,在内存中运行。索引,事务,隔离性,日志等,都是在内存中完成的,即在 MySQL 内部的相关缓冲区中,保存相关数据,完成各种判断操作。然后在合适的时候,将相关数据刷新到磁盘当中的。
所以,我们这里理解undo log,简单理解成,就是 MySQL 中的一段内存缓冲区,用来保存日志数据的就行。
3.4 模拟 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提交,释放锁。
undo log里面的都是历史数据。
现在又有一个事务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提交,释放锁。
这样,我们就有了一个基于链表记录的历史版本链。所谓的回滚,无非就是用历史数据,覆盖当前数据。
上面的一个一个版本,我们可以称之为一个一个的快照。
其他操作
delete
,将flag
位标记为删除,将原来的加入undo log
。insert
,插入之前是没有的,那么也需要往undo log
插入一条表示空的数据。select
,是比较特殊的,它不会修改数据,维护多个版本是没有意义的,需要解决的是脏读、不可重复读、幻读等问题。
所以读有两种读法:
- 当前读:读取最新的记录,就是当前读。增删改,都叫做当前读,select也有可能当前读,比如:
select lock in share mode
(共享锁),select for update
- 快照读:读取历史版本(一般而言),就叫做快照读。
- select读取历史的哪一个版本,由隔离级别决定。
- 在多个事务同时删改查的时候,都是当前读,是要加锁的。那同时有select过来,如果也要读取最新版(当前读),那么也就需要加锁,这就是串行化。
- 如果是快照读,读取的是历史版本,是不受锁的限制的,也就是可以并发执行,提高程序的效率,这就是MVCC的意义所在。
那么,如何保证,不同的事务,看到不同的内容呢?也就是如何如何实现隔离级别? 这就需要read view
了。
3.5 Read View
Read View
就是事务进行快照读
操作的时候生产的读视图 (Read View)
,在该事务执行的快照读的那一刻,会生成数据库系统当前的一个快照,记录并维护系统当前活跃事务的ID(当每个事务开启时,都会被分配一个ID, 这个ID是递增的,所以最新的事务,ID值越大)。
Read View
在 MySQL 源码中,就是一个类,本质是用来进行可见性判断的。 即当我们某个事务执行快照读的时候,对
该记录创建一个Read View 读视图
,把它比作条件,用来判断当前事务能够看到哪个版本的数据,既可能是当前最新的数据,也有可能是该行记录的 undo log 里面的某个版本的数据。
Read View的基本结构
class ReadView {
// 省略...
private:
/** 高水位,大于等于这个ID的事务均不可见*/
trx_id_t m_low_limit_id
/** 低水位:小于这个ID的事务均可见 */
trx_id_t m_up_limit_id;
/** 创建该 Read View 的事务ID*/
trx_id_t m_creator_trx_id;
/** 创建视图时的活跃事务id列表*/
ids_t m_ids;
/** 配合purge,标识该视图不需要小于m_low_limit_no的UNDO LOG,
* 如果其他视图也不需要,则可以删除小于m_low_limit_no的UNDO LOG*/
trx_id_t m_low_limit_no;
/** 标记视图是否被关闭*/
bool m_closed;
// 省略...
};
m_ids; //一张列表,用来维护Read View生成时刻,系统正活跃的事务ID
up_limit_id; //记录m_ids列表中事务ID最小的ID
low_limit_id; //ReadView生成时刻系统尚未分配的下一个事务ID,也就是目前已出现过的事务ID的最大值+1
creator_trx_id //创建该ReadView的事务ID
在实际读取数据版本链的时候,是能读取到每一个版本对应的事务ID的,即:当前记录的 DB_TRX_ID 。
- 事务ID就类似于时间,时间一直是在增长的,在某一个时间点最多只能看到之前的东西,而自己生活的时间段,也只能看到已经发生的事情,也就是
Read View
里面的事务ID表,正在发生的事情是无法看到的。 - 拿到某个历史版本,就可以知道他的事务ID,然后再自己的事务列表查找,这样就可以有效隔离非法读取。
四、RR和RC的本质区别
RR是可以重复读的,RC不可以。这是为什么呢?
读命令:
- 快照读:正常读
select * from 表名
- 当前读:读最新的
select * from 表名 lock in share mode
下面使用隔离级别为RR做测试
表一:
在A修改之前B已经读过一次了,更改为18后,当前读可以读到最新数据18,快照读没有。
表二:
在A修改之前B没有读,更改为18后,当前读、快照读都可以读到最新数据28。
测试结论:
- 事务中快照读的结果是非常依赖该事务首次出现快照读的地方,即某个事务中首次出现快照读,决定该事务后续快照读结果的能力
- delete同样如此
总结
- 从上面可以看出,RR级别下,
ReadView
是在第一次快照读的时候生成,后面使用的都是同一个ReadView
,这就是可以重复的读原因。 - RC级别下,不可重复读,是因为每次快照读,
ReadView
都会更新,所以读到的数据可能不一样。