MySQL的事务和锁
阅读目录
什么是事务
事务:是数据库操作的最小工作单元,是作为单个逻辑工作单元执行的一系列操作;这些操作作为一个整体一
起向系统提交,要么都执行、要么都不执行;事务是一组不可再分割的操作集合(工作逻辑单元);
事务的简单操作
显式启动事务语句,begin或者start transaction;
提交commit;
回滚rollback;
SET AUTOCOMMIT=0 禁止自动提交
SET AUTOCOMMIT=1 开启自动提交
事务的四大特性(ACID)
- 原子性(Atomicity):事务一个事务(transaction)中的所有操作,要么全部完成,要么全部不完成,不会结束在中间某个环节。事务在执行过程中发生错误,会被回滚(Rollback)到事务开始前的状态,就像这个事务从来没有执行过一样。
- 一致性(Consistency):一致性是指事务从一种一致性状态转变成另一种一致性状态。在事务开始之前和事务结束之后,数据库的完整性约束没有破坏。即:A和B一共5000元,无论双方进行多少次转账,他们的总和都只能是5000元。或者说表中有一个字段为name,为唯一约束,即在表中姓名不能重复。如果一个事务对name字段进行了修改,但是事务提交或者事务操作发生回滚后,表中的姓名变得非唯一了,这就破坏了事务的一致性要求。因此,事务是一致性单元,如果事务中某个动作失败了,系统可以自动撤销事务,返回初始状态。
- 隔离性(Ioslation):隔离性是当多个用户并发访问数据库时,比如操作同一张表时,数据库为每一个用户开启的事务,不能被其他事务的操作所干扰,多个并发事务之间要相互隔离。即该事务提交前对其他事物都不可见。通过锁或者MVCC实现,MVCC(多版本并发控制)在可重复读的位置举例介绍。
- 持久性(Durability):持久性是指一个事务一旦被提交了,那么对数据库中的数据的改变就是永久性的,即便是在数据库系统遇到故障的情况下也不会丢失提交事务的操作。
事务的隔离性通过锁和MVCC机制实现,原子性、一致性和持久性通过redo/undo log 来完成。redo log 称为重做日志,用来保证事务的原子性和持久性。undo log 称为撤销日志,用来保证事务的一致性。
redo log/undo log
redo log
redo log重做日志,用来保证事务的原子性和持久性。由两部分组成:一是内存中的重做日志缓冲(redo log buffer),其是易失的;二是重做日志文件,其是持久的。InnoDB存储引擎当事务提交时,必须先将该事务的所有日志写入重做日志进行持久化,待事务的commit操作完成才算完成。当数据库挂了之后,通过扫描redo日志,就能找出那些没有刷盘的数据页(在崩溃之前可能数据页仅仅在内存中修改了,但是还没来得及写盘),保证数据不丢。
由于重做日志文件打开并没有使用O_DIRECT选项,因此重做日志缓冲先写入文件缓存系统。为了确保每次日志都写入重做日志文件,在每次将重做日志缓冲写入重做日志后,InnoDB存储引擎都需要调用一次fsync操作。
O_DIRECT在执行磁盘IO时绕过缓冲区高速缓存,从用户空间直接将数据传递到文件或磁盘设备,称为直接IO(direct IO)或者裸IO(raw IO)。
fsync函数的功能是确保文件所有已修改的内容已经正确同步到硬盘上,该调用会阻塞等待直到设备报告IO完成。
事务更新数据操作流程:
1.当事务执行更新数据的操作时,会先从mysql中读取出数据到内存中,然后对内存中数据进行修改操作。
2.生成一条重做日志并写入redo log buffer,记录的是数据被修改后的值。
3.定期将内存中修改的数据刷新到磁盘中,这是由innodb_flush_log_at_trx_commit决定的,重做日志文件打开并没有使用O_DIRECT选项,因此重做日志缓冲先写入文件缓存系统,最后通过执行fsync将数据写入磁盘。
- 当设置该值为 1 时,每次事务提交都要做一次 fsync,这是最安全的配置,即使宕机也不会丢失事务;
- 当设置为 2 时,则在事务提交时只做 write 操作,只保证写到文件系统的缓存,不进行fsync操作。因此mysql数据库发生宕机而操作系统不发生宕机时不会丢失数据。操作系统宕机会丢失文件系统缓存中未刷新到重做日志中的事务;
- 当设置为 0 时,事务提交不会触发 redo 写操作,而是留给后台线程每秒一次的fsync操作,因此数据库宕机将最多丢失一秒钟内的事务。
4.commit提交后数据写入redo log file中,然后将数据写入到数据库。
undo log
Undo log是InnoDB MVCC事务特性的重要组成部分。当我们对记录做了变更操作时就会产生undo记录,Undo记录默认被记录到系统表空间(ibdata)中,但从5.6开始,也可以使用独立的Undo 表空间。
在Innodb当中,INSERT操作在事务提交前只对当前事务可见,Undo log在事务提交后即会被删除,因为新插入的数据没有历史版本,所以无需维护Undo log。而对于UPDATE、DELETE,责需要维护多版本信息。 在InnoDB当中,UPDATE和DELETE操作产生的Undo log都属于同一类型:update_undo。(update可以视为insert新数据到原位置,delete旧数据,undo log暂时保留旧数据)。
Session1(以下简称S1)和Session2(以下简称S2)同时访问(不一定同时发起,但S1和S2事务有重叠)同一数据A,S1想要将数据A修改为数据B,S2想要读取数据A的数据。没有MVCC只能依赖加锁了,谁拥有锁谁先执行,另一个等待。但是高并发下效率很低。InnoDB存储引擎通过多版本控制的方式来读取当前执行时间数据库中行的数据,如果读取的行正在执行DELETE或UPDATE操作,这是读取操作不会因此等待行上锁的释放。相反的,InnoDB会去读取行的一个快照数据(Undo log)。在InnoDB当中,要对一条数据进行处理,会先看这条数据的版本号是否大于自身事务版本(非RU隔离级别下当前事务发生之后的事务对当前事务来说是不可见的),如果大于,则从历史快照(undo log链)中获取旧版本数据,来保证数据一致性。而由于历史版本数据存放在undo页当中,对数据修改所加的锁对于undo页没有影响,所以不会影响用户对历史数据的读,从而达到非一致性锁定读,提高并发性能。
另外,如果出现了错误或者用户手动执行了rollback,系统可以利用undo log中的备份将数据恢复到事务开始之前的状态。与redo log不同的是,磁盘上不存在单独的undo log 文件,他存放在数据库内部的特殊段(segment)中。
事务的隔离等级
mysql默认的隔离等级是可重复读,如果想要在mysql启动时就修改mysql的隔离等级,需要修改配置文件,在[mysqld]中添加如下内容:
[mysqld] transaction-isolation = READ-COMMITTED
如果想要查看当前事务隔离级别,可以使用:
mysql>select @@tx_isolation\G;
脏读:事务A读取了事务B更新、未提交的数据,然后B回滚操作,那么A读取到的数据是脏数据(没有用的数据)。
不可重复读:事务 B 在事务A多次读取的过程中,对数据作了更新操作并提交,导致事务A两次读取同一数据不一致。主要针对数据更新的。
幻读:同一事务中,两次按相同条件查询到的记录不一样。造成幻读的原因在于事务处理没有结束时,其他事务对同一数据集合增加或删除了记录。在mysql中MVCC在一定程度上解决了幻读,但并没有完全解决。如下:
事务在插入已经检查过不存在的记录时,插入失败,出现冲突显示这条数据已经存在了。比如:A查询id为2的数据不存在则插入一条id为2的数据,在事务A查询完毕后,事务B插入了一条id为2的数据,并提交了。此时事务A向表中插入id为2的数据插入失败。第二次的insert其实也属于隐式的读取,只不过是在mysql的机制中读取的,插入数据也是要先读取一下有没有主键冲突才能决定是否执行插入。错误提示如下:
Duplicate entry 2 for key 'id' # 关键字id的重复条目2
注意:不可重复读和幻读很容易混淆,不可重复读侧重于修改,幻读侧重于新增或删除。
读未提交
最低级别,任何情况都无法保证,可能造成脏读、幻读、不可重复读,效率最高,但最不安全。
读提交
可避免脏读的发生。
可重复读
可避免脏读、不可重复读的发生。(mysql默认的级别)
可重复读就是在一个事务内,对于同一个查询请求,多次执行,获取到的数据集是一样的。这一般是通过保存事务的快照实现的。MVCC会保存某个时间点上的快照,意味着事务可以看到一个一致性的状态。但是不同事务在同一个时间点上看到同一个表中的数据可能是不同的。
串行化
可避免脏读、不可重复读、幻读的发生。但是效率最低。事务的最高级别,在每个读的数据行上,加上锁,使之不可能相互冲突。如果有事务对该数据行操作,那么其他事务就要等他结束才能进行操作。
MVCC
可重复读使用的是一种叫MVCC的控制方式 ,即Mutil-Version Concurrency Control,多版本并发控制。InnoDB在每行记录后面保存两个隐藏的列来,分别保存了这个行的创建时间和行的删除时间。这里存储的并不是实际的时间值,而是系统版本号。每开启一个新事务,事务的版本号就会递增。所以增删改查中对版本号的作用如下:
insert:把当前系统(事务)版本号作为行记录的版本号。
创建一个事务,ID为1,插入两条数据。
begin; insert into user(name) values('xiaoqi'); insert into user(name) values('dada'); commit;
id | name | 创建时间(事务ID) | 删除时间(事务ID) |
---|---|---|---|
1 | xiaoqi | 1 | undefined |
2 | dada | 1 | undefined |
select:事务每次只能读到行记录的版本号小于等于此次系统版本号的记录,这样可以确保事务读取的行,要么是在事务开始前已经存在的,要么是事务自身插入或者修改过的。在事务自身执行过程中,不会读取到其他事务进行的操作。
行的删除版本要么未定义,要么大于当前事务版本号(这可以确保事务读取到的行,在事务开始之前未被删除), 只有条件1、2同时满足的记录,才能返回作为查询结果。
delete:把当前系统版本号作为行记录的删除版本号。
创建第二个事务,ID为2,进行删除处理。
begin; select * from user; # 执行事务2的第一步 select * from user; # 执行事务2的第二步 commit;
假设1:在执行ID为2的事务第一步的时候,有另一个事务ID为3往这个表里插入了一条数据;
创建第三个事务,ID为3
begin; insert into user(name) values('jianren'); commit;
id | name | 创建时间(事务ID) | 删除时间(事务ID) |
---|---|---|---|
1 | xiaoqi | 1 | undefined |
2 | dada | 1 | undefined |
3 | jianren | 3 | undefined |
然后,继续执行ID为2的事务的第二步,由于id=3的数据的创建时间(事务ID为3),执行当前事务的ID为2,而InnoDB只会查找事务ID小于等于当前事务ID的数据行,所以id=3的数据行并不会在执行事务2中的第二步被检索出来,在事务2中的两条select 语句检索出来的数据均如下表:
id | name | 创建时间(事务ID) | 删除时间(事务ID) |
---|---|---|---|
1 | xiaoqi | 1 | undefined |
2 | dada | 1 | undefined |
假设2:在执行ID为2的事务第一步之后,假设执行完ID为3的事务后,接着又执行了ID为4的事务。
创建第四个事务,ID为4
begin; delete from user where id=1; commit;
此时数据库中表如下:
id | name | 创建时间(事务ID) | 删除时间(事务ID) |
---|---|---|---|
1 | xiaoqi | 1 | 4 |
2 | dada | 1 | undefined |
3 | jianren | 3 | undefined |
然后执行ID为2的事务的第二步。根据SELECT 检索条件可以知道,它会检索创建时间(创建事务的ID)小于当前事务ID的行和删除时间(删除事务的ID)大于当前事务ID的行,而id=3的行上面已经说过,而id=1的行由于删除时间(删除事务的ID)大于当前事务的ID,所以事务2的第二步select * from user;也会把id=1的数据检索出来。最终,事务2中的两条select 语句检索出来的数据都如下:
id | name | 创建时间(事务ID) | 删除时间(事务ID) |
---|---|---|---|
1 | xiaoqi | 1 | 4 |
2 | dada | 1 | undefined |
update:插入一条新记录,并把当前事务版本号作为行记录的版本号,同时保存当前系统版本号到原有的行作为删除版本号。
假设3:假设在执行完事务2的第一步后,其它用户执行了事务3、4,这时,又有一个用户对这张表执行了UPDATE操作,创建了第五个事务,ID为5。
第五个事务,ID为5
begin; update user set name='dazi' where id=2; commit;
此时数据库中表如下:
id | name | 创建时间(事务ID) | 删除时间(事务ID) |
---|---|---|---|
1 | xiaoqi | 1 | 4 |
2 | dada | 1 | 5 |
3 | jianren | 3 | undefined |
2 | dazi | 5 | undefined |
继续执行事务2的第二步,根据select 语句的检索条件,得到下表:
id | name | 创建时间(事务ID) | 删除时间(事务ID) |
---|---|---|---|
1 | xiaoqi | 1 | 4 |
2 | dada | 1 | 5 |
还是和事务2中第一步select 得到相同的结果。
这就是mysql中事务的可重复读,即一个事务执行后,无论其他事务对其进行怎样的修改读到的数据都是一样的,也就是可以重复读多少次结果都一样。不过这就可能出现同一时刻两个用户读到的数据不同。
快照读和当前读
-
- 快照读:读取的是快照版本,也就是历史版本
- 当前读:读取的是最新版本
- 普通的select就是快照读,而update,delete,insert,select...LOCK In SHARE MODE(共享锁),SELECT...for update(排他锁)就是当前读,其实执行update,delete,insert的时候也是先进行读取数据,然后进行操作。
InnoDB存储引擎中的锁
锁的类型
共享行锁
共享锁数据行锁,允许不同事务共享加锁读取,但不允许其它事务修改或者加入排他锁。如果有修改必须等待一个事务提交完成,才可以执行,容易出现死锁
例如:事务1已经获得了行r的共享锁,那么另外的事务2可以立即获得行r的共享锁,因为读取并没有改变行r的数据,称这种情况为锁兼容。
select * from user where id = 1 lock in share mode; # 共享锁的写法
排他行锁
排他锁属于行锁,当一个事物加入排他锁后,不允许其他事务加共享锁或者排它锁读取,更加不允许其他事务修改加锁的数据行。
例如:事务1已经获得了行r的共享锁,若有事务3想获得行r的排他锁,则必须等事务1释放行r上的共享锁,这种情况称为锁不兼容。
select * from user where id = 1 for update; # 排他锁的写法
InnoDB存储引擎支持多粒度锁定,这种锁定允许事务在行级上的锁和表级上的锁同时存在。为了支持在不同粒度上进行加锁操作,InnoDB存储引擎还提供了一种额外的加锁方式,称为意向锁,意向排他锁是表级别的锁。意向锁是将锁定的对象分为多个层次,意向锁意味着事务希望在更细粒度上进行加锁。
意向锁
意向锁的作用:意图锁是表级锁,指示事务稍后需要对表中的行使用哪种类型的锁(共享或独占)。意向锁意味着事务希望在更细粒度上进行加锁。
为什么没有意向锁,表锁和行锁不能共存?
假设事务A写锁锁住了某一行,其他事务就不可能修改这一行。这与”事务B锁住整个表就能修改表中的任意一行“形成了冲突。所以,没有意向锁的时候,行锁与表锁共存就会存在问题!
有了意向锁之后,前面例子中的事务A在申请行锁(写锁)之前,数据库会自动先给事务A申请表的意向排他锁。当事务B去申请表的写锁时就会失败,因为表上有意向排他锁之后事务B申请表的写锁时会被阻塞。
若将上锁的对象看成一棵树,那么对最下层的对象上锁,也就是对最细粒度的对象进行上锁,那么首先需要对粗粒度的对象上锁。如上图,如果对页上的记录上排他锁,那么分别需要对数据库A、表、页上意向锁,最后对记录上排他锁。若其中任何一个部分导致等待,那么该操作需要等待粗粒度锁的完成。这其实也是为什么Myisam数据库引擎的查询速度更优于InnoDB数据库引擎的原因,锁加的多了自然就慢了。
InooDB存储引擎意向锁的设计目的主要在于为了在一个事务中解释下一行即将被请求的锁类型。其支持两种意向锁:
意向共享锁(IS Lock):事务在获取表中某行上的共享锁之前,必须先获取表上的IS锁。
意向排他锁(IX Lock):事务在获取表中某行的排他锁之前,必须先获取该表的IX锁。
表级锁类型的兼容性汇总在下表
意向共享锁 | 意向排他锁 | 共享表锁 | 排他表锁 | |
---|---|---|---|---|
意向共享锁 | 兼容 | 兼容 | 兼容 | 不兼容 |
意向排他锁 | 兼容 | 兼容 | 不兼容 | 不兼容 |
共享表锁 | 兼容 | 不兼容 | 兼容 | 不兼容 |
排他表锁 | 不兼容 | 不兼容 | 不兼容 | 不兼容 |
记录锁(Record Lock)
记录锁定是对索引记录的锁定。例如, SELECT c1 FROM t WHERE c1 = 10 FOR UPDATE;
可以防止从插入,更新或删除行,其中的值的任何其它交易t.c1
是 10
记录锁定始终锁定索引记录,即使没有定义索引的表也是如此。对于这种情况,请 InnoDB
创建一个隐藏的聚集索引,并将该索引用于记录锁定。
间隙锁(Gap Lock)
间隙锁定是对索引记录之间的间隙的锁定,或者是对第一个或最后一个索引记录之前的间隙的锁定,锁定一个范围,但不包含记录本身。例如,SELECT c1 FROM t WHERE c1 BETWEEN 10 and 20 FOR UPDATE;
阻止其他事务将value 15
插入column中t.c1
,无论该列 中是否已经存在该值,因为范围内所有现有值之间的间隙都被锁定。
间隙可能跨越单个索引值,多个索引值,甚至为空。
对于使用唯一索引来锁定唯一行来锁定行的语句,不需要间隙锁定。(这不包括搜索条件仅包含多列唯一索引的某些列的情况;在这种情况下,会发生间隙锁定。)例如,如果该id
列具有唯一索引,则以下语句仅使用一个索引记录锁定id
值为100 的行,其他会话是否在前面的间隙中插入行都没有关系:
SELECT * FROM child WHERE id = 100;
如果id
未建立索引或索引不唯一,则该语句会锁定前面的间隙。
间隙锁可以共存。一个事务进行的间隙锁定不会阻止另一事务对相同的间隙进行间隙锁定。共享和专用间隙锁之间没有区别。它们彼此不冲突,并且执行相同的功能。
Next-Key Lock
是Record Lock+Gap Lock的组合,锁定一个记录范围,并锁定记录本身。
例如一个索引有10,11,13和20这四个值,那么该索引可能被锁定的区间为:
(-∞,10] (10,11] (11,13] (13,20] (20,+∞)
如果查询的索引是聚集索引时,InnoDB存储引擎会对Next-Key Lock进行优化,将其降级为Record Lock,即仅锁住索引本身,而不是范围。
create table t(a int primary key); insert into t values(1); insert into t values(2); insert into t values(5);
接着执行sql
创建事务A
begin; select * from t where a=5 for update; commit;
如果在事务A提交之前,创建事务B并插入一条数据,且提交。
# 事务B begin; insert into t values(4); commit; # 成功不需要阻塞
表t中有1,2,5三个值,在事务A对a = 5进行排他锁锁定。而由于a是主键且唯一,因此锁定的仅是5这个值,而不是(2,5)这个范围,这样事务B插入值4时可以立即插入,不会阻塞。Next-Key Lock降级为Record Lock,从而提高并发性。
若是辅助索引,即则会阻塞等待上一事务提交后才能继续执行。比如:一个表a中有辅助索引四个值m为1、2、4、6,一个辅助索引使用Next-Key Lock锁定条件是4,代码如下,那么他锁定的范围是(2,4),但特别注意的是,InnoDB存储引擎还会对辅助索引下一个键值加上gap lock,即还有一个辅助索引范围为(4,6)的锁。因此若执行插入m=5的sql语句会被阻塞。
select * from a where m=4 for update;
通过Next-Key Lock可以避免幻读的出现。