第15章 锁
1. 概述
锁
是计算机协调多个进程或线程并发访问某一资源
的机制。在程序开发过程中会存在多线程同步的问题,当多线程并发访问某个数据的时候,需要保证这个数据在任何时候最多只有一个线程
访问,以保证数据的完整性
和一致性
。
数据库中,数据也是一种供许多用户共享的资源
。为保证数据的一致性,需要对 并发操作进行控制
,因此产生了 锁
。同时 锁机制
也为实现MySQL 的各个隔离级别提供了保证。 锁冲突
也是影响数据库 并发访问性能
的一个重要因素。
事务的隔离性是由锁
来实现的。
2. MySQL并发事务访问相同记录
并发事务访问相同记录的情况大致可以划分为3种:
2.1 读-读情况
读-读
情况,即并发事务相继读取相同的记录
。读取操作本身不会对记录有任何影响,并不会引起什么问题,所以允许这种情况的发生。
2.2 写-写情况
脏写
是任何一种隔离级别都不允许发生的问题。在多个未提交事务相继对一条记录做改动时,需要让它们 排队执行
, 这个排队的过程其实是通过锁
来实现的。这个所谓的锁其实是一个内存中的结构,在事务执行前本来是没有锁的,一开始是没有锁结构
与记录
进行关联的。
当一个事务想对这条记录做改动时,首先会看看内存中有没有与这条记录关联的 锁结构
,当没有的时候就会在内存中生成一个 锁结构
与之关联。比如,事务 T1
要对这条记录做改动,就需要生成一个 锁结构
与之关联:
锁结构
有很多信息,其中比较重要的属性是:
trx信息
:代表这个锁结构是哪个事务生成的。is_waiting
:代表当前事务是否在等待。- 事务1改动了某条记录,就生成一个
锁结构
与之关联,此时无其他事务为这条记录加锁,is_waiting = false
,加锁成功,继续执行操作。 - 事务2也想对该记录做出变动,发现有与这条记录关联的
锁结构
,也生成一个锁结构
与这条记录关联,并is_waiting = true
,表示当前事务需要等待,这种情况就叫做加锁失败
。 - 等待事务1提交后释放
锁结构
,事务2在等待获取锁,就将事务2的锁结构中的is_waiting = false
,然后将对应的线程唤醒,继续执行,此时事务2就获得了锁。
小结几种说法:
-
不加锁
意思就是不需要在内存中生成对应的
锁结构
,可以直接执行操作。 -
获取锁成功,或者加锁成功
意思就是在内存中生成了对应的
锁结构
,而且锁结构的is_waiting
属性为false
,也就是事务 可以继续执行操作。 -
获取锁失败,或者加锁失败,或者没有获取到锁
意思就是在内存中生成了对应的
锁结构
,不过锁结构的is_waiting
属性为true
,也就是事务 需要等待,不可以继续执行操作。
2.3 读-写/写-读情况
读-写
或 写-读
,即一个事务进行读取操作,另一个进行改动操作。这种情况下可能发生 脏读 、 不可重 复读 、 幻读
的问题。
2.4 并发问题的解决方案
怎么解决脏读、不可重复读、幻读
问题?有两种解决方案
- 方案1:读操作使用多版本并发控制,写操作进行
加锁
。- 所谓的
MVCC
,就是生成一个ReadView
, 通过ReadView
找到符合条件的历史记录版本(由undo
日志构建),查询语句只能读
到在生成ReadView之前已经提交事务所做的更改
,在生成ReadView之前未提交或之后才开启的事务所做的修改是不可见的。而写操作
针对的是最新版本的记录
,读记录的历史版本和改动记录的最新版本并不冲突,也就是说,采用MVCC使得读-写
操作不冲突。
- 所谓的
普通的SELECT语句在
READ COMMITTED
和REPEATABLE READ
隔离级别下会使用到MVCC读取记录。
- 在
READ COMMITTED
隔离级别下,一个事务在执行过程中每次执行SELECT操作时都会生成一 个ReadView,ReadView的存在本身就保证了事务不可以读取到未提交的事务所做的更改
,也就是避免了脏读现象;- 在
REPEATABLE READ
隔离级别下,一个事务在执行过程中只有第一次执行SELECT操作
才会生成一个ReadView,之后的SELECT操作都复用
这个ReadView,这样也就避免了不可重复读和幻读的问题。
- 方案二:读、写操作都采用
加锁
的方式。- 一些特殊的业务场景下不允许读取记录的旧版本,而每次都必须读取
记录的最新版本
,直到本次事务执行完成之前,其他业务都不可以访问该条记录。这样就需要在读取记录时进行加锁
的操作,这样使得读-写
操作和写-写
操作那样排队执行
。
小结对比发现:
- 一些特殊的业务场景下不允许读取记录的旧版本,而每次都必须读取
- 采用
MVCC
方式的话, 读-写 操作彼此并不冲突, 性能更高 。 - 采用
加锁
方式的话, 读-写 操作彼此需要排队执行
,影响性能。
3. 锁的不同角度的分类
3.1 数据操作类型上划分:读锁和写锁
使用加锁
的方式解决问题,既要允许读-读
情况不受影响,而读-写
、写-写
情况相互阻塞
。MySQL使用两种类型的锁来解决
- 读锁:也称为共享锁(Shared Lock),用S表示;针对同一份数据,多个事务的读操作不会互相影响且不会相互阻塞
- 写锁:也称为排他锁(Exclusive Lock),用X表示。当写的操作没有完成之前,会阻断其他写锁和读锁。这样就能确保给定的时间内,只有一个事务能执行写入,防止其他用户读取正在写入的同一资源。
需要注意的是,对于InnoDB引擎,读锁和写锁可以加在表上,也可以加在行上。
1. 锁定读
- 对读取的记录加上S锁(共享锁)
SELECT .. LOCK IN SHARE MODE;
当前事务执行该语句,会将读取到的记录加S锁
,这样允许别的事务继续获取这些记录的S锁
而不能获取X锁
。如果别的事务想获取这些记录的X锁
,那么将会阻塞直到当前事务提交之后将这些记录上的S锁
释放掉。
-
- 对读取的记录加上X锁(排他锁)
SELECT ... FOR UPDATE
当前事务执行了该语句,那么它会读取到的记录加X锁
,这样既不允许别的事务获取这些记录的S锁,也不允许其他事务获取这些记录的X锁。如果别的事务想获取这些记录的S锁
或X锁
,那么它们会阻塞,直到当前事务提交之后将这些记录上的X锁
释放掉。
2.写操作
3.2 从数据操作的粒度划分:表级锁、页级锁、行锁
锁粒度
:在并发响应
和资源
两方面进行平衡。
1. 表锁(Table Lock)
① 表级别的S锁、X锁
InnoDB很少提供表级别的S锁和X锁,在特殊情况如崩溃恢复
时会用到。
MyISAM在执行查询语句(SELECT)前,会给涉及的所有表加读锁,在执行增删改操作前,会给涉及的表加写锁。
InnoDB存储引擎是不会为这个表添加表级别的读锁和写锁的。
② 意向锁 (intention lock)
InnoDB 支持 多粒度锁(multiple granularity locking)
,它允许 行级锁
与 表级锁
共存,而意向锁
就是其中的一种 表锁
。
- 意向锁的存在就是为了协调行锁和表锁之间的关系,支持多粒度(行锁与表锁)的锁共存。
- 意向锁是一种
不与行级锁冲突的表级锁
。 - 意向锁的含义是“某个事务正在某些行持有了锁或该事务准备去持有锁”
意向锁分为两种:
- 意象共享锁(intention shared lock, IS):事务有意向对表中的某些行加共享锁(S锁)
-- 事务要获取某些行的 S 锁,必须先获得表的 IS 锁。
SELECT column FROM table ... LOCK IN SHARE MODE;
- 意向排他锁(intention exclusive lock, IX):事务有意向对表中的某些行加排他锁(X锁)。
即:意向锁是由存储引擎 自己维护的
,用户无法手动操作意向锁,在为数据行加共享/排他锁之前, InooDB 会先获取该数据行 所在数据表的对应意向锁
。
例如:
事务1:对id=5的数据加X锁,表级锁加IX;
事务2:对id=6的数据加X锁,表级锁加IX;
两者是不冲突的
- InnoDB 支持
多粒度锁
,特定场景下,行级锁可以与表级锁共存。 - 意向锁之间互不排斥,但除了 IS 与 S 兼容外,
意向锁会与 共享锁 / 排他锁 互斥
。 - IX,IS是表级锁,不会和行级的X,S锁发生冲突。只会和表级的X,S发生冲突。
- 意向锁在保证并发性的前提下,实现了
行锁和表锁共存
且满足事务隔离性
的要求。
③自增锁(AUTO-INC,了解)
保证多个事务进行添加操作时,AUTO-INCREMENT
修饰的属性的一致性,因此是对表发生作用,是表级别
的锁。
AUTO-INC
锁,是向使用含有AUTO_INCREMENT
列的表中插入数据的特殊表级锁,一个事务在持有AUTO-INC
锁的过程中,其他事务的插入语句都要被阻塞。
④元数据锁(MDL锁,了解)
MDL锁是表锁的范畴,主要保证的是读写的正确性。当对一个表进行增删改查的操作时,加MDL读锁
;当要对表的结构进行变更操作时,加MDL写锁
。
读锁之间不互斥,因此可以有多个线程同时对一张表进行增删改查操作。读写锁之间、写锁之间是互斥的,用于变更表结构操作的安全性,解决了DML和DDL操作的一致性问题。
2. InnoDB的行锁
行锁
也称为记录锁
,锁住某一行(某条记录row)。行锁只能在存储引擎层实现。
优点:锁定力度小,发生锁冲突概率低
,可以实现的并发度高
。
缺点:对于锁的开销比较大
,加锁会比较慢,容易出现死锁
情况。
InnoDB引擎与MyISAM最大不同的两点就是——支持事务
和采用了行级锁
。
① 记录锁(Record Locks)
记录锁加在一条记录上,不同的记录之间的记录锁相互不冲突,同一条记录的S锁和X锁规则与表级锁相同。
记录锁是有S锁和X锁之分的,称之为
S型记录锁
和X型记录锁
。
- 当一个事务获取了一条记录的S型记录锁后,其他事务也可以继续获取该记录的S型记录锁,但不可以继续获取X型记录锁;
- 当一个事务获取了一条记录的X型记录锁后,其他事务既不可以继续获取该记录的S型记录锁,也不可以继续获取X型记录锁。
②间隙锁(Gap Locks)
MySQL在可重复读的隔离级别下是可以解决幻读问题的,解决方案有两种:1. 是可以使用MVCC
的方案解决 2. 加锁
的方案。
但是在使用加锁方案解决时有个大问题,就是事务在第一次执行读取操作时,那些幻影记录尚不存在,我们无法给这些 幻影记录
加上 记录锁
。InnoDB存储引擎提出了一种间隙锁
:
id=8的位置加上了间隙锁,意味着不允许事务在id=8的记录前面的间隙中插入数据,实际上是对id(3,8)的区间内的记录是不允许插入操作的。如果要在间隙中插入记录,就会阻塞插入操作,直到拥有gap锁的事务提交之后,区间(3,8)中的新纪录才可以被插入。
**间隙锁的提出仅仅是为了防止插入幻影记录而提出的。**虽然有共享gap锁
和独占gap锁
这样的说法,但是它们起到的作用是相同的。而且如果对一条记录加了gap锁(不论是共享gap锁还是独占gap锁),并不会限制其他事务对这条记录加记录锁或者继续加gap锁。
举例:
Session1 | Session2 |
---|---|
select * from student where id=5 lock in share mode; | |
select * from student where id=5 for update; | |
事务1给不存在的记录id=5加了一个间隙锁,而事务2给id=5的记录又加了另一个间隙锁,两者是不冲突的——两者都有共同的目标,保护间隙(3,8)的区间内不允许插入值。 |
给一条记录加
间隙锁
只是不允许
其他事务往这条记录前面的间隙插入新纪录
,对于最后一条记录之后的间隙,例如给id=最大值20之后的间隙加间隙锁,例如给id=25的位置添加间隙锁,那么(20,+无穷)的区间要被锁定
其原理是在数据页中的伪记录
Infimum
记录,表示该页面中最小的记录。Supremun
记录,表示该页面中最大的记录。
给id=20所在页面的Supermum记录
加了间隙锁,就阻止其他事务插入id在(20,+无穷)区间的新纪录。
间隙锁可能引发死锁问题:
假设有两个事务事务A和事务B在(3,8)的范围添加间隙锁并锁定,如果事务B在(3,8)的范围内有一个INSERT操作,那么事务B将会被事务A的间隙锁阻塞;如果事务A再试图在(3,8)的范围内进行INSERT操作,那么将会被事务B阻塞;事务A如果想要停止阻塞,必须等待事务B释放间隙锁,两者如果想要结束锁定状态,都需要对方的事务向下执行,将会形成循环等待。
实际上MySQL会处理该死锁问题,将事务A进行回滚,使得事务A释放间隙锁;于是事务B就可以继续执行成功。
⑤ 临键锁(Next-key Locks)
临键锁的作用是锁住某条记录,又阻止其他事务在该记录的前面间隙中插入记录(等于是记录锁和间隙锁的合体)。
Next-Key Locks是在存储引擎 innodb 、事务级别在可重复读
的情况下使用的数据库锁。
begin;
select * from student id <= 8 and id > 3 for update;
就将(3,8)之间的记录添加了间隙锁,在id=8这条记录上添加了记录锁。
⑥插入间隙锁(Insert Intention Locks)
事务在插入
记录时,如果插入位置被加了间隙锁
,那么插入操作就需要等待,直到拥有间隙锁
的事务提交。InnoDB规定事务在等待的时候会在内存中生成一个锁结构
,表示事务想要在某个间隙中插入新纪录。
- 插入意向锁是一种
特殊的间隙锁
,是一种行锁
(间隙锁可以锁定开区间内的记录)。 - 插入意向锁之间
互不排斥
,所以即使多个事务在同一区间进行插入多条记录,只要记录本身不冲突,事务之间就不会出现冲突等待。 - 插入意向锁并不会阻止别的事务继续获取该记录上的任何类型的锁。
3.页锁(不重要)
3.3(重要) 从对待锁的态度上划分:乐观锁和悲观锁
这两种锁是两种看待 数据并发的思维方式
。需要注意的是,乐观锁和悲观锁并不是锁,而是锁的 设计思想
。
1. 悲观锁(Pessimistic Locking)
悲观锁的数据被其他事务的修改持保守态度,会通过数据库自身的锁机制来实现,保证数据的排他性。
悲观锁总是假设最坏的情况,每次去获取数据时都认为别的事务会修改,所以每次获取数据时都要上锁,这样别的事务想获取这个数据就会阻塞直到这个事务释放锁。(共享资源每次只给一个线程使用,其他线程阻塞,用完之后再把资源转给其他线程。)比如行锁
,表锁
等,读锁
,写锁
等,都是在做操作之前先上锁,当其他线程想要访问数据时,都需要阻塞挂起。Java中的Synchronized
和ReentrantLock
等独占锁就是悲观锁思想的实现。
select .. for update
是MySQL的悲观锁,此时id=1001的数据就被锁定了,其他对1001进行查询或更改的操作需要等本次事务提交之后释放锁才能执行,这样就可以保证当前数据不会被其他事务更改。
注意:select ... for update
语句执行过程中会把所有扫描到的行都上锁,因此如果WHERE条件内的是聚簇索引中的主键值
,就会只上锁1001这条记录;而如果没有使用索引
,就会进行全表扫描
,将整个表锁住。
悲观锁的适用场景不多,悲观锁大多数情况下依靠数据库的锁机制实现,这样对数据库性能的开销很大,特别是长事务方面,这样的开销无法忍受,这时就需要乐观锁。
2.乐观锁(Optimistic Locking)
乐观锁认为对同一数据的并发操作不会总发生,属于小概率事件,不用每次都对数据上锁,但是在更新的时候会判断一下在此期间别的事务有没有去更新这个数据,也就是不采用数据库自身的锁机制,而是通过程序来实现。在程序上,我们可以采用 版本号机制
或者 CAS机制
实现。乐观锁适用于多读的应用类型, 这样可以提高吞吐量。
在Java中
java.util.concurrent.atomic
包下的原子变量类就是使用了乐观锁的一种实现方式:CAS实现的。
1. 乐观锁的版本号机制
在表中设计一个版本字段version
,一个事务第一次去读的时候,会获取version
字段的取值,然后如果对数据进行增删改操作(增删改操作会使得version+1)时需要验证version
字段的值与先前读的版本号
是否一致;如果一致,说明没有别的事务修改过;如果不一致就说明其他事务对该条数据进行了修改,也就是说想要修改的数据已经不是之前读取到的数据了——因此需要再次查询最新的版本号和数据,再进行修改。
2. 乐观锁的时间戳机制
与版本号类似,在更新操作提交时,比较当前时间戳和更新之前查询操作获取的时间错是否一致,如果一致则更新成功,否则发生版本冲突。
如果数据表是
读写分离
的,可能需要强制读取master表中的数据
(将select语句放到事务中),保证不是因为主从同步的问题引发的更新失败。
注意:如果对同一条数据进行频繁的修改
,就会有大量的失败操作。可以修改为:
3. 两种锁的适用场景
乐观锁
适用于读操作较多
的场景,相对来说写操作比较少。它的优点在于程序实现
,不存在死锁
。悲观锁
适用于写操作多
的场景,因为写操作具有排他性
。采用悲观锁的方式,可以在数据库层面阻止其他事务对该数据的访问操作,防止读-写
和写-写
冲突。
3.4 按锁的加锁方式可以分为:显式锁和隐式锁
1.隐式锁(略)
对于INSERT操作的事务,保护新插入的记录在本事务提交之前不被别的事务访问。
3.5 全局锁
对整个数据库实例进行加锁,让整个库处于只读状态,全局锁的典型使用场景是对全库逻辑备份
。
3.6 死锁
1.概念
两个事务都持有对方需要的锁,并且在等待对方释放,双方都不会释放自己的锁。
2.产生死锁的必要条件
- 两个以上事务
- 每个事务已经持有锁并申请新的锁
- 锁资源同时只能被同一个事务持有
- 事务之间因为持有锁和申请锁导致循环等待。
死锁的关键在于:两个以上的session加锁的顺序不一致
3. 如何解决死锁
方式1:等待直到超时,innodb_lock_wait_timeout=50s
两个事务互相等待时,当一个事务的等待时间超过设置的阈值,就使其回滚,确保另一个事务继续执行。
方式2:使用死锁检测
一旦检测到有回路死锁,InnoDB会选择回滚undo量最小的事务
,让其他事务继续执行。
缺点:每个新的被阻塞的线程,都要判断是不是由于自己的加入导致了死锁,这个操作时间复杂度是O(n)。如果100个并发线程同时更新同一行,意味着要检测100*100=1万次,1万个线程就会有1千万次检测。
如何解决?
- 方式1:关闭死锁检测,但意味着可能会出现大量的超时,会导致业务有损。
- 方式2:控制并发访问的数量。比如在中间件中实现对于相同行的更新,在进入引擎之前排队,这样在InnoDB内部就不会有大量的死锁检测工作。
进一步的思路:
可以考虑通过将一行改成逻辑上的多行来减少锁冲突
。比如,连锁超市账户总额的记录,可以考虑放到多条记录上。账户总额等于这多个记录的值的总和。
4. 如何避免死锁?
4. 锁的内部结构
略