1、简介
事务是一组原子性的SQL查询,或者说一个独立的工作单元。事务将数据库从一种一致状态转换为另一种一致状态,事务内的语句,要么全部执行成功,要么全部执行失败。
2、事务的ACID概念
A: 原子性(automicity)
一个事务必须被视为一个不可分割的最小单元,整个事务中所有的操作要么全部提交成功,要么全部失败回滚。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21问题:
对于单核单进程单个指令操作需要考虑事务的原子性吗?
理解:
首先我们知道,在计算机中的大概分层如下,其次数据库的数据实际是存储在磁盘上,因此,针对单条指令,如insert,插入一条数
据到数据库,实际上是由应用层/CPU(针对不同的编程语言可能会不同,如C语言可编译为二进制形式直接在CPU上运行,而PHP等
语言需要通过OS)执行代码将需要的数据存储到磁盘中,此时若出现操作系统崩溃或者异常掉电的情况,则可能出现数据不一致的情况.
结论:即使单核单进程单条指令操作也是需要考虑事务的原子性.
应用层
-------
OS层
-------
CPU
-------
内存
-------
磁盘
-------C: 一致性(consistency)
数据库总是从一个一致性的状态转换到另一个一致性的状态。保证数据库的完整性约束没有被破坏.1
2
3
4
5
6
7
8
9
10
11
12
13例如:
在表t中有一个字段id,为唯一约束,即在表中id不能重复.假设当前表t中有一条id=1的记录,此时事务A对id=1的记录进行了删
除但尚未提交,事务B又创建了一个id=1的记录并提交,事务A回滚,若没有一致性约束,此时表t中存在两条id=1的记录,违反了id为唯一约束.
Trans A Trans B
Start T
delete id =1
Start T
insert id=1
Commit T
Rollback TI: 隔离性(isolation)
通常来说,一个事务所做的修改在最终提交前,对其他事务是不可见的。(与隔离级别有关)通常通过锁来实现,当前数据库系统中都提供了一种粒度锁(granular lock)的策略,允许事务仅锁住一个实体对象的子集,以此来提高事务之间的并发度.
D: 持久性(durability)
一旦事务提交,则其所作的修改就会永久保存到数据库中。
3、隔离级别
在SQL标准中定义了四种隔离级别,每一种级别都规定了一个事务中所做的修改,哪些是在事务内和事务间是可见的,哪些是不可见的。
(不同事物的隔离级别,实际上是一致性与并发性的一个权衡与折中。)
3.1 READ UNCOMMITTED(未提交读)(不推荐)
事务中的修改,即使没有提交,对其他事务也是可见的。
- 优点:性能上比另外三种级别好很多(并发性高)。
- 缺点:读取到未提交的数据,出现脏读。且缺乏其他级别的好处(
一致性最差)。
3.1.1 InnoDB的实现方式
InnoDB在此种事务隔离级别下,select语句不加锁(官方文档原文:SELECT statements are performed in a nonlocking fashion.)。
3.2 READ COMMITTED(提交读)(别名:不可重复读)
事务开始直到提交之前,所做的任何修改对其他事务都是不可见的。
- 缺点:不可重复读,当两次执行同样的查询,可能会得到不一样的结果。
3.2.1 InnoDB的实现方式
- 普通读是快照读;
- 加锁的select,update,delete等语句,除了在外面约束检查以及重复键检查时会封锁区间,其他时刻都只使用记录锁;此时其他事务的插入依旧可以执行,就可能导致幻读。
3.3 REPEATABLE READ(可重复读)——Mysql的默认事务隔离级别
该级别解决了脏读的问题,且保证同一个事务中多次读取同样的记录结果是一致的。
- 缺点:可能会出现幻读,即当某个事务在读取某个范围内的记录时,另外一个事务又在该范围插入了新的记录,就会产生幻行。
- 解决: InnoDB和XtraDB通过
多版本并发控制(MVCC, Multiversion Concurrency Control)解决了上述缺点。
3.3.1 InnoDB的实现方式
RR隔离级别为InnoDB的默认隔离级别。
①、普通的select使用快照读(snapshot read),这是一种不加锁的一致性读(Consistent Nonlocking Read),底层使用MVCC来实现。
②、加锁的select(select … in share mode/select … fro update),update,delete等语句,他们的锁,依赖于他们是否在唯一索引上使用了唯一的查询条件,或者范围查询条件:
- 在唯一索引上使用唯一的查询条件,会使用
记录锁(record lock),而不会封锁记录之间的间隔,即不会使用间隙锁(gap lock)和临键锁(next-key lock)。 - 范围查询条件,会使用
间隙锁与临键锁,锁住索引记录之间的范围,避免范围间插入记录,以避免产生幻行记录,及避免不可重读的读。
3.4 SERIALIZABLE(可串行化)
强制事务串行执行,避免可重复读的幻读问题。
- 有点:一致性最好
- 缺点:该级别会在读取的每一行数据加锁,可能导致大量的超时和锁争用问题(并发性最差)。
3.4.1 InnoDB的实现方式
这种事务的隔离级别下,所有的select语句都会被隐式的转化为select … in share mode.
这可能导致,如果有未提交的事务正在修改某些行,所有读取这些行的select都会被阻塞(官方文档原文:To force a plain SELECT to block if other transactions have modified the selected rows.)。
关于“脏读”,“不可重复读”,“幻读”示例:
1 | 假设有InnoDB表: |
脏读
脏读指的是在不同事务下,当前事务可以读到另外事务未提交的数据.
1 | 事务A,先执行,处于未提交的状态: |
不可重复读
不可重复读是指在一个事务中多次读取同一数据集合,在这个事务还没结束时,另一个事务也访问了该数据集合,并做了DML操作.导致第一个事务多次读取到的数据不一样.
1 | 事务A,先执行: |
- 幻读
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19事务A,先执行:
select * from t where id>3;
结果集为:
NULL
事务B,后执行,并且提交:
insert into t values(4, wangwu);
commit;
事务A,首次查询了id>3的结果为NULL,于是想插入一条为4的记录:
insert into t values(4, xxoo);
结果集为:
Error : duplicate key!
事务A的内心OS是:你TM在逗我,查了id>3为空集,insert id=4告诉我PK冲突?
这次是已提交事务B对事务A产生的影响,这个影响叫做“幻读”。
4、死锁
死锁是指两个或多个事务在同一资源上相互占用,并请求锁定对方占用的资源,从而导致恶性循环的现象。
解决方式:
- ①、数据库系统实现了各种死锁检测和死锁超时机制。
(InnoDB处理方式:将持有最少行级排它锁的事务进行回滚) - ②、当查询的时间达到锁等待超时的设定后当其锁请求。
示例:
1 | 事务A: |
5、MySQL中的事务
Mysql提供了两种事务型存储引擎:InnoDB和NDB Cluster。
设置自动提交模式
Mysql默认采用自动提交模式,即如果不是显式地开始一个事务,每个查询都被当做一个事务执行提交操作。更改自动提交的命令如下:1
SET AUTOCOMMIT = 1;(0:启用,1:禁用)
设置隔离级别
Mysql能识别所有的4个ANSI隔离级别,InnoDB也支持所有隔离级别。
命令示例:1
SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED
Mysql中的事务是由下层的存储引擎实现的,同一个事务中使用多种存储引擎是不可靠的。
6、多版本并发控制(MVCC)
MVCC可以被认为是行级锁的一个变种,但他在很多情况下避免了加锁操作,因此开销更低。
MVCC的实现:通过保存数据在某个时间点的快照来实现的。
典型的存储引擎的MVCC实现:
- 乐观并发控制(optimistic)
- 悲观并发控制(pessimistic)
6.1 InnoDB的MVCC的实现
InnoDB的MVCC是通过在每行记录后面保存两个隐藏的列来实现的。这两个列一个保存了行的创建时间(系统版本号),一个保存行的过期时间(删除时间)(系统版本号)。每开始一个新的事务,系统版本号都会自动递增。事务开始时的系统版本号作为事务的版本号,用来和查询到的每行记录的版本号进行比较。
REPEATABLE READ隔离级别下,MVCC具体操作:
SELECT
InnoDB会根据以下两个条件检索:- a、InnoDB查找行的系统版本号小于/等于事务的系统版本号,确保事务读取的行,要么在事务开始前已经存在,要么是事务自身插入或修改过的。
- b、行的删除版本要么未定义,要么大于当前事务版本号,确保事务读取到的行,在事务开始前未被删除。
INSERT
InnoDB为新插入的每一行保存当前系统版本号作为行版本号。DELETE
InnoDB为删除的每一行保存当前系统版本号作为行删除标识。UPDATE
InnoDB为新插入的每一行保存当前系统版本号作为行版本号,同时保存当前系统版本号到原来的行作为删除标识。
注:MVCC只在REPEATABLE READ和READ COMMITTED两个隔离级别下工作。(READ UNCOMMITTED总是读取最新的数据行,而SERIALIZABLE则会对所有读取的行都加锁)
7、Mysql终端模拟并发事务
7.1 几个关键的配置
要测试InnoDB的锁互斥,以及死锁,有几个配置需要提前确认:
- ①、区间锁(间隙锁,临键锁)是否关闭
- ②、事务自动提交(auto commit)是否关闭
- ③、事务的隔离级别
7.1.1 区间锁是否关闭
区间锁(间隙锁,临键锁)是InnoDB特有施加在索引记录区间的锁,Mysql5.6可以手动关闭区间锁。
控制参数:
1 | innodb_locks_unsafe_for_binlog |
该参数支持两个值:
- ON: 表示关闭区间锁,此时一致性会被破坏(所以是unsafe)
- OFF:表示开启区间锁(默认)
查询该参数的方法:
1 | mysql> show variables like 'innodb_locks%'; |
修改方法:修改配置文件(/etc/mysql/mysql.conf.d/mysqld.cnf)文件,添加更改选项并重新启动服务:
1 | innodb_locks_unsafe_for_binlog = 1 |
1 | sudo service mysql restart |
7.1.2 事务自动提交
Mysql默认把每一个单独的SQL语句作为一个事务,自动提交。
控制参数:
1 | autocommit |
MySQL5.7默认开启
1 | mysql> show variables like 'autocommit'; |
设置(关闭)方式:
1 | set session autocommit=0; |
7.1.3 事务的隔离级别
不同事务的隔离级别,InnoDB的锁实现是不一样的。默认为repeatable read。
控制参数:
1 | tx_isolation |
查询事务的隔离级别的方法:
1 | mysql> show global variables like 'tx_isolation'; |
设置事务的隔离级别的方式:
1 | set session transaction isolation level <X>'; |
其中X可取值为:
- read uncommitted
- read committed
- repeatable read
- serializable
7.2 模拟并发事务前准备工作
- ①、配置准备
要模拟并发事务,需要修改事务自动提交这个选项,每个session要改为手动提交。
1 | mysql> set session autocommit=0; |
②、数据准备
InnoDB的行锁都是实现在索引上,实验时可以使用主键,建表时设定为innodb引擎:建表
1
mysql> create table t_id_pk( id int(10) primary key) engine=innodb;
准备初始数据
1
mysql> insert into t_id_pk(id) values (1),(3),(10);
7.3 实验
7.3.1 间隙锁互斥实验
开启区间锁,RR的隔离级别下,上例会有以下四个区间:
- (-infinity, 1)
- (1, 3)
- (3, 10)
- (10, infinity)
事务A删除某个某个区间不存在的记录,获取到共享间隙锁,会阻止其他事务B在相应的区间插入数据,因为插入需要获取排他间隙锁。
sessionA:
1 | mysql> set autocommit=0; |
sessionB:
1 | mysql> set autocommit=0; |
事务B插入的值:0,2,12都不在(3, 10)区间内,能够插入,而7在(3, 10)这个区间,会阻塞。若事务A一直不提交,事务B就会一直等待,直到超时,超时后会有如下提示:
1 | ERROR 1205 (HY000): Lock wait timeout exceeded; try restarting transaction |
可以使用以下命令查看锁的情况:
1 | show engine innodb status; |
当前实验锁部分情况如下:
1 | TRANSACTIONS |
可知insert into t_id_pk(id)values(7)正在等待事务A提交或回滚,这样事务B就能获得相应的锁,以继续执行。
7.3.2 共享排它锁的死锁实验
该实验需要三个并发的session。
sessionA:
1 | mysql> start transaction; |
sessionB:
1 | mysql> start transaction; |
sessionC:
1 | mysql> set autocommit=0; |
此时三个事务都试图往表中插入一条为7的记录:
- A先执行,插入成功,并获取id=7的
排它锁; - B后执行,需要进行PK校验,故需要先获取id=7的
共享锁,阻塞; - C后执行,也需要进行PK校验,也要先获取id=7的
共享锁,阻塞;
此时若sessionA执行:
1 | mysql> rollback; |
id=7的排它锁释放,则B,C会继续进行主键校验:
- ①、B会获取到id=7共享锁,主键未互斥;
- ②、C也会获取到id=7共享锁,主键未互斥;
B和C要想插入成功,必须获得id=7的排他锁,但由于双方都已经获取到id=7的共享锁,它们都无法获取到彼此的排他锁,死锁就出现了。
InnoDB有死锁检测机制,B和C中的一个事务会插入成功,另一个会自动放弃:
sessionB:
1 | mysql> insert into t_id_pk(id) values(7); |
sessionC:
1 | mysql> insert into t_id_pk(id) values(7); |
7.3.3 并发间隙锁的死锁
共享排它锁,在并发量插入相同记录的情况下,相应的案例比较容易分析,而并发的间隙锁死锁,是比较难定位的。
1 | A:set session autocommit=0; |
sessionA:
1 | mysql> start transaction; |
sessionB:
1 | mysql> start transaction; |
- A执行delete后,会获得(3, 10)的共享间隙锁。
- B执行delete后,也会获得(3, 10)的共享间隙锁。
- A执行insert后,希望获得(3, 10)的排他间隙锁,于是会阻塞。
- B执行insert后,也希望获得(3, 10)的排他间隙锁,于是死锁出现。
使用show engine innodb status查看死锁的情况:
1 | 2019-05-14 15:05:35 0x7f9fc003d700 |
从事务锁的情况可以看出,当检测到死锁后,事务2自动回滚了。
总结:
- ①、并发事务,间隙锁可能互斥;
- a. A删除不存在的记录,获取共享间隙锁;
- b. B插入,必须获得排他间隙锁,故互斥;
- ②、并发插入相同的记录,可能死锁(某一个回滚);
- ③、并发插入,可能出现间隙锁死锁(难排查)。
参考资料
- MySQL第三版