本文的主要内容是介绍锁,包括锁的背景(事务),锁面向的问题,锁的实现与分类等。
这里的引擎指的是InnoDB。
背景
锁的大背景是事务对隔离性的要求,这里我们简单介绍一下事务。
事务机制存在的目的就是无论我们的操作过程中是成功、失败、异常、或是受到干扰的情况下,事务都能保证我们数据最终的一致性。
事务有著名的4大特性:ACID,分别是原子性(Atomicity)、隔离性(Isolation)、一致性(Consistency)、持久性(Durability)。一致性是事务的最终目的,而原子性、隔离性、持久性其实都是为了实现一致性的手段。
- 原子性:一个事务必须是一系列操作的最小单元,这系列操作的过程中,要么整个执行,要么整个回滚,不存在只执行了其中某一个或者某几个步骤。
- 隔离性:两个事务的执行都是独立隔离开来的,事务之前不会相互影响,多个事务操作一个对象时会以串行等待的方式保证事务相互之间是隔离。
- 一致性:事务要保证数据库整体数据的完整性和业务的数据的一致性,事务成功提交整体数据修改,事务错误则回滚到数据回到原来的状态。整体对应着分步,这些分步要么都成功,要么你都不成功,回到原状态。
- 持久性:持久性是指一旦事务成功提交后,只要修改的数据都会进行持久化。
锁这种机制主要针对隔离性。
隔离级别
问题
下边来看看锁面向的问题:
-
脏读(Dirty Read)问题
出现原因:本事务读到了其他事务没有提交的数据。
示例:

如上图,A读取到了尚未提交事务B的数据,造成最后结果的不一致。
-
不可重复读(NonRepeatable Read)问题
出现原因:本事务读后的数据,被其他事务修改了。
这个名字叫不可重复读,被其他事务修改后,若本事务再次读,发现前后2次读的内容不一致。这回带来什么问题呢?
若第2次的操作不是读,而是基于第1次读的修改操作,就会出现不一致问题。如下图:

本事务读+写2个操作,读完、写前,被B事务修改了数据,造成不一致。
-
幻读(Phantom Read)问题
出现原因:本事务读后的数据,被其他事务又插入了新的数据。
幻读的其实跟不可重复读很相似,本事务读取数据后,又被其他事务插入的新的数据,若事务第2次的操作基于前次的读操作,就会发现有不一致的问题。

幻读其实与重复读很像,只不过一个是新增数据(幻读),一个是修改数据(重复读)。
标准
隔离级别更像是一种处理标准,在这种标准上,能够解决一些上述的问题。
隔离级别包括:
-
READ_UNCOMMITTED(读未提交)
对同一数据,写的时候不能写。这种情况只限制了两个数据不能同时修改,但是修改数据的时候是可以读取的,这级别的事务隔离有脏读、重复读、幻读的问题;
-
READ_COMMITTED(读提交)
对同一数据,写的时候不能读。这种隔离级别的事务限制了修改数据的时候不允许读取,但是读取数据的时候是可以进行修改的,所以这种事务的隔离级别解决了脏读问题,但会存在重复读、幻读问题;
-
REPEATABLE_READ(可重复读)
对同一数据,写的时候不能读,读的时候也不能写。这种隔离级别的事务既限制修改数据时不允许读,也限制了读取数据的时候也不可以进行修改,所以解决了重复读的问题,但是读取范围数据的时候可以插入数据,所以还会存在幻读问题;
-
SERIALIZABLE (序列化)
对所有数据,所有操作都是串行的。事务最高隔离级别,在该级别下,所有事务都是进行串行化顺序执行,这样就可以避免脏读、不可重复读与幻读所有问题了。但是这种事务隔离级别下事务执行的效率低下,比较耗数据库性能。
隔离级别 | 脏读(Dirty Read) | 不可重复读(NonRepeatable Read) | 幻读(Phantom Read) |
未提交读(Read uncommitted) | 可能 | 可能 | 可能 |
已提交读(Read committed) | 不可能 | 可能 | 可能 |
可重复读(Repeatable read) | 不可能 | 不可能 | 可能 |
可串行化(Serializable ) | 不可能 | 不可能 | 不可能 |
锁
那如何来实现这种隔离的控制呢,这就用到了锁。InnoDB中的锁与Linux中的锁很相似,但也有一些差别,这里简单来回顾一下Linux中常用互斥锁与读写锁。
- 互斥锁:使用者使用互斥锁时在访问共享资源之前对进行加锁操作,在访问完成之后进行解锁操作,谁加锁谁释放,其他使用者没有释放权限。 加锁后,任何其他试图再次加锁的线程会被阻塞,直到当前进程解锁
- 读写锁:读模式共享和写模式互斥。在数据没有被写的前提下,多个使用者读取时完全不需要加锁的,在写加锁模式下,任何试图对这个锁进行加锁的线程都会被阻塞,直到写进程对其解锁。
从上图可以看出,那么InnoDB中的锁的属性与Linux中的读写锁是一致的,但InnoDB的锁还有不同的粒度。根据不同的粒度可以分成:表锁、行锁、记录锁、间隙锁、临键锁。
读写锁
来细看一下读写锁。
读锁(共享锁 S-shared):

写锁(排他锁 X-exclusive):

读写锁解决了写的时候不能读的问题,读的时候也不能写。由这两把锁,起码可达到可重复的隔离级别。
行锁
-
定义
行锁是指上锁的时候锁住的是表的某一行或多行记录,其他事务访问同一张表时,只有被锁住的记录不能访问,其他的记录可正常访问;
特点:粒度小,加锁比表锁麻烦,不容易冲突,相比表锁支持的并发要高;
-
原理
InnoDB存储引擎支持的是行级别的锁,InnoDB 行锁是通过给索引上的索引项加锁来实现的。(这一点 MySQL 与 Oracle 不同,后者是通过在数据块中对相应数据行加锁来实现的)
InnoDB 这种行锁实现特点意味着:只有通过索引条件检索数据,InnoDB 才使用行级锁,否则,InnoDB 将使用表锁!
不论是使用主键索引、唯一索引或普通索引,InnoDB 都会使用行锁来对数据加锁。
-
类型
行锁根据不同的情况,可以进行细分:
-
记录锁
记录锁是行锁中的一种,只不过记录锁的范围只是表中的某一条记录,记录锁是说事务在加锁后锁住的只是表的某一条记录。
**触发条件:**精准条件命中,并且命中的条件字段是唯一索引;
**例如:**update user_info set name=’张三’ where id=1 ,这里的id是唯一索引。
-
间隙锁
间隙锁属于行锁中的一种,间隙锁是在事务加锁后其锁住的是表记录的某一个区间,当表的相邻ID之间出现空隙则会形成一个区间,遵循左开右闭原则。
触发条件:范围查询并且查询未命中记录,查询条件必须命中索引,间隙锁只会出现在REPEATABLE_READ(重复读)的事务级别中。
例如:对应上图的表执行select * from user_info where id>1 and id<4(这里的id是唯一索引) ,这个SQL查询不到对应的记录,那么此时会使用间隙锁。
-
临键锁
临键锁也属于行锁的一种,并且它是INNODB的行锁默认算法,总结来说它就是记录锁和间隙锁的组合,临键锁会把查询出来的记录锁住,同时也会把该范围查询内的所有间隙空间也会锁住,再之它会把相邻的下一个区间也会锁住。
触发条件:范围查询并命中,查询命中了索引。
**临键锁的作用:**结合记录锁和间隙锁的特性,临键锁避免了在范围查询时出现脏读、重复读、幻读问题。加了临键锁之后,在范围区间内数据不允许被修改和插入。
表锁
-
定义
表锁是指上锁的时候锁住的是整个表,当下一个事务访问该表的时候,必须等前一个事务释放了锁才能进行对表进行访问;
特点: 粒度大,加锁简单,容易冲突;
-
原理
事务需要更新大部分或全部数据,表又比较大,如果使用默认的行锁,不仅这个事务执行效率低,而且可能造成其他事务长时间锁等待和锁冲突,这种情况下可以考虑使用表锁来提高该事务的执行速度。
- 表锁不是由 InnoDB 存储引擎层管理的,而是由其上一层──MySQL Server 负责的。仅当 autocommit=0(不自动提交,默认是自动提交的)、InnoDB_table_locks=1(默认设置)时,InnoDB 层才能知道 MySQL 加的表锁,MySQL Server 也才能感知 InnoDB 加的行锁。这种情况下,InnoDB 才能自动识别涉及表级锁的死锁,否则,InnoDB 将无法自动检测并处理这种死锁。
- 在用 LOCK TABLES 对 InnoDB 表加锁时要注意,要将 AUTOCOMMIT 设为 0,否则 MySQL 不会给表加锁。事务结束前,不要用 UNLOCK TABLES 释放表锁,因为 UNLOCK TABLES 会隐含地提交事务。COMMIT 或 ROLLBACK 并不能释放用 LOCK TABLES 加的表级锁,必须用 UNLOCK TABLES 释放表锁。
-
使用
SET AUTOCOMMIT=0;
LOCK TABLES t1 WRITE, t2 READ, ...;
[do something with tables t1 and t2 here];
COMMIT;
UNLOCK TABLES;
意向锁
意向锁(Intention Locks)分成两种:意向共享锁(意向读锁),意向排他锁(意向写锁),而且都是表锁。
意向锁主要作用是处理行锁和表锁之间的矛盾,能够显示“某个事务正在某一行上持有了锁,或者准备去持有锁”。
举个例子,如果表中记录1亿,事务A把其中有几条记录上了行锁了,这时事务B需要给这个表加表级锁,如果没有意向锁的话,那就要去表中查找这一亿条记录是否上锁了。如果存在意向锁,那么假如事务A在更新一条记录之前,先加意向锁,再加X锁,事务B先检查该表上是否存在意向锁,存在的意向锁是否与自己准备加的锁冲突,如果有冲突,则等待直到事务A释放,而无须逐条记录去检测。事务B更新表时,其实无须知道到底哪一行被锁了,它只要知道反正有一行被锁了就行了。
MVCC
MVCC(Multi-Version Concurrency Control)多版本并发控制,可以看成对锁的一种反。通过加锁基本达到了事务对隔离性的要求,但读数据的时候没办法修改,修改数据的时候没办法读取,降低了数据库性能,尤其在高并发的情况下。这样,MVCC就出现了,它实现了读取数据不用加锁, 可以让读取数据同时修改,修改数据时同时可读取。参考
几个基本概念
-
事务ID
每次事务开启前都会从数据库获得一个自增长的事务ID,可以从事务ID判断事务的执行先后顺序。
-
表格隐藏列
DB_TRX_ID: 记录操作该数据事务的事务ID;
**DB_ROLL_PTR:**指向上一个版本数据在undo log 里的位置指针;
DB_ROW_ID: 隐藏ID ,当创建表没有合适的索引作为聚集索引时,会用该隐藏ID创建聚集索引,这个与MVCC无关;
-
Undo Log
Undo log 主要用于记录数据被修改之前的日志,在表信息修改之前先会把数据拷贝到undo log 里,当事务进行回滚时可以通过undo log 里的日志进行数据还原。
在MVCC多版本控制中,通过读取undo log的历史版本数据可以实现不同事务版本号都拥有自己独立的快照数据版本。
举个例子来看一下这几个概念:
user表中原始字段:

开启一个事务A: 对user_info表执行 update user_info set name =“李四”where id=1之后。

Read view
在innodb 中每个SQL语句执行前都会得到一个read_view,就是一个读的副本,它包括一下几个重要属性:
-
数据
creator_trx_id: 创建当前read view的事务版本号;
low_limit_id: 创建当前read view 时“当前系统最大事务版本号+1”。
up_limit_id: 创建当前read view 时“系统正处于活跃事务最小版本号”
trx_ids: 当前系统活跃(未提交)事务版本号集合。
-
算法
-
数据事务ID <up_limit_id 则显示
如果数据事务ID小于read view中的最小活跃事务ID,则可以肯定该数据是在当前事务启之前就已经存在了的,所以可以显示。
-
数据事务ID>=low_limit_id 则不显示
如果数据事务ID大于read view 中的当前系统的最大事务ID,则说明该数据是在当前read view 创建之后才产生的,所以数据不予显示。
-
up_limit_id <数据事务ID<low_limit_id 则与活跃事务集合trx_ids里匹配
如果数据的事务ID大于最小的活跃事务ID,同时又小于等于系统最大的事务ID,这种情况就说明这个数据有可能是在当前事务开始的时候还没有提交的。这时候我们需要把数据的事务ID与当前read view 中的活跃事务集合trx_ids 匹配。
情况1: 如果事务ID不存在于trx_ids 集合(则说明read view产生的时候事务已经commit了),这种情况数据则可以显示。
情况2: 如果事务ID存在trx_ids则说明read view产生的时候数据还没有提交,但是如果数据的事务ID等于creator_trx_id ,那么说明这个数据就是当前事务自己生成的,自己生成的数据自己当然能看见,所以这种情况下此数据也是可以显示的。
情况3: 如果事务ID既存在trx_ids而且又不等于creator_trx_id那就说明read view产生的时候数据还没有提交,又不是自己生成的,所以这种情况下此数据不能显示。
原理
