一、并发事务访问相同记录的三种情况
-
读-读情况
并发事务相继读取相同的记录,不会产生什么问题。 -
写-写情况
这种情况下会发生脏写的问题。 -
读-写或写-读情况
会发生脏读、不可重复读、幻读的情况
并发问题的解决方案
- 读操作利用多版本并发控制,写操作进行加锁
- 读写操作都采用加锁的方式
二、锁的不同分类
2.1、从数据操作角度分:读锁、写锁
- 读锁:共享锁,用
S
表示,对于同一份数据的读操作可以同时进行不影响,相互不阻塞。 - 写锁:排他锁,用
X
表示,在当前写操作没有完成前,它会阻断其他写锁和读锁。
锁定读时对读取记录加S锁
SELECT ... LOCK IN SHARE MODE;
#或者
SELECT ... FOR SHARE;
锁定读时对读取记录加X锁
SELECT ... FOR UPDATE;
2.2、从数据操作粒度分:表级锁、页级锁、行锁
锁粒度是由于数据库系统需要在高并发响应和系统性能两方面进行平衡所产生的概念。
2.2.1、表锁
该锁会锁定整张表,开销最小且避免了死锁,但是并发效率大打折扣。
表级别的S锁、X锁
LOCK TABLES t READ
:InnoDB
引擎对表t
添加表级别的S锁
。LOCK TABLES t WRITE
:InnoDB
引擎对表t
添加表级别的X锁
。
在InnoDB上使用这些语句只会降低并发能力,所以一般不使用。
意向锁
在一个事务在设置行锁的时候其他事务就需要去遍历这张表的每行是否存在锁。所以通过在表上设置意向锁,从而避免其他事务的逐行检查。
- 意向共享锁:如果事务想要获得数据表中某些记录的共享锁,就需要在数据表上添加
意向共享锁
。
SELECT column FROM table ... LOCK IN SHARE MODE;
- 意向共享锁:如果事务想要获得数据表中某些记录的排他锁,就需要在数据表上添加
意向排他锁
。
SELECT column FROM table ... FOR UPDATE;
总的来说,意向锁是一种不与行锁冲突的表级锁
,它是为了协调行锁和表锁的关系。支持多粒度的锁并存。
自增锁
自增锁时当向使用含有AUTO_INCREMENT列的表中插入数据时需要获取的一种特殊的表级锁。一个事务在持有自增锁的过程中,其他事务的插入语句都要被阻塞。
2.2.2、行锁
- 优点:发生锁冲突概率低,并发度高。
- 缺点:锁的开销比较大,加锁慢,容易出现死锁。
记录锁
记录锁仅仅把一条数据锁上,其分为S型记录锁
和X型记录锁
- 当一个事务获取了一条记录的
S型记录锁
后,其他事物可以继续获取该记录的S型记录锁
,但不能获取X型记录锁
。 - 当一个事务获取了一条记录的
X型记录锁
后,其他事务不能获取
该记录的S型和X型记录锁
。
间隙锁
在不可重复读
的隔离级别下,MySQL可以采用加间隙锁的方式解决幻读问题。间隙锁仅仅是为了防止插入幻影记录而提出的。
如果在id=8
的记录上加上间隙锁,那么在该事务提交之前不允许id在(3,8)
之间的记录被插入。使用方法为:
SELECT * FROM student WHERE id=5 LOCK IN SHARE MODE;
# 或者
SELECT * FROM student WHERE id=5 FOR UPDATE;
因为表里没有id=5
的记录,所以两条语句的间隙锁都加在id=8
上,这里两条语句都是为了保护(3, 8)
这个区间的间隙锁,这两条语句相互之间不会有冲突。
如果要对最前面的记录和最后面的记录加上间隙锁,则需要使用两条伪记录:
Infimum
记录,表示该页面中最小的记录Supremum
记录,表示该页面中最大的记录
在这两条记录上设置间隙锁就可以达到不允许id小于1或者大于20的记录
被插入。如果要在(20, + ∞ \infty ∞)上不允许记录插入,则需要
SELECT * FROM student WHERE id>20 LOCK IN SHARE MODE;
临键锁
临键锁本质是一个记录锁
加间隙锁
,相当于在间隙锁上加了一个等号。
SELECT * FROM student WHERE id <= 8 AND id > 3 FOR UPDATE;
插入意向锁
插入意向锁是由INSERT
操作产生的一种间隙锁,是由于INSERT
操作获取锁失败而开始等待产生的,是行级锁并不是表级锁。
- 插入意向锁是一种特殊的间隙锁,可以锁定开区间内的部分记录。
- 插入意向锁之间互不排斥。
2.2.3、页锁
页锁开销介于表锁和行锁之间,会出现死锁。锁定粒度介于表锁和行锁之间,并发度一般。
2.3、从对待锁的态度划分:乐观锁、悲观锁
悲观锁
悲观锁主要保证了数据的排他性,在事务查询数据的时候会认为该事务会修改数据从而上锁,从而影响阻塞别的事务。
SELECT ... FOR UPDATE
是MySQL的悲观锁,它在执行过程中所有扫描的行都会被锁上,因此在MySQL中用悲观锁必须确定使用索引,而不是全表扫描,否则整个表都会被锁上。
悲观锁的不足在于其依靠数据库的锁机制来实现,对数据库性能开销较大。
乐观锁
乐观锁不采用数据库自身的锁机制,而是通过程序来实现。其适用于多读的应用类型来提高吞吐量。如果写操作过多则还是需要使用悲观锁。实现方式有版本号机制和CAS机制。
-
版本号机制
在表中设置版本字段version
,第一次读的时候获取version
字段,对数据进行更新和删除时,会执行UPDATE ... SET version=version+1 WHERE version=version
。这就是通过对版本进行控制,从而避免了读写操作的相互干扰,即类似于git
的思想。 -
时间戳机制
与版本号类似,把version
换成了时间戳
-
CAS机制
java中的java.util.concurrent.atomic
包下的原子变量类就是使用了乐观锁的CAS机制实现的。
两种锁的适用场景
- 乐观锁:适合读操作多,写操作少的场景。优点在于程序实现,不存在死锁的问题,但是阻止不了除了程序以外的数据库操作
- 悲观锁:适合写操作多的场景。可以在数据库层面阻止其他事务对该数据的操作权限,防止
读-写
和写-写
的冲突。
2.4、按加锁的方式分:显式锁、隐式锁
隐式锁
隐式锁的逻辑如下:
- InnoDB的每条记录中都有一个隐含的
trx_id
字段,这个字段存在于聚簇索引的B+Tree
中。 - 在操作记录前,首先根据记录中的
trx_id
检查该事务是否是活动的事务。如果是活动的事务,首先将隐式锁转换为显式锁。 - 检查是否有锁冲突,有冲突就创建锁并设置为waiting状态。如果没有冲突就不加锁,并且直接跳到下下步。
- 等待加锁成功,被唤醒或者超时
- 写数据,并将自己的
trx_id
写入trx_id
字段。
显示锁
即通过特定语句加锁。
显式加共享锁:
SELECT ... LOCK IN SHARE MODE
显式加排他锁
SELECT ... FOR UPDATE
2.5、全局锁
全局锁是对整个数据库实例加锁,使得整个数据库处在只读状态。全局锁的典型使用场景是:做全库逻辑备份。
F1ush tables with read lock
2.6、死锁
两个事务都持有对方需要的锁,并且在等待对方释放,并且双方都不会释放自己的锁。
2.6.1、死锁的必要条件
死锁的关键在于两个事务的上锁顺序不一致
- 存在两个及以上的事务
- 每个事务都已经持有锁并且申请新的锁
- 锁资源同时只能被一个事务持有或者不兼容
- 事务之间因为持有锁和申请锁导致彼此循环等待。
2.6.2、死锁处理方法
-
等待直到超时
可以通过设置事务等待时间的阈值,但是缺点在于有可能会使得正常等待的事务由于超时而退出。 -
使用死锁检测进行死锁处理
innodb提供了wait-for graph算法
来检测死锁。算法要求保存所得信息链表和事务等待链表两个信息,并基于这两个信息绘制等待图
得到下图
这里与操作系统的死锁检测方法类似。
2.6.3、死锁避免
- 合理设置索引,使业务SQL尽可能通过索引定位更少的行,减少锁竞争
- 调整业务逻辑SQL执行顺序,避免update/delete长时间持有锁的SQL在事务前面。
- 避免大事务,将大事务拆分为小事务缩短锁定资源的时间,减少发生锁冲突的几率。
- 在并发高的系统中,不要显式加锁,特别是在事务里显示加锁。
- 降低隔离级别,避免掉由于gap锁导致的死锁。