1.概述
怎么解决脏读
、不可重复读
、幻读
这些问题呢?其实有两种可选的解决方案
方案一 :读操作利用多版本并发控制(MVCC),写操作进行加锁。
所谓的MVCC
,就是生成一个ReadView
,通过ReadView找到符合条件的记录版本(历史版本由undo日志
构建)。查询语句只能读
到在生成ReadView之前已提交事务所做的更改
,在生成ReadView之前未提交的事务或者之后才开启的事务所做的更改是看不到的。而写操作
肯定针对的是最新版本的记
录,读记录的历史版本和改动记录的最新版本本身并不冲突,也就是采用MVCC时,读-写
操作并不冲突。
普通的SELECT语句在READ COMMITTED和REPEATABLE READ隔离级别下会使用到MVCC读取记录。
在
READ COMMITTED
隔离级别下,一个事务在执行过程中每次执行SELECT操作时都会生成一个ReadView,ReadView的存在本身就保证了事务不可以读取到未提交的事务所做的更改
,也就是避免了脏读现象;在
REPEATABLE READ
隔离级别下,一个事务在执行过程中只有第一次执行SELECT操作才会生成一个ReadView,之后的SELECT操作都复用
这个ReadView,这样也就避免了脏读,不可重复读和幻读的问题。
方案二:读、写操作都采用加锁
的方式。
比如,在银行存款的事务中,你需要先把账户的余额读出来,然后将其加上本次存款的数额最后再写到数据库中。在将账户余额读取出来后,就不想让别的事务再访问该余额,直到本次存款事务执行完成,其他事务才可以访问账户的余额。这样在读取记录的时候就需要对其进行加锁
操作,这样也就意味着读
操作和写
操作也像写-写
操作那样排队
执行。
脏读 例如a事务读取了b未提交事务。如果a事务负责读,b事务负责写,b事务从事务开启到关闭中间的过程,事务a不能对事务b进行读,因为加锁了。
不可重复读 例如a事务先读取一条记录,b事务对该记录做了改动之后并提交之后,a事务再次读取时会获得不同的值。如果a事务读取记录时就给该记录加锁,那么另一个事务就无法修改该记录,自然也不会发生不可重复读了。
幻读 例如A事务读取了一个范围的内容,而同时B事务在此期间插入了一条数据.造成幻觉。如果采用加锁的方式解决幻读问题就有一些麻烦,因为当前事务在第一次读取记录时幻影记录并不存在,所以读取的时候加锁就有点尴尬(因为你并不知道给谁加锁)。
对比发现:
-
采用
MVCC
方式的话,读-写
操作彼此并不冲突,性能更高
。 -
采用
加锁
方式的话,读-写
操作彼此需要排队执行
,影响性能。
一般情况下我们当然愿意采用MVCC
来解决读-写
操作并发执行的问题,但是业务在某些特殊情况下,要求必须采用加锁
的方式执行。下面就讲解下MySQL中不同类别的锁。
2. 锁的不同角度分类
锁的分类图,如下:
共享锁(S锁)
共享锁(Share Locks,简记为S锁)又被称为读锁
,其他事务可以并发读取数据,但任何事务都不能获取数据上的排他锁(只能加共享锁,不能加排他锁),直到已释放所有共享锁。
列如:事务 A对数据对象 T 加上 S锁,则事务 A 只能读 T;其他事务只能再对 A 加 S锁,而不能加 X锁,直到A释放T上的S锁。这就保证了其他事务可以读A,但在A释放T上的S锁之前不能对T做任何修改。
select ... lock in share mode; #添加s锁
排他锁(X锁):
排它锁((Exclusive lock,简记为X锁))又称为写锁,当前写操作没有完成前,它会阻断其他事务写锁和读锁。这样就能确保在给定的时间里,只有一个事务能执行写入,并防止其他用户读取正在写入的同一资源。
列如:事务A对数据对象 T加上 X锁,则只允许A 读取和修改T,其它任何事务都不能再对 T 加任何类型的锁,直到A 释放T上的锁。它防止任何其它事务获取资源上的锁,直到在事务的末尾将资源上的原始锁释放为止。再更新操作(INSERT、UPDATE 或 DELETE)过程中始终应用排它锁。
select ... for update; #添加X锁
在 MySQL
中,update
,delete
,insert
,alter
这些写的操作默认都会加上排他锁。select
默认不会加任何锁类型。一旦写数据的任务没有完成,数据是不能被其他任务读取的,这对并发操作有较大的影响。
表级锁
表级锁应用在 MyISAM、InnoDB、BDB 等存储引擎中,每次操作锁住整张表。
优点:开销小,加锁快;不会出现死锁。
缺点:锁定粒度大,发生锁冲突的概率最高,并发度最低。
1.表级别的S锁、X锁
MyISAM在执行查询语句(SELECT)前,会给涉及的所有表加读锁,在执行增删改操作前,会给涉及的表加写锁。InnoDB存储引擎是不会为这个表添加表级别的读锁
或者写锁
的。(有行锁,谁TM用表锁啊)
不过尽量避免在使用InnoDB存储引擎的表上使用LOCK TABLES
这样的手动锁表语句,它们并不会提供什么额外的保护,只是会降低并发能力而已。InnoDB的厉害之处还是实现了更细粒度的行锁,关于InnoDB表级别的S锁和X锁大家了解一下就可以了。
show open tables where in_use > 0; # 查看哪些表被锁了
lock tables student read # 加写锁
lock tables student write;
unlock tables; # 表解锁
以下是MySQL的表级锁有两种模式:(以MyISAM表进行操作的演示)
锁类型 | 自己可读 | 自己可写 | 自己可操作其他表 | 他人可读 | 他人可写 |
---|---|---|---|---|---|
读锁 | 是 | 否 | 否 | 是 | 否,等 |
写锁 | 是 | 是 | 否 | 否,等 | 否,等 |
2.意向锁 (intention lock)
InnoDB 支持多粒度锁(multiple granularity locking)
,它允许行级锁与表级锁共存,而 意向锁 就是其中的一种表锁
。
1、意向锁的存在是为了协调行锁和表锁
的关系,支持多粒度(表锁与行锁)的锁并存。
2、意向锁是一种不与行级锁冲突表级锁
,这一点非常重要。
3、表明“某个事务正在某些行持有了锁或该事务准备去持有锁”
1.意向锁要解决的问题
在数据表的场景中,如果我们给某一行数据加上了排它锁,数据库会自动给更大一级的空间,比如数据页或数据表加上意向锁,告诉其他人这个数据页或数据表已经有人上过排它锁了(不这么做的话,想上表锁的那个程序),这样当其他人想要获取数据表排它锁的时候,只需要了解是否有人已经获取了这个数据表的意向排他锁即可
意向共享锁(IS
):事务想要在获得表中某些记录的共享锁,需要在表上先加意向共享锁。
意向互斥锁(IX
):事务想要在获得表中某些记录的互斥锁,需要在表上先加意向互斥锁。
3.自增锁(AUTO-INC锁)
是一种特殊的表级别的锁,专门针对事务插入 auto-increment
类型的列。最简单的情况,如果一个事务正在往表中插入记录,所有其他事务的插入必须等待,以便第一个事务插入的行,是连续的主键值。
4.元数据锁(MDL锁)
MySQL5.5引入了meta data lock,简称MDL锁,MDL 的作用是,保证读写的正确性。比如 一个查询正在一个表中的数据,而执行期间另一个线程对这个表结构做变更
,增加了一列,那么查询线程拿到的结果跟表结构对不上,肯定是不行的,因此,当对一个表做增删改查操作的时候,加 MDL读锁;当要对表做结构变更操作的时候,加 MDL 写锁,不需要显式使用
,在访问一个表的时候会被自动加上。
举例:会话A:
mysql> begin;
Query 0K,0 rows affected (8.00 sec)
# 默认加了MDL 读锁
mysql> select count( 1 ) from teacher ;
会话B:
begin;
# 这个时候就会阻塞在这里,有其他事务在读数据。
# 修改表结构 需要加MDL 写锁
alter table teacher add age int;
元数据带来的并发问题:
Session A | Session B | Session C |
begin; select * from teacher; | begin; alter table teacher add age int; | begin; select * from teacher; |
#会阻塞 | #这个时候会阻塞在这里。本来两个S锁可以并发执行的,但是由于会话B添加X锁导致了阻塞 | |
行级锁
行级锁应用在 InnoDB 存储引擎中,每次锁住一行数据。
优点:锁定粒度小,发生锁冲突的概率最低,并发度最高。
缺点:开销大,加锁慢;会出现死锁。
记录锁(Record Locks)
1,记录锁也就是仅仅把一条记录锁上。记录锁总是会锁住索引记录,如果 InnoDB
存储引擎表,比如我们把id值为 8 的 那条记录加一个记录锁。仅仅是锁住了id值为 8 的记录,对周围的数据没有影响。
2,在建立的时候没有设置任何一个索引,那么InnoDB
存储引擎会使用隐式的主键来进行锁定。
记录锁是有S锁和X锁之分的,称之为
S型记录锁
和X型记录锁
。
当一个事务获取了一条记录的S型记录锁后,其他事务也可以继续获取该记录的S型记录锁,但不可以继续获取X型记录锁;
当一个事务获取了一条记录的X型记录锁后,其他事务既不可以继续获取该记录的S型记录锁,也不可以继续获取X型记录锁。
间隙锁(Gap Locks)
图中id值为 8 的记录加了gap锁,意味着不允许别的事务在id值为 8 的记录前边的间隙插入新记录
,其实就是id列的值( 3 , 8 )这个区间的新记录是不允许立即插入的。比如,有另外一个事务再想插入一条id值为 4 的新记录,它定位到该条新记录的下一条记录的id值为 8 ,而这条记录上又有一个gap锁,所以就会阻塞插入操作,直到拥有这个gap锁的事务提交了之后,id列的值在区间( 3 , 8 )中的新记录才可以被插入。
1,这里是会阻塞,是前面的行锁知识点。
session 1 | session 2 |
---|---|
select *from student where id =8 lock in share mode; | |
select * from student where id = 8 for update; |
2,这里session 2并不会被堵住。因为表里并没有id=5这个记录,因此session 1加的是间隙锁(3,8)。而session 2也是在这个间隙加的间隙锁。它们有共同的目标,即:保护这个间隙,不允许插入值。但,它们之间是不冲突的。
session 1 | session 2 |
---|---|
select *from student where id =5 lock in share mode; | |
select * from student where id =5 for update; |
3,这里就会阻塞了,以为加了间隙锁。
session 1 | session 2 |
---|---|
select *from student where id =5 lock in share mode; | |
insert into student(id, name, class) values (6, 'tom', '三班'); |
临键锁(Next-Key Locks)
有时候我们既想锁住某条记录
,又想阻止
其他事务在该记录前边的间隙插入新记录
,所以InnoDB就提出了一种称之为Next-Key Locks的锁
,官方的类型名称为:LOCK_ORDINARY
,我们也可以简称为next-key锁。Next-Key Locks
是在存储引擎innodb、事务级别在可重复读
的情况下使用的数据库锁,innodb默认的锁就是Next-Key locks。
begin;
select * from student where id <= 8 and id > 3 for update;
插入意向锁(Insert Intention Locks)
我们说一个事务在插入
一条记录时需要判断一下插入位置是不是被别的事务加了间隙锁
(next-key锁
也包含间隙锁
),如果有的话,插入操作需要等待,直到拥有间隙锁
的那个事务提交。但是 InnoDB规 定事务在等待的时候也需要在内存中生成一个锁结构 ,表明有事务想在某个间隙
中插入
新记录,但是 现在在等待。InnoDB就把这种类型的锁命名为Insert Intention Locks,官方的类型名称为:LOCK_INSERT_INTENTION
,我们称为插入意向锁。插入意向锁
是一种Gap
锁
,不是意向锁,在insert 操作时产生。
插入意向锁是在插入一条记录行前,由 INSERT 操作产生的一种间隙锁。
事实上 插入意向锁并不会阻止别的事务继续获取该记录上任何类型的锁。
页级锁
页级锁应用在 DBD 存储引擎中,每次锁住一页数据 - 16KB左右。
特点:开销和加锁时间介于表级和行级之间;会出现死锁,锁定力度介于表锁和行锁之间,并发度一般。
悲观锁和乐观锁
乐观锁
乐观的假定
大概率不会发生并发更新冲突,访问、处理数据的过程中不加锁,只在更新数据时根据版本号
或时间戳
判断是否有冲突,有则处理,无责提交事务,乐观锁
适合读操作多
的场景,相对来说写的操作比较少。它的优点在于程序实现,不存在死锁问题,不过适用场景也会相对乐观,因为它阻止不了除了程序以外的数据库操作。
1.乐观锁的版本号机制
在表中设计一个版本字段 version
,第一次读的时候,会获取 version 字段的取值。然后对数据进行更新或删除操作时,会执行UPDATE ... SET version=version+1 WHERE version=version
。此时如果已经有事务对这条数据进行了更改,修改就不会成功。
2. 乐观锁的时间戳机制
时间戳和版本号机制一样,也是在更新提交的时候,将当前数据的时间戳和更新之前取得的时间戳进行 比较,如果两者一致则更新成功,否则就是版本冲突。
你能看到乐观锁就是程序员自己控制数据并发操作的权限,基本是通过给数据行增加一个戳(版本号或 者时间戳),从而证明当前拿到的数据是否最新。
悲观锁
就是很悲观,对数据被其他事务的修改持保守态度,会通过数据库自身的锁机制来实现,从而保证数据操作的排它性。悲观锁
适合写操作多
的场景,因为写的操作具有排它性
。采用悲观锁的方式,可以在数据库层
面阻止其他事务对该数据的操作权限,防止读 - 写
和写 - 写
的冲突。
悲观锁总是假设最坏的情况,每次在拿数据的时候都会上锁,都会认为别人会修改,所以,这样别人想拿这个数据就会阻塞
直到它拿到锁( 共享资源每次只给一个线程使用,其它线程阻塞,用完后再把资源转让给其它线程 )。比如行锁,表锁等,读锁,写锁等,都是在做操作之前先上锁,当其他线程想要访问数据时,都需要阻塞挂起。Java中synchronized
和ReentrantLock
等独占锁就是悲观锁思想的实现。
按加锁的方式划分:显式锁、隐式锁
隐式锁
-
情景一: 对于聚簇索引记录来说,有一个trx_id隐藏列,该隐藏列记录着最后改动该记录的事务id。那么如果在当前事务中新插入一条聚簇索引记录后,该记录的trx_id隐藏列代表的的就是当前事务的事务id,如果其他事务此时想对该记录添加S锁或者X锁时,首先会看一下该记录的trx_id隐藏列代表的事务是否是当前的活跃事务,如果是的话,那么就帮助当前事务创建一个X锁(也就是为当前事务创建一个锁结构,is_waiting属性是false),然后自己进入等待状态(也就是为自己也创建一个锁结构,is_waiting属性是true)。
-
情景二: 对于二级索引记录来说,本身并没有trx_id隐藏列,但是在二级索引页面的PageHeader部分有一个PAGE_MAX_TRX_ID属性,该属性代表对该页面做改动的最大的事务id,如果PAGE_MAX_TRX_ID属性值小于当前最小的活跃事务id,那么说明对该页面做修改的事务都已经提交了,否则就需要在页面中定位到对应的二级索引记录,然后回表找到它对应的聚簇索引记录,然后再重复情景一的做法。
session 1:
mysql> begin;
Query OK, 0 rows affected (0.00 sec)
mysql> insert INTO student VALUES( 34 ,"周八","二班");
Query OK, 1 row affected (0.00 sec)
session 2:
mysql> begin;
Query OK, 0 rows affected (0.00 sec)
mysql> select * from student lock in share mode; #执行完,当前事务被阻塞
执行下述语句,输出结果:
mysql> SELECT * FROM performance_schema.data_lock_waits\G;
*************************** 1. row ***************************
ENGINE: INNODB
REQUESTING_ENGINE_LOCK_ID: 140562531358232 :7:4:9:
REQUESTING_ENGINE_TRANSACTION_ID: 422037508068888
REQUESTING_THREAD_ID: 64
REQUESTING_EVENT_ID: 6
REQUESTING_OBJECT_INSTANCE_BEGIN: 140562535668584
BLOCKING_ENGINE_LOCK_ID: 140562531351768 :7:4:9:
BLOCKING_ENGINE_TRANSACTION_ID: 15902
BLOCKING_THREAD_ID: 64
BLOCKING_EVENT_ID: 6
BLOCKING_OBJECT_INSTANCE_BEGIN: 140562535619104
1 row in set (0.00 sec)
显式锁
通过特定的语句进行加锁,我们一般称之为显示加锁,例如:
显示加共享锁:
select .... lock in share mode
显示加排它锁:
select .... for update