《高性能Mysql》解读---Mysql的事务和多版本并发
1、base:ACID属性,并发控制
2、MySql事务的隔离级别有哪些,含义是什么?
3、锁知多少,读锁,写锁,排他锁,共享锁,间隙锁,乐观锁,悲观锁。
4、Mysql的事务与锁有什么关联?MySq中的事务实例。
1.1 ACID属性,多版本并发控制
在数据库汇总,事务可以看作是一组SQL语句组成的逻辑处理单元,事务主要具有以下4个属性,简称ACID属性:
- 原子性(Atomicity):事务是一个原子操作单元,其内部的SQL语句,要么全部成功,要么全部失败。
- 一致性(Consistent):在事务开始和完成后,数据都必须保持一致状态。
- 隔离性(Isolation):数据库系统提供一定的隔离机制,保证事务在不受外部并发操作影响的独立环境下执行。这要求事务处理过程的中间状态对外部是不可见的。
- 持久性(Durable):事务完成后,它对数据库的修改是永久性的,即使出现系统故障也能够保持。
1.2 多版本并发控制
MySQL的大多数的事务型存储引擎实现的都不是简单的行级锁,基于提升并发性能的考虑,都引入了多版本并发控制技术(MVCC),可以认为MVCC是行级锁的一个变种,在很多情况下可以避免加锁的操作,让开销更低。InnoDB的MVCC,是通过在每行记录后面保存两个隐藏的列来实现的。一个保存的行的创建时间,一个保存行过期时间(或删除时间)。存储的不是实际的时间值,是系统的版本号。每开始一个新的事务,系统版本号都会自动递增。事务开始时刻的系统版本号会作为事务的版本的版本号,用来和查询列的每行记录的版本号进行比较。
MVCC只在已提交读(Read Committed)和可重复读(Repeatable Read)两个隔离级别下工作,其他两个隔离级别和MVCC是不兼容的。因为未提交读,总数读取最新的数据行,而不是读取符合当前事务版本的数据行。而串行化(Serializable)则会对读的所有数据多加锁,下面以可重复读(Repeatable Read)为例子,说明下以select、delete、 insert、 update语句MVCC具体是如何操作的。
SELECT
Innodb检查每行数据,确保他们符合两个标准:
1、InnoDB只查找版本早于当前事务版本的数据行(也就是数据行的版本必须小于等于事务的版本),这确保当前事务读取的行都是事务之前已经存在的,或者是由当前事务创建或修改的行
2、行的删除操作的版本一定是未定义的或者大于当前事务的版本号,确定了当前事务开始之前,行没有被删除符合了以上两点则返回查询结果。
INSERT
InnoDB为每个新增行记录当前系统版本号作为创建ID。
DELETE
InnoDB为每个删除行的记录当前系统版本号作为行的删除ID。
UPDATE
InnoDB复制了一行。这个新行的版本号使用了系统版本号。它也把系统版本号作为了删除行的版本。
1.3 事务的一致性和数据库的一致性读
- 一致性关注数据的可见性,中间状态的数据对外部不可见,只有最初状态和最终状态的数据对外可见。强调的是当前事务在未提交之前的任何修改对其他事务都是不可见的。
- 读一致性是数据库一致性的一个重要方面,跟事务的一致性是2个不同的概念。数据库的读一致,因为innoDB采用了多版本控制技术(MVCC),为查询提供了一个该时间点的数据快照。从时间的角度来看,查询可以看到该时间点之前已经提交的更新,但是不能看到该时间点之后的更新以及尚未提交的事务所做的更新。
- 在事务的隔离级别为已提交读(Read Committed),读一致性是语句级别的,意味着同一个事务执行2次相同的查询,返回的结果存在不一致的问题,因为只要别的事务修改后提交了,在该隔离级别下再次查询都是最新的所有数据。
- 在事务的隔离级别为可重复读(Repeatable Read)时。读一致性是事务级别的,同一个事务无论执行多少次查询,无论别的事务在其过程中怎么修改数据,读到的永远是第一次读取到的快照。
2.1 事务并发时有可能产生的问题
由于数据库在处理过程中是并发进行处理的,因此在事务并发执行过程中,可能会带来如下一些问题:
- 更新丢失(Lost Update)
当多个事务对同一条数据库记录进行操作时,由于每个事务不知道其他事务的存在,如果其中一个事务读取了数据库记录后,该记录又被另外一个事务进行了更新,那么该事务基于最初读取到的值进行一些运算并将运算结果写回数据库时就可能会出现问题:中间事务的更新被覆盖,从而导致更新丢失的问题。
解决方法:使用InnoDB存储引擎时,主要通过排他锁避免更新丢失的问题,这就要求所有更新都遵循一个基本原则:在更新数据库记录前获得该记录的排他锁。对于某些特殊的应用,在业务允许的情况下也可以使用增量更新的方式进行优化:即对某些字段进行A+=X之类的操作,利用Mysql更新语句会默认获得行锁的特性去避免更新丢失。
- 脏读(Dirty Reads)
事务A在某个步骤中修改了一条记录,在A完成并提交前,该记录处于一个中间状态,此时,另外一个事务如果读取同一条记录,如果该事务读到这个中间状态,那么就有可能会出现数据不一致状态,这种现象叫做脏读。
解决方法:使用InnoDB存储引擎时,可以通过共享锁或者排他锁避免脏读,这就要求所有更新操作都遵循一个基本原则:在读取数据库记录前获得该记录的排他锁获取共享锁。此外,还可以通过设置InnoDB的事务隔离级别来避免脏读的问题。
- 不可重复读(Non-Repeatable Reads)
事务A在读取了某条记录后,该记录又被另外一个事务进行了修改,此时,如果事务A再次读取该记录时,如果该记录的数据和它首次读取的记录不一致了,那么这种现象就叫做不可重复读,它与脏读的区别是:脏读是读到事务中间过程的临时数据,数据处于一个临时的非稳定状态,可能被该事务再次修改或者回滚;而不可重复读是由于两次读取时间点的间隙中,数据被其他事务修改而导致的数据不一致,这种修改对数据库的影响是永久性的。
解决方法:使用InnoDB存储引擎时,可以通过设置InnoDB的事务隔离级别来控制重复读的特性,关于事务隔离级别,后续将进行详细描述。
- 幻读(Phantom Reads)
幻读出现在多条记录的查询中。当事务A用同样的查询条件读取之前某个点检索过的数据时,检索到了被其他事务在其间插入的新记录(或者少了被其他事务删除的记录),这种现象就称为幻读。
解决方法:幻读和不可重复读的本质相同,都是由于两次读取间隙中由于其他事务的操作导致的记录数据变更,其区别是:不可重复读一般是针对单条记录的,幻读是针对一个结果集而言。
事务并发问题不能单靠数据库事务控制来解决,通常需要结合InnoDB的锁机制来解决,因此,防止出现事务并发问题是应用层面需要解决的问题,不能单纯依靠事务机制。但是脏读、不可重复读、幻读是数据库读一致性的问题,需要由数据库提供一定的事务隔离机制进行解决。
2.2 事务的隔离级别
事务就是一组原子性的SQL查询,或者说一个独立的工作单元,如果数据库引擎能成功对数据库应用该组查询的全部语句,那么事务内的语句,要么全部执行成功,要么全部执行失败,如果其中有任何一条语句因为崩溃或者其他原因无法执行或者执行失败,那么其他的语句就都不会执行。在SQL标准中定义了四种隔离级别,每一种级别都规定了一个事务中所做的修改,哪些在事务内和事务间是可见的,哪些是不可见的。
下面用表格简单表述下:
隔离级别 | 读一致性 | 脏读 | 不可重复读 | 幻读 |
未提交读(Read Uncommitted) | 最低级别 | 是 | 是 | 是 |
已提交读(Read Committed) | 语句级别 | 否 | 是 | 是 |
可重复读(Repeatable Read) | 事务级别 | 否 | 否 | 是 |
串行化(Serializable) | 最高级别,事务级 | 否 | 否 | 否 |
- 未提交读(READ UNCOMMITTED)
在未提交读模式下,SELECT语句以不加锁方式提交执行,而且有可能使用之前较早版本的数据。通俗的可以理解为可能读到其他事务未提交的中间状态数据,这个中间数据有可能被重新修改或者被回滚。所以在该模式下,数据库查询是不符合一致性读的原则。
- 已提交读(READ COMMITTED)
在事务的读取操作点上可以看到该事务开始后到读取操作点之间被其他事务提交的修改。只要其他事务提交后的数据,当前事务在任一点都能看见。
- 可重复读(REPETABLE READ)
在一个事务内部,对记录的读取有以下特性,所有一致性读是读取由第一次读所确定的同一快照。也就是意味着,在同一个事务内,以同样的查询条件执行查询均以第一次查询的结果为准,哪怕后续有其他事务对记录进行过更改。
- 可序列化(SERIALIZABLE)
可序列化是读一致性的最高级别, 该级别与可重复读类似,但是在该级别下,即使是普通的不加锁查询,InnoDB也会用LOCK IN SHARE MODE方式提交该查询。
数据库的事务隔离级别越严格,并发副作用就越小,但是付出的代价也越大,因为事务隔离级别越高,就要求数据库在一定程度上进行“串行化”操作,从而导致并发能力降低。Mysql的缺省隔离模式为可重复读,实际应用中大都采用已提交读,这两种隔离级别的主要差异有两点:一、可重复读特性;二、间隙锁的锁定模式。(其他两种隔离模式应用比较少)。事务隔离级别在一致性读特性上的影响是针对当前事务的,也就是说:如果事务A的隔离级别是未提交读,则不管其他事务的隔离级别是如何设置的,A总会读到其他事务的中间状态。但是对于锁的影响,则是以最先对记录加锁的事务的隔离级别为准,例如:一个设置为未提交读模式的事务使用FOR UPDATE对某个范围的记录进行了锁操作,另外一个可重复读模式的事务允许在该范围内进行插入操作,反之,则插入操作会被阻塞。由于采用不同隔离级别将会导致难以预计的复杂性,因此,不要针对单个连接设定事务的隔离级别,只针对数据库设定全局的事务隔离级别。
2.3 自动提交模式
使用自动提交模式,意味着任何SQL语句都在一个事务当中。即使没有显式的调用BEGIN之类的命令,Mysql也会加上BEGIN,这些场景可能包括:建了一个连接后、COMMIT之后、ROLLBACK之后…、创建表之后…。使用SET AUTOCOMMIT来设置连接的事务提交模式:1-自动提交;0-关闭自动提交。
MYSQL缺省使用自动提交模式,但是什么是自动提交呢?首先来做一个测试,在两个终端A和B上执行如下MySQL操作:
隔离级别(缺省=可重复读)提交模式(缺省=1:自动提交) | ||
TIME | | | V |
终端A | 终端B |
update test.my_table set F=1; | ||
select F from test.my_table; |
B的查询结果是1,“任何SQL语句都在一个事务当中”,因为update语句总是位于一个事务当中,然而A没有进行显式提交(COMMIT),B在A执行完UPDATE之后,就能看到A的修改了,所以,说明在update之后,MySQL进行了自动提交操作,所以这种模式叫做自动提交模式。这种行为符合我们的常识。那下面再看看提交模式为0:关闭自动提交。
隔离级别(缺省=可重复读)提交模式(缺省=0:关闭自动提交) | ||
TIME | | | | v |
终端A | 终端B |
SET AUTOCOMMIT=0; | ||
USE test; | ||
USE test; | ||
UPDATE my_table SET F=1; | ||
SELECT F from my_table; |
假定执行前test.my_table的F字段为0,而B查询到的结果就是0。A终端在建立数据库连接之后就已经是一个事务当中,但是由于关闭了自动提交,所以,这个事务必须在明确的执行一个COMMIT、ROLLBACK或者其他隐式的COMMIT和ROLLBACK后才会结束,所以B查询出来的结果为0。
2.4 demo小测试
不同的隔离级别下,对数据库的一致性读问题:脏读,不可重复读,幻读的解决程序是不一样的,下面demo开始前设置:提交模式=0(关闭自动提交模式),SET AUTOCOMMIT=0;
use test;
create table T(Fid int(11), Fname varchar(24), primary key(Fid)) engine=InnoDB;
insert into T(Fid, Fname) values(1, 'superman');
insert into T(Fid, Fname) values(2, 'Jack');
A:脏读和不可重复读的差别?
脏读,不可重复读 | ||
TIME | | | | | | | | | v |
终端A | 终端B |
use test; | use test; | |
begin; | begin; | |
update t set Fname ='loleina' where Fid='1' ; | ||
select * from t; | ||
commit; | ||
select * from t; | ||
commit; | ||
select * from t; | ||
现象:未提交读:事务B在第一个select sql执行时就能读取到事务A更新的数据。事务A未提交,事务B也会读取该数据。事务B读取的数据有可能是事务中的中间状态数据,会有脏读的问题。 已提交读:事务B在第二个select sql执行时才能读取到事务A更新的数据。事务A未提交,事务B不能看见事务A所做的修改,可见已提交读,解决了脏读的问题,但是事务B的过程中2次读取的数据会不一致,出现了不可重复读的问题。 可重复读:事务B在第三个select sql执行时才能读取到事务A更新的数据。事务B的过程中2次读取的数据一致,解决了不可重复读的问题。 |
不可重读与脏读的区别是:脏读是读到事务中间过程的临时数据,数据处于一个临时的非稳定状态,可能被该事务再次修改或者回滚;而不可重复读是由于两次读取时间点的间隙中,数据被其他事务修改而导致的数据不一致,这种修改对数据库的影响是永久性的。
B:幻读是什么?
幻读,并不是说两次读取获取的结果集不同,幻读侧重的方面是某一次的 select 操作得到的结果所表征的数据状态无法支撑后续的业务操作。更为具体一些:select 某记录是否存在,不存在,准备插入此记录,但执行 insert 时发现此记录已存在,无法插入;或者更新某范围的所有值时,明明都已经全部更新了,最后提交的时候发现新增了一条数据居然没有更新,此时就发生了幻读。
下面以可重复读(Repeatable Read)模式下为例:
幻读 | ||
TIME | | | | | | | | | v |
终端A | 终端B |
use test; | use test; | |
begin; | begin; | |
update t set Fname ='loleina' where Fid >=0 AND Fid <='2' ; | ||
insert into T(Fid, Fname) values(0, 'Jack'); | ||
commit; | ||
commit; | ||
现象: 业务侧的真实想法是把Fid从0-2范围内的记录的值的Fname全更新,事务提交后查询,居然发现有Fid=0的记录未完成需求。 |
在RR隔离级别下,这种业务场景,是可以通过加间隙锁来防止该类事件的发生。通常就是在查询语句后面加上for update,如:select * from t where Fid>=0 and Fid <=2 for update;行级锁实际上是索引锁。但是需要注意的是在不同的隔离模式下,不同的SQL语句在是否使用间隙锁上存在较大的差异,例如SELECT … FOR UPDATE在READ COMMITTED模式下,并不会使用间隙锁,这种情况下允许插入索引间隙的记录;而REPETABLE READ模式下,则会使用间隙锁,不允许插入锁定的索引范围内的记录。有关各种语句的锁定方式请参考后续章节更详细的说明。
3.1 常见的锁类型
如果三个进程,第一个进程企图修改t表的Fid=1的Fname值为"test",第二个进程企图修改值为"develop",第三个进程希望能查询该值,如果不做任何的并发控制,第三个查询进程返回的值是不确定的,有可能会返回test,也有可能会返回develop,无论返回哪一个数据,都存在数据丢失的情况。解决这类经典问题的方法就是并发控制,可以通过实现一个由两种类型的锁组成的锁系统来解决。一个叫共享锁(shared lock),也叫读锁(read lock),还有一个叫排他锁(exclusive lock),也叫写锁(write lock)。先大概描述下锁的概念:读锁是共享的。多个客户在同一时刻可以同时读取同一个资源,互不干扰。而写锁是排他的,一个写锁会阻塞其他的写锁和读锁。只有这样,才能确保在给定的时间内,只有一个进程能执行写入,并防止其他用户读取正在写入的同一资源。
3.2 常见的锁策略
一种提高共享资源并发性的方式就是让锁定对象更有选择性。尽量只锁定需要修改的部分数据,而不是所有的资源。在给定的资源上,锁定的数据量越少,则系统的并发程序就越高,只要处理好关系,相互之间不发生冲突即可。加锁是会消耗资源的,锁的各类操作,包括获取锁,检查锁是否已经解除,释放锁等等,都是会增加系统的开销的。如果系统花费大量的时间来管理锁,而不是存取数据,那系统的性能可能会因此收到影响。MySQL的不同存储引擎都可以实现自己的锁策略和锁粒度,常用的有两种重要的锁策略。
表锁:是MySQL中最基本的锁策略,开销也是最小的。表锁会锁定整张表,一个用户在对表进行写操作时前,需要选获得写锁,但是这会阻塞其他用户对该表的所有的读写操作。只能在没有写锁时,其他读取的用户才能获取读锁。写锁比读锁有更高的优先级,因此一个写锁请求可能会被插入到读锁队列的前面。
行锁:行级锁可以最大程序的支持并发处理(同时也带来了最大的锁开销),在MySQL中,行级锁只在存储引擎层实现,而在服务器层没有实现,服务器层完全不了解存储引擎中的锁实现。
行级锁中,InnoDB引入了一种叫做下一键锁定(next-key locking)的算法,俗称"间隙锁"。它通过如下方式实现行级锁:扫描表索引,并对符合条件的索引项设置共享锁/排他锁。因此,行级锁实际上是索引锁。InnoDB设置在索引上的锁也会影响索引项之前的间隙(键值在索引范围内但并不存在的记录)。如果一个用户通过索引对记录R设置了共享锁或排他锁,那么另一个用户不能在记录R之前(按索引顺序)插入一个新的索引项。这种锁定间隙的方式用于阻止所谓的“幻读”问题,例如假定你希望读取并锁定所有的标识大于100的child记录,以便更新该范围内的某些字段,下边是查找的SQL(假定id是一个索引字段):SELECT * FROM child WHERE id > 100 FOR UPDATE;该查询从第一个id大于100的索引开始扫描,若是设置在索引上的锁不能锁定索引间隙的话,就存在更新过程中有新记录被插入的可能,当重新执行一次同样的查询时,就可能会在查询返回的结果集中发现新插入的行,这与事务的隔离原则时违背的:必须在整个事务的执行过程中,它所读取的数据没有发生变更。在应用程序中,可以使用间隙锁进行唯一性检查:使用共享模式读取数据时,如果没有找到即将要插入的记录,那么就可以安全的插入新的记录,因为在记录上设置的间隙锁可以阻止其他人同时插入一条同样的记录。也就是说,间隙锁允许锁定表中并不存在的记录。
间隙锁在InnoDB的唯一作用就是防止其它事务的插入操作,以此来达到防止幻读的发生,所以间隙锁不分什么共享锁与排它锁。如果InnoDB扫描的是一个主键、或是一个唯一索引的话,那InnoDB只会采用行锁方式来加锁,而不会使用Next-Key Lock的方式,也就是说不会对索引之间的间隙加锁,
3.3 常用的锁方法
悲观锁:正如其名,它指的是对数据被外界(包括本系统当前的其他事务,以及来自外部系统的事务处理)的修改持保守态度,因此,在整个数据处理过程中,将数据处于锁定状态。悲观锁的实现,往往依靠数据库提供的锁机制(也只有数据库层提供的锁机制才能真正保证数据访问的排他性,否则,即使在本系统中实现了加锁机制,也无法保证外部系统不会修改数据)。悲观并发控制主要用于数据争用激烈的环境,以及发生并发冲突时使用锁保护数据的成本要低于回滚事务的成本的环境中。常用的手段是使用select..for update来实现行锁机制,例如2.4的第一个demo里修改为select * from t where Fid='1' for update;时,其他的写操作都会被阻塞,此刻其他事务只能进行读操作。只有当前事务可以对该数据进行修改操作。锁住选中的数据行或者数据集,待后面sql更新操作完成事务提交后,再释放锁。
需要注意的是,在事务中,只有SELECT ... FOR UPDATE 或LOCK IN SHARE MODE 同一笔数据时会等待其它事务结束后才执行,使用select…for update会把数据给锁住,不过需要注意一些锁的级别,MySQL InnoDB默认Row-Level Lock,所以只有明确地指定主键,MySQL 才会执行Row lock (只锁住被选取的数据) ,否则MySQL 将会执行Table Lock (将整个数据表单给锁住)。除了主键外,使用索引也会影响数据库的锁定级别。
乐观锁:在关系数据库管理系统里,乐观并发控制(又名“乐观锁”,Optimistic Concurrency Control,缩写“OCC”)是一种并发控制的方法。它假设多用户并发的事务在处理时不会彼此互相影响,各事务能够在不产生锁的情况下处理各自影响的那部分数据。在提交数据更新之前,每个事务会先检查在该事务读取数据后,有没有其他事务又修改了该数据。如果其他事务有更新的话,正在提交的事务会进行回滚。乐观事务控制最早是由孔祥重(H.T.Kung)教授提出。相对悲观锁而言,乐观锁假设认为数据一般情况下不会造成冲突,所以在数据进行提交更新的时候,才会正式对数据的冲突与否进行检测,如果发现冲突了,则让返回用户错误的信息,让用户决定如何去做。那么如何实现乐观锁呢,一般来说有以下2种方式:
1.使用数据版本(Version)记录机制实现,这是乐观锁最常用的一种实现方式。何谓数据版本?即为数据增加一个版本标识,一般是通过为数据库表增加一个数字类型的 “version” 字段来实现。当读取数据时,将version字段的值一同读出,数据每更新一次,对此version值+1。当提交更新的时候,判断数据库表对应记录的当前版本信息与第一次取出来的version值进行比对,如果数据库表当前版本号与第一次取出来的version值相等,则予以更新,否则认为是过期数据。
2. 第二种实现方式和第一种差不多,同样是在需要乐观锁控制的table中增加一个字段,字段类型使用时间戳(timestamp), 和上面的version类似,也是在更新提交的时候检查当前数据库中数据的时间戳和自己更新前取到的时间戳进行对比,如果一致则OK,否则就是版本冲突。
通用以2.4的第一个小demo为例,如果使用乐观锁,本质上就是遵守一个加前置条件更新的规则。则大致实现过程如下 ;
begin;
select (Fid,Fname) from t where Fid=1;-- 假设查询回来值为(1,‘’test')
update t set Fname=‘develop’ where id=1 and Fname='test';
commit;
这种情况下,更新数据是先get,修改get回来的数据,然后put回系统。如果事务在更新之前Fname已经被其他事务修改了,这当前事务更新的时候就会失败。
4.1 SELECT … IN SHARE MODE和SELECT … FOR UPDATE
2.4下的两个demo验证测试,充分的说明了在常用的RR和RC模式下,单靠事务隔离级别来解决并发引入的问题是不可能的。即使使用RR,仍然存在幻读的可能,InnoDB的事务ACID属性除了与事务的隔离级别有关之外,还需要InnoDB的行锁支持,锁是InnoDB事务的一部分,是解决事务并发问题的核心机制。并发问题不单是事务机制需要考虑的问题,更是业务需要考虑的问题。InnoDB锁操作的影响与索引、事务隔离级别等紧密关联。
常用的两种在事务中加锁的方法是,使用SELECT … IN SHARE MODE和SELECT … FOR UPDATE,SELECT … IN SHARE MODE是加共享锁进行读取时,意味着能获取到数据最后的更新状态,并同时为该记录设置一个共享锁。共享锁可以阻止其他操作对该记录的更新和删除操作。但是别的事务也可以同时获取该共享锁,此时如果当前事务企图更新该条语句,则会出现死锁,下面是一个小测试。
SELECT … IN SHARE MODE可能会产生死锁 | ||
TIME | | | | | | | | | v |
终端A | 终端B |
use test; | use test; | |
begin; | begin; | |
select * from t where Fid =0 lock in share mode; | ||
select * from t where Fid =0 lock in share mode; | ||
update t set Fname ='wang' where Fid=0; | ||
update t set Fname ='wang' where Fid=0; | ||
现象: 事务A企图执行update 语句时,会报获取锁超时的错误:ERROR 1205 (HY000): Lock wait timeout exceeded; try restarting transaction。因为事务B查询数据的时候使用了SELECT … IN SHARE MODE,获取了共享锁导致。同理,事务B也不能执行更新语句,最后两边相互等待共享锁释放发导致死锁超时。 |
如果把删除的小测试的语句换成SELECT … FOR UPDATE,就能很好的避免这个问题。
SELECT … FOR UPDATE 唯一索引加排他锁 | ||
TIME | | | | | | | | | v |
终端A | 终端B |
use test; | use test; | |
begin; | begin; | |
select * from t where Fid =0 for update; | ||
select * from t where Fid =0 ; | ||
select * from t where Fid =0 for update; | ||
update t set Fname ='wang' where Fid=0; | ||
commit; | ||
现象: 事务A查询时对唯一索引Fid使用SELECT … FOR UPDATE时,加了排他锁,事务B此刻可以正常查询Fid =0 的数据,但是如果也希望执行通用的sql,则会提示获取锁超时错误,最终事务A能按照预期更新数据。 |
4.2 事务与锁
InnoDB的实现遵循标准的行锁方式,支持两种类型的锁:共享锁(S锁)用于读取行(记录);排他锁(X锁)用于更新或者删除行。结合4.1的两个小测试说明下结果:
- 若事务T1持有了记录t的S锁,那么:事务T2对记录t的S锁请求会立刻被允许,T1和T2都持有了t的S锁;事务T2对记录t的X锁请求不会立刻被允许
- 若事务T1持有了记录t的X锁,那么:事务T2对记录t的任何类型的锁请求都不会立刻被允许,T2必须等到T1释放它在t记录上持有的锁。
使用SELECT … FOR UPDATE,在不同的情况下,mysql采取的锁策略是不一样的,这里的不同,包括隔离级别的不同,where 条件字段是否是唯一索引,查询时记录是否存在等常见情况。下面对此一一做个小测试:
- 测试一:在RC(已提交读)和RR(可重复读)隔离级别下,针对唯一索引使用 FOR UPDATE会对该记录(已存在),都会对该条记录加排他锁,其他事务只能读取数据,不能更新数据。
隔离级别(已提交读) | ||
TIME | | | | | | | | | v |
终端A | 终端B |
use test; | use test; | |
begin; | begin | |
select * from T where Fid=1 for update; | ||
select * from T where Fid=2 for update; | ||
select * from T where Fid=1 for update; | ||
提示:此处被阻塞 | ||
commit; | ||
commit; | ||
现象:在B上执行的第一个select查询顺利执行,但是第二个select被阻塞,直等到终端A的事务被提交或者等待锁超时为止。 | ||
结论:在已提交读的模式下,针对唯一索引使用FOR UPDATE会对该记录(已存在)加排他锁。 | ||
隔离级别(可重复读) | ||
TIME | | | | | | v |
终端A | 终端B |
use test; | use test; | |
begin; | begin; | |
select * from T where Fid=1 for update; | ||
select * from T where Fid=2 for update; | ||
select * from T where Fid=1 for update; | ||
提示:此处被阻塞 | ||
commit; | ||
commit; | ||
现象:在B上执行的第一个select查询顺利执行,但是第二个select被阻塞,直等到终端A的事务被提交或者等待锁超时为止。 | ||
结论:在可重复读模式下,使用FOR UPDATE会对记录(已存在)加排他锁,其表现与已提交读模式相同。 |
- 测试二:在RC(已提交读)和RR(可重复读)隔离级别下,针对非索引使用 FOR UPDATE会对该记录(已存在),都会对整个表的所有记录都加排他锁,其他事务只能读取数据,不能更新任何数据。
隔离级别(可重复读) | ||
TIME | | | | | | | | v |
终端A | 终端B |
use test; | use test; | |
begin; | begin; | |
select * from t where Fname='wang' for update; | ||
select * from t where Fname='wang' for update; | ||
提示:此处被阻塞 | ||
select * from t where Fname='loleina' for update; | ||
提示:此处被阻塞 | ||
commit; | ||
commit; | ||
现象:在B上执行的第一个select查询被阻塞,第二个select也被阻塞,直等到终端A的事务被提交或者等待锁超时为止。 | ||
结论:在可重复读的模式下,针对非索引的字段使用FOR UPDATE会对所有记录(已存在)加排他锁。 | ||
隔离级别(已提交读) | ||
TIME | | | | | | v |
终端A | 终端B |
use test; | use test; | |
begin; | begin; | |
select * from t where Fname='wang' for update; | ||
select * from t where Fname='wang' for update; | ||
提示:此处被阻塞 | ||
select * from t where F name='loleina' for update; |
||
提示:此处被阻塞 | ||
commit; | ||
commit; | ||
现象:在B上执行的第一个select被阻塞,第二个select也会被阻塞,直等到终端A的事务被提交或者等待锁超时为止。 | ||
结论:在提交读模式下,对非索引字段使用FOR UPDATE会对该表的所有记录(已存在)加排他锁,其表现与已提交读模式相同。 |
- 测试三:在RC(已提交读)和RR(可重复读)隔离级别下,使用唯一索引锁定不存在记录。RC模式下,使用的是排他锁,会有一个能执行成功,另一个事务插入时提示主键冲突,不影响其他行的插入;而RR模式下会使用意图锁(IX)锁定全表的插入操作,两个事务都不能执行成功,且影响别的记录插入。
隔离级别(可重复读) | ||
TIME | | | | | | | | | | | | | | v |
终端A | 终端B |
use test; | use test; | |
begin; | begin | |
select * from T where Fid=3 for update; | select * from T where Fid=3 for update; | |
insert into T(Fid, Fname) values(3, ‘bad’); | ||
提示:此处被阻塞 | ||
insert into T(Fid, Fname) values(3, ‘bad’); | ||
提示:此处发生死锁,事务被重启 | ||
insert into T(Fid, Fname) values(4, ‘bad’); | ||
提示:此处发生死锁,事务被重启 | ||
提示:B的事务被重启后,阻塞被解除 | ||
commit; | ||
现象:在A和B上分别用FOR UPDATE锁定不存在的记录时,没有阻塞,但是当A执行插入操作时被阻塞;此时如果B继续执行插入,则B报死锁,事务被重启,由于B事务重启会释放B占有的锁,A的阻塞被解除。 | ||
个人理解:在重复读模式下,当使用FOR UPDATE锁定不存在的唯一索引时,InnoDB认为用户企图插入新的记录,会使用意图锁(IX)锁定全表的插入操作,以阻塞其他插入请求(不影响已存在记录的更新)。由于IX和IX是兼容的,因此,多个FOR UPDATE是被允许的,当A执行insert操作时,A必须获得X锁,由于B已经获得了IX锁,所以A被阻塞,然后B也企图获得X锁,InnoDB检测到死锁,从而重启B的事务并释放B的锁资源。 | ||
结论:在可重复读模式下,使用FOR UPDATE对不存在的记录加锁时,InnoDB不会阻塞在FOR UPDATE处,从而在使用FOR UPDATE+INSERT的模式执行插入时,在并发条件下几乎不可避免的会导致死锁的发生。 | ||
隔离级别(已提交读) | ||
TIME | | | | | | v |
终端A | 终端B |
use test; | use test; | |
begin; | begin | |
select * from T where Fid=3 for update; | select * from T where Fid=3 for update; | |
Insert into T(Fid,Fname) values(3, ‘ok’) | ||
Insert into T(Fid,Fname) values(3, ‘bad’) | ||
提示:被阻塞 | ||
commit | ||
提示:报主键冲突 | ||
现象:在B上执行的select未被阻塞,A上的插入操作可以执行,B的插入操作被阻塞,当A的事务被提交时,B的插入操作会继续,然后报主键冲突。 | ||
个人理解:在已提交读模式下锁定不存在的唯一索引使用的是排他锁(非间隙),而记录并不存在,所以并不会对记录加锁。为了进一步验证上述猜测,将B的主键键值改为其他值,发现A和B的操作都能顺利进行,说明这种情况下InnoDB确实没有使用间隙锁来禁止插入操作。 | ||
结论:在已提交读模式下,用唯一索引进行FOR UPDATE操作不能阻止插入重复记录,可能会导致主键冲突。 |
下面的测试需要使用非唯一索引,为此建立一张新表:
CREATE TABLE `H` ( `Fid` int(11) DEFAULT NULL, `Fname` varchar(24) DEFAULT NULL, KEY `Fid` (`Fid`,`Fname`) ) ENGINE=InnoDB DEFAULT CHARSET=gbk insert into H(Fid, Fname) values(1, 'superman'); insert into H(Fid, Fname) values(2, 'JACK');
- 测试四:在RC(已提交读)和RR(可重复读)隔离级别下,使用非唯一索引锁定存在记录。都会对该条记录加排他锁,其他事务只能读取数据,不能更新数据。跟测试一针对唯一索引锁定存在记录一样。
隔离级别(可重复读) | ||
TIME | | | | | | | | | | | v |
终端A | 终端B |
use test; | use test; | |
begin; | begin | |
select * from H where Fid=1 for update; | ||
select * from H where Fid=2 for update; | ||
select * from H where Fid=1 for update; | ||
提示:此处被阻塞 | ||
commit; | ||
commit; | ||
现象:在B上执行的第一个select查询顺利执行,但是第二个select被阻塞,直等到终端A的事务被提交或者等待锁超时为止。 | ||
结论:在可重复读模式下,使用FOR UPDATE对不存在的记录加锁时,InnoDB会使用排他锁对索引进行加锁,阻止其他事务对该索引的加锁操作,但是并不会对其他非相关索引值设置间隙锁。 | ||
隔离级别(已提交读) | ||
TIME | | | | | | v |
终端A | 终端B |
use test; | use test; | |
begin; | begin | |
select * from H where Fid=1 for update; | ||
select * from H where Fid=1 for update; | ||
提示:在此处会被阻塞 | ||
commit | ||
现象:在B上执行的select被阻塞,直等到终端A的事务被提交或者等待锁超时为止。 | ||
结论:使用非唯一索引(或联合唯一索引的部分字段)锁定已存在记录时,已提交读和可重复读的锁特性并没有区别。 |
- 测试五:在RC(已提交读)和RR(可重复读)隔离级别下,使用非唯一索引锁定不存在记录。RC模式下,使用的是排他锁,会有一个能执行成功,另一个事务插入时提示主键冲突,不影响其他行的插入;而RR模式下会使用意图锁(IX)锁定全表的插入操作,两个事务都不能执行成功,且影响别的记录插入。
隔离级别(可重复读) | ||
TIME | | | | | | | | | | | | | v |
终端A | 终端B |
use test; | use test; | |
begin; | begin | |
select * from H where Fid=3 for update; | ||
select * from H where Fid=3 for update; | ||
insert into H(Fid, Fname) values(3, ‘bad‘) | ||
提示:此处被阻塞 | insert into H(Fid, Fname) values(3, ‘bad‘) | |
insert into H(Fid, Fname) values(4, ‘bad‘) | ||
提示:此处出现死锁 | ||
现象:在B上执行的select查询顺利执行,但是A上的insert语句会被阻塞,需要注意的是,即使将B的Fid改为其他不存在的值,A上的insert也同样会被阻塞。 | ||
个人理解:用FOR UPDATE锁定不存在的记录时,InnoDB认为用户企图插入新的记录,会使用意图锁(IX)锁(锁间隙)定全表的插入操作,以阻塞其他插入请求(不影响已存在记录的更新)。由于IX和IX是兼容的,因此,多个FOR UPDATE是被允许的,当A执行insert操作时,A必须获得X锁,由于B已经获得了IX锁,所以A被阻塞,然后B也企图获得X锁,InnoDB检测到死锁,从而重启B的事务并释放B的锁资源。 | ||
结论:使用非唯一索引锁定不存在记录时,会锁定索引对应的全部记录。 | ||
隔离级别(已提交读) | ||
TIME | | | | | | | | | | | | | | | | v |
终端A | 终端B |
use test; | use test; | |
begin; | begin | |
select * from H where Fid=3 for update; | select * from H where Fid=3 for update; | |
insert into H(Fid, Fname) values(3, 'Tom'); | ||
insert into H(Fid,Fname) values(3, ‘Win’); | ||
提示:此处被阻塞,直到A提交或超时 | ||
insert into H(Fid, Fname) values(4, 'Tom'); | ||
commit | commit | |
现象:在B中第一个事务里边的insert语句会被阻塞,若A执行提交,则B的insert会报主键冲突;而第二个事务中A和B的insert都可以成功。 | ||
分析:由于在已提交读隔离级别下,使用非唯一索引时, A和B的FOU UPDAET蜕化为共享锁,因此SELECT语句都可以顺利执行;第一个事务中,当A执行insert操作时,对索引是使用了排他锁(非间隙锁),所以,B的插入操作被阻塞;第二个事务当中,A和B的insert操作虽然都是排他锁,但是由于针对的是不同索引,所以互相不影响。 | ||
结论:在已提交读隔离级别下,使用FOR UPDATE并不能锁定记录的插入,可能会出现主键冲突的错误。 |
测试五和测试三说明:在RC(已提交读)和RR(可重复读)隔离级别下,不管是使用唯一索引还是非唯一索引锁定不存在记录。RC模式下,使用的是排他锁,会有一个能执行成功,另一个事务插入时提示主键冲突,不影响其他行的插入;而RR模式下会使用意图锁(IX)锁定全表的插入操作,两个事务都不能执行成功,且影响别的记录插入。
测试四和测试一说明:在RC(已提交读)和RR(可重复读)隔离级别下,不管是唯一索引还是使用非唯一索引锁定存在记录。都会对该条记录加排他锁,其他事务只能读取数据,不能更新数据。跟测试一针对唯一索引锁定存在记录一样。
测试二说明:使用非索引字段,在任何隔离级别下,都会锁全表。