事务(Transaction)
事务是一个最小的不可再分的工作单元,事务只和DML语句有关,用来管理insert,update和delete语句,在 MySql 中只有使用了 Innodb 数据库引擎的数据库或表才支持事务。
一、事务是必须满足4个条件(ACID)
原子性(Atomicity):
一个事务中的所有操作,要么全部完成,要么全部不完成,不会结束在中间某个环节。事务在执行过程中发生错误,会被回滚到事务开始前的状态,就像这个事务从来没有执行过一样(undo log日志)。
一致性(Consistency):
在事务开始之前和事务结束以后,数据库的完整性没有被破坏。应用系统应该从一个正确的状态到另一个正确的状态,这表示写入的资料必须完全符合所有的预设规则,这包含资料的精确度、串联性以及后续数据库可以自发性地完成预定的工作(redo log日志)。
隔离性(Isolation):
数据库允许多个并发事务同时对其数据进行读写和修改的能力,隔离性可以防止多个事务并发执行时由于交叉执行而导致数据的不一致。防止出现:脏读、幻读、不可重复读。
持久性(Durability):
事务处理结束后,对数据的修改就是永久的。
二、事务隔离级别
加锁可以非常完美的保证隔离性,但是会造成数据库性能的大大下降。所以视操作不同,事务管理分为了不同的情况。
a.如果两个事务并发的修改则必须隔离开。
b.如果两个事务并发的查询则完全不用隔离。
c.如果一个事务修改,另一个事务查询,则可能出现脏读、不可重复读、幻读的情况,隔离级别主要针对这种情况。
-
1.脏读
:一个事务读取到另一个事务未提交的数据。
2.不可重复读
:一个事务读取到另一个事务已经提交的数据。例如事务A读取了一条记录,此时事务B修改了该条数据并提交成功,事务A再次查询该条数据发现与第一次读取的不一样,即为不可重复读(同一个事务内
重复读取的数据不一样,则理解成不可重复读)。
3.幻读
:事务级别设置为可重复读,解决了不可重复读的问题,事务A查询时保证了重复读取的数据一致,但当事务A执行update,insert,delete操作时,会直接更新事务B的最新结果。这到底咋回事???这是因为可重复读底层使用的是mvcc的机制,select查询操作属于快照读(历史版本),insert、update、delete会使用数据库实时的值来进行计算是当前读(和readView有关)。
-
如图红框部分:
左侧事务A查询为2条,更新之后,影响到的是三条数据,并且再次查询也是三条数据,好像出现了幻觉,这就是出现了幻读。
针对上面的三个问题,数据库提出四大隔离级别
:
读未提交(read uncommitted)
: 不作任何隔离,具有脏读、不可重复读、幻读问题读已提交(read committed)
: 可防止脏读,不能防止不可重复读和幻读问题可重复读(repeatable read)
: 可以防止脏读、不可重复读,不能防止幻读问题(mysql默认是这个隔离级别)串行化(serializable)
: 数据库运行在串行化,上述问题都可以防止,只是性能非常低
MySQL的默认隔离级别是 可重复读(repeatable read),解决了脏读,不可重复读,但是未解决幻读。
导致幻读的原因是什么呢?
要了解这个原因,则还需要知道另一个概念:MVCC
三、MVCC
1. MVCC基本概念:
MVCC全称 Mutli Version Concurreny Control,多版本并发控制,是MySQL中基于乐观锁理论实现隔离级别的方式,用于实现读已提交(read committed)和可重复读(repeatable read)隔离级别的实现。它通过行的多版本控制方式来读取当前执行时间数据库中的行数据。原理是将数据保存在某个时间点(版本号)的快照来实现的,MySQL中MVCC的实现方式是在数据库保存最新版本的数据,但是会在使用undo时动态重构旧版本数据。这样就可以实现不加锁读。
MVCC可以认为是行级锁的一个变种,它可以在很多情况下避免加锁操作,因此效率高开销低。MVCC实现了非阻塞的读操作,写操作也只锁定必要的行。
2. InnoDB的MVCC实现机制:
InnoDB MVCC通过数据行的undo日志的版本链条、ReadView(一致性读视图)。
ReadView
ReadView简单来说就是你执行事务的时候,就给你生成一个ReadView,里面有比较关键的4个东西
- m_ids:里面包含了还有哪些事务还未提交,也就是活跃事务数组
- min_trx_id:最小的活跃事务ID(m_ids数组最小的值)
- max_trx_id:已创建的最大的事务ID
- creator_trx_id:当前你自己的事务ID
undo日志的版本链条
是指同一个数据行被多个事务并发的修改后,每个事务会保存修改之前的undo日志,用于回滚。并且通过两个隐藏字段trx_id(当前版本的事务ID)、roll_pointer(指向上一个版本的指针)把这些undo日志版本给串联起来形成一个版本链条。
InnoDB的MVCC,是通过在每行记录后面保存两个隐藏的列来实现的。这两个列,一个保存了行的创建时间,一个保存行的过期时间(或删除时间)。当然存储的并不是实际的时间值,而是系统版本号。每开始一个新的事务,系统版本号都会自动递增。事务开始时刻的系统版本号会作为事务的版本号,用来和查询到的每行记录的版本号进行比较。
RR隔离级别是如何基于ReadView机制实现的???
假设原始数据如下:
现在并发开启两个事务A(trx_id = 200)、B(trx_id = 230)
此时事务A发起一个查询请求,记住只有第一次查询时才会生成对应的ReadView视图。此时ReadView中的值分别是creator_trx_id:200,min_trx_id是100,max_trx_id是231,m_ids是[200,230]。
这个时候事务A去查询数据,会发现这条数据的事务ID是100,是小于ReadView中的min_trx_id,说明此事务是在事务A开启前就已经开启了并提交了,所以是可见的。
接着事务B修改了这条数据,此时会生成一个undo log,同时会将事务ID修改为230,并将事务提交。如图:
此时事务A再次查询,会发现这条数据的事务ID为230。
思考这个时候事务A的ReadView的m_ids还会是[200,230]吗???
那必然是的,因为一旦ReadView一旦生成后,就不会改变的。这也是mvcc解决了不可重复的原因。
首先事务A会比较这条数据的当前事务ID是否小于min_trx_id,结果发现是大于的。再次比较是否大于max_trx_id,发现不成立,这个时候就去活跃事务ID数组里面找了,一找,还真存在,说明此事务基本上是跟自己查不多时间开启的,因此是不可见的,于是根据版本roll_pointer指针,顺着往下面找,找到事务id为100的,进行比较100小于min_trx_id,说明是事务A开启事务前就开启了事务并提交了,所以数据可见。
此时开启事务C(事务ID:400),并修改了这条数据且提交了事务,这个时候的undo版本链如下:
此时事务A再次去查询,发现当前数据的事务id为400了,于是再次进行比较,发现事务id大于了最大的事务id,说明此事务是我开启事务之后开启的,数据当然也是不可见的。然后顺着roll_pointer指针查找undo版本链,将数据的每一个事务版本进行ReadView比较,最终找到了事务ID为100的才可见。
如果事务A(事务id:200)此时修改了此数据,则这条数据的undo版本链如下:
事务A再次查询时,会发现此时当前数据的事务ID是自己,因此数据是可见的。
比较规则总结
- 如果查询数据事务id < min_trx_id,数据可见
- 如果查询数据事务id > max_trx_id,数据不可见
- 如果min_trx_id< 当前数据事务id < max_trx_id分三种情况:
1:如果查询数据事务ID等于当前自己的事务ID,数据是可见。
2:如果查询数据事务ID不等于自己,且查询数据事务ID在活跃事务列表数据不可见。
3:如果查询数据事务ID不等于自己,且查询数据事务ID不在活跃事务列表则数据不见。
注意:begin/start transaction 命令并不是一个事务的起点,在执行到它们之后的第一个修改操作InnoDB表的语句,
事务才真正启动,才会向mysql申请事务id,mysql内部是严格按照事务的启动顺序来分配事务id的。
下面看一下在可重复读(repeatable read)隔离级别下,MVCC具体是如何操作的:
什么是快照读、当前读
- 快照读读取的是数据行的快照(历史数据,原因是不会刷新read view中活跃事务),不加锁的普通select都是属于快照读。
- 当前读就是读取的是数据行的实时数据,比如update、insert、delete和加锁的select操作都是属于当前读。
快照读就是读取数据的时候会根据一定规则读取事务可见版本的数据。 而当前读就是读取最新版本的数据。
正是因为生成时期的不同造成了RC,RR存储隔离级的不同可见性。RR会创建一个快照即read view 将当前系统中活跃的其他事务记录起来,此后在调用快照读的时候,还是用的是同一个read view。而在read commited级别下事务中每条select语句也就是每次调用快照读的时候,都会创建一个新的快照
。这就是我们之前在RC下能用快照读看到别的事务已提交的对表记录的增删改,而在RR下如果首次使用快照读,是在别的事务在对增删改进行提交之前,此后即便别的事务做了增删改并且做了提交还是读不到数据变更的原因。对于RR来讲首次事务select 的时机是相当重要的。
InnoDB的RR隔离级别没有或者解决了幻读问题都不太准确。应该说它并没有完全解决幻读的问题。如果在同一个事务里面,只是总是执行普通的select快照读,是不会产生幻读的。但是如果在这个事务里面通过当前读
或者先更新然后快照读
的形式来读取数据,就会产生幻读。
四、数据库锁详解
锁是计算机协调多个进程或者线程并发访问同一资源的机制。
在数据库中,除了传统的计算机资源(如cpu、RAM、I/O)共享外,数据也是用户所共享的资源,那么如何保证用户并发访问统一资源时,数据的一致性、安全性、有效性等问题,此时就会用上锁的概念。
锁分类
从以下几个方面分类:
- 粒度:表锁、行锁
- 操作:读锁(共享锁)、写锁(排他锁)
- 性能:乐观锁、悲观锁
读锁:lock table mylock read;
写锁:lock table mylock WRITE;
1、对MyISAM表的读操作(加读锁) ,不会阻寒其他进程对同一表的读请求,但会阻赛对同一表的写请求。只有当读锁释放后,才会执行其它进程的写操作。
2、对MyISAM表的写操作(加写锁) ,会阻塞其他进程对同一表的读和写操作,只有当写锁释放后,才会执行其它进程的读写操作。
Gap Lock(间隙锁)
间隙锁,锁的就是两个值之间的空隙。Mysql默认级别是repeatable-read,有办法解决幻读问题吗?间隙锁在某些情况下可以解决幻读问题。在可重复读的事务级别下面,普通的select读的是快照,不存在幻读情况,但是如果加上for update的话,读取是已提交事务数据,gap锁保证for update情况下,不出现幻读。
假设此时表数据如下:
那么间隙就有(3,10)、(10,20)、(20,正无穷)。
如果事务A执行
update account set name = ‘lisi’ where id > 8 and id <18;
则其他事务没法在这个范围(3,20】,区间插入或者修改数据。注意最后那个20是包含的。
如果条件为 id > 8 and id <10,此时产生的间隙锁区间为(3, 10】。
间隙锁只能在可重复读的隔离级别下才生效。
如果此时其他事务插入不在这个间隙锁区间内的数据,是可以插入成功的,当前事务再次以前面提到过的两种方式操作(验证幻读),查询表所有记录时,发现其他事务插入的数据是可以查询到的。
总结:所以间隙锁也不能完全解决幻读的问题,只能是一定程度上的解决
事务B插入lisi2记录,id为12(在事务A产生的间隙锁区间),事务B尝试提交事务,事务阻塞。
Next-Key Locks(临键锁)
InnoDB默认的行锁算法。在存储引擎innodb中,事务级别在可重复读的情况下使用的数据库锁,Next-Key Locks是行锁和gap锁的组合
。当sql执行是通过索引的范围查询来检索数据,且能查到数据时,此时sql会加上临键锁。
- 唯一索引精确等值检索:
Next-Key Locks就退化为记录锁,不会加gap锁。 - 唯一索引范围检索:
会锁住where条件中相应的范围,范围中的记录以及间隙,换言之就是加上记录锁和gap 锁。 - 非唯一索引精确等值检索:
Next-Key Locks会对间隙加gap锁,以及对应检索到的记录加记录锁。 - 非唯一索引范围检索:
会锁住where条件中相应的范围,范围中的记录以及间隙,换言之就是加上记录锁和gap 锁。 - 非索引检索,全表间隙gap lock,全表记录加记录锁(record lock)。
临键锁会锁住满足条件的区间,区间左开右闭。就比如上面的(3,20】。
总论:Innodb存储引擎由于实现了行级锁定,虽然在锁定机制的实现方面所带来的性能损耗可能比表级锁会更高一点,但是整体的并发能力是远远高于表锁性能的。where条件全部命中主键索引或者唯一索引只加记录行锁,不加Gap锁。gap出现场景:主要出现在非唯一索引或者不走索引的当前读当中,如果where条件部分命中或者未命中,则会加Gap锁,并锁住修改地方的周边。当前读不走索引的时候,他就会对所有的gap都上锁,类似于锁表。InnoDB的RR级别下通过引入next-key锁来,避免幻读问题。而next-key由record lock 和 gap lock组成。gap lock会用在不走索引或者走非唯一索引的当前读,以及仅命中部分条件的结果集。并且用到主键索引以及唯一索引的当前读中(Gap锁锁住内容同时也会锁住对应的唯一索引和最上层主键索引)。
- 唯一索引:
- 非唯一索引:
注意:无索引锁会升级为表锁。
锁优化建议
- 尽量让所有数据检索都通过索引来完成,避免无索引升级为表锁
- 合理设计索引,尽量缩小锁的范围
- 尽可能减少检索条件范围,避免间隙锁
- 尽量控制事务大小,减少锁定资源量与时间,涉及事务加锁的sql尽量放在最后执行
- 尽可能降低事务隔离