目录
锁的介绍:
在数据库中除了计算资源外,数据也是共享的资源,为了保证数据的一致性,需要对并发操作进行控制。
锁是计算机协调多个进程或者线程并发访问某一资源的机制。当多线程并发访问某个数据,需要保证在任何时刻最多只有一个线程在访问,保证数据的完整性和一致性。锁机制为实现MySQL的各个隔离级别提供了保证。锁冲突也是影响数据库并发访问性能的一个重要因素。
并发事务访问相同数据可以分为以下几种情况:
都是进行读操作:
因为读操作并不会对数据产生任何影响,所以不会引发问题,是允许这种情况的发生。
都是进行写操作:
在这种情况下,会产生脏写的问题,任何一种隔离级别都不允许脏写发生。所以在多个事务对同一数据进行操作时,需要排队执行。而排队的过程就通过锁来实现。
有读操作也有写操作:
这种情况就可能会发生脏写、脏读、不可重复读、幻读问题。
锁其实是一个内存中的结构,在事务执行前是没有锁的,也就是一开始没有锁结构和记录进行关联。当一个事务想对一条记录做改动时,会先看内存中是否存在与这条记录关联的锁结构,如果不存在,则生成一个锁结构与之关联。
比如:事务A对某条数据进行修改,就会生成一个锁结构与该记录关联。因为在这之前没有别的事务对这条记录枷锁,所以加锁成功,继续执行操作。在事务A提交之前,事务B对同一条记录进行修改操作,那么也会看是否有锁结构与该记录有关联,因为事务A对该数据进行操作,该数据已经有一个锁结构与之关联。虽然也会生成锁,但是此时事务B就会加锁失败。需要等待事务A提交之后,将事务A生成的锁结构释放掉之后,检查是否有别的事务在等待加锁。此时事务B就会加锁成功,就能对该条记录进行操作。
上述说一条记录加锁也就是在内存中创建一个锁结构与该记录关联。但是如果一个事务对多条记录进行加锁,会占用很大的空间。
所以在对不同记录加锁时,将以下的记录放到一个页结构中:
同一个事务中进行加锁,被加锁的记录在同一个页面中,加锁的类型是一样的,等待的状态相同。
读锁、写锁:
对于写-写、写-读或者读写这些情况可能引起一些问题,为了能让这三种情况中的操作相互阻塞,所以MySQL实现了一个由两种类型的锁组成的锁系统,通常被称为共享锁和排他锁,也叫读锁(S锁)和写锁(X锁)。
读锁:
用英文S表示,针对同一份数据,多个事务的读操作可以同时进行而不会互相影响,相互不阻塞。
读取一条记录时会获取该记录的读锁还要获取该记录的写锁来防止其他事务对该记录进行修改。
select ... lock in share mode;
当加上"lock in share mode",当前事务执行了该语句,那么就会为读到的记录加上读锁。其他事务也可以获取该记录,不会阻塞,但是不能获取该记录的写锁,如果想要获取写锁,那么则会排队阻塞,直到当前的事务提交之后,该记录上的读锁释放掉。
写锁:
用英文X表示,当前写操作没有完成前,会阻塞其他写锁和读锁,能确保只有一个事务执行写入,并防止其他用户读取正在写入的同一数据。
select ... for update;
当加上"for update",当前事务执行了该语句,则会为读取到的记录加上写锁,其他事务则不能对该记录同时进行修改操作,也不允许其他事务获取该记录的读锁。也就是其他事务要获取该记录的读锁或者写锁,都会阻塞,直到当前事务提交之后将这些记录上的写锁释放掉。如果等待获取锁的时间超过了innodb_lock_wait_timeout参数的值则会结束。
注意:当执行"select ... for update;"语句会将所有扫描的行加上锁,所以要确保使用索引,避免全表扫描。
按照锁粒度分类:
从数据操作的粒度划分,可以分为表级锁、页级锁、行锁。
为了尽可能提高数据库的并发度,每次锁定的数据范围越小越好,但是对于资源的消耗会更大。因此数据库系统需要在并发响应和系统性能两方面进行平衡。
每个层架的锁数量是有限的,因为锁会占用内存空间,锁空间的大小是有限的。当锁的数量超过了这个层级的阈值时,就会用更大粒度的锁代替多个粒度小的锁,也就是锁升级。
表锁:
读锁、写锁:
表锁会锁定整张数据表,并不依赖于存储引擎,是最基本的锁策略。表锁的开销相对来说是最小的,因为粒度比较大,对于资源的消耗更小。由于表锁一次会将整个表锁定,所以可以避免死锁问题。但是锁的粒度大会带来锁资源争用的概率高,导致并发率大打折扣。
InnoDB引擎不会为对增删改查语句进行对表加上读锁或者写锁。在执行对DDL语句时对表结构进行修改,那么其他事务对整个表的增删改查则会发生阻塞。同理当事务对某个表执行增删改查时,其他事务对表进行DDL语句进行表结构的修改也会发生阻塞。这是通过Server层使用的一种元数据锁结构实现的。
一般情况下,不会使用InnoDB存储引擎的表级读锁和写锁。
lock tables 表名 read;
lock tables 表名 write;
MyISAM执行查询语句前,会给涉及的表加上读锁,对于增删改操作,会给涉及的表加上写锁。
意向锁:
InnoDB支持多粒度锁,允许行级锁与表级锁共存,意向锁就是一种表锁。意向锁不会与行级锁冲突,只会与表级读锁和写锁冲突。实现了行锁和表锁共存,并且满足了事务的隔离性要求。
意向共享锁:
事务有意向对表中的某些行加共享锁(读锁)。
意向排他锁:
事务有意向对表中的某些行加排他锁(写锁)。
意向锁是由存储引擎维护的,无法手动操作意向锁,在数据行加共享锁或者排他锁之前,InnoDB会先获取该数据行所在数据表的对应意向锁。
当给某一行数据加了排他锁,数据库会自动给大一级空间,如数据页或者数据表加上意向锁,其他事务想获取数据表的排他锁时,需要排队。如果事务是想获取数据表中的某些记录的共享锁,就需要在数据表上添加意向共享锁。如果是事务想要获取数据表中的某些记录的排他锁,就需要在数据表上添加意向排他锁。
意向锁之间时互相兼容的,除了意向共享锁和共享锁之外与普通的共享锁或者排他锁是互斥的。
自增锁:
当对含有自增列的表中进行插入数据时,需要获取的一种表级锁。在多个事务并发情况下,当一个事务对该表进行插入时,就会加上一个自增锁,为自增列分配递增的值,其他事务都需要等待阻塞,只有当该事务提交之后,把自增所释放掉,其他事务才能继续对该表进行插入。所以并发性不高。InnoDB通过innodb_autoinc_lock_mode参数的值来提供不同的锁机制和,提高了SQL语句的可伸缩性和性能。
1.innodb_autoinc_lock_mode=0,传统锁定模式。
2.innodb_autoinc_lock_mode=1,连续锁定模式。
3.innodb_autoinc_lock_mode=2,交错锁定模式。
元数据锁:
也称为DML锁,当对一个表做增删改查操作时,加元数据读锁,当对表的结构进行更新时,加元数据写锁。这里指的是从元数据锁的角度,读锁之间不互斥,所以可以多个线程同时对一张表增删改查。读写锁之间和写锁之间是互斥的,用于保证数据表结构的安全性,解决了DML和DDL操作之间的一致性。不需要显示使用,在访问一个表时会自动加上。
行锁:
也称为记录锁,行级锁只在存储引擎层实现。
锁定力度小,发生锁冲突的概率低,可以实现的并发度更高,但是对于锁的开销比较大,加锁会比较慢,容易出现死锁。
记录锁:
只把一条记录锁上,对该记录周围的数据没有影响,基于主键或者唯一索引精确匹配。
记录锁是有读写锁之分的,和读锁写锁是一样的。
间隙锁:
用于解决幻读,给某条记录加上间隙锁,就不允许别的事物在该记录前面的间隙插入新记录,也就是该记录和其前面记录的主键值之间不允许插入新记录。
临建锁(记录锁+间隙锁的组合):
当想即对某条记录进行加锁,又想赋值其他事务在该记录前面的间隙插入新记录,就可以使用临建锁。临建锁是在InnoDB存储引擎中事务级别在可重复读的情况下使用的数据库锁。InnoDB中默认使用的是临建锁。可以预防大部分幻读和范围数据修改。
插入意向锁:
一种特殊的间隙锁,与普通间隙锁不同,它允许同一间隙内不同位置的并发插入。解决普通间隙锁在高并发插入时的性能问题,减少不必要的阻塞。
一个事务插入一条记录时,需要判断插入位置是否加了锁,如果有的话,那么该事务需要进行等待。而在InnoDB中规定事务在等待时需要在内存中生成一个锁结构,就会加上插入意向锁,表示有事务想在某个间隙中插入新记录。在insert时产生,不是意向锁,在插入一条记录前,有insert产生的一种间隙锁,用于表示插入意向,多个事务对同一区间插入位置不同的多条记录时,不需要互相等待。插入意向锁不会阻止别的事务继续获取该记录上的任何类型的锁。
页锁:
页锁锁定的数据资源比行锁要多,当使用页锁时,会出现数据浪费的现象。页锁的开销时在表锁和行锁之间,会出现死锁的情况,并发度一般。
锁的态度:
悲观锁:
对数据被其他事务的修改持保守态度,通过数据库自身的锁机制来实现,从而保证数据库操作的排他性。共享资源每次只给一个线程使用,其他线程阻塞,用完之后再将资源给其他线程。
乐观锁:
不采用数据库自身的锁机制,通过程序来实现,可以采用版本号机制或者CAS机制实现。不用每次都对数据上锁,但是在更新时会判断在此期间是否有事务更新这个数据。读操作多,写操作少,不存在死锁场景。
隐式锁:
由MySQL引擎自动施加和释放,无需手动操作。通过事务ID(trx_id)标记数据行的锁定状态。例如插入新记录时,系统自动用当前事务ID标记该行,其他事务访问时会检测此ID是否活跃,从而触发锁等待或转换。
隐式锁的逻辑过程:
InnoDB中每条记录都有一个隐含的trx_id字段,这个字段存在于聚簇索引中的B+树中。在一条操作记录之前,首先根据记录中的trx_id检查该事务是否是活动的事务,如果是那么将隐式锁转换为显式锁,也就是为该事务添加一个锁。检查是否有锁冲突,如果有,那么创建锁之后将锁结构中的is_waiting置为true,如果没有,则直接将该事务的id写入到trx_id字段,如果没有,那么则进行等待阻塞,直到超时或者被唤醒,然后会将事务id写入到trx_id字段中。
对于非聚簇索引,本身没有trx_id隐藏列,但是非聚簇索引的页面的页头结构中有一个属性记录修改当前页的最大事务id,如果记录的事物id小于当前活跃的事务id,那么说明记录的事务已经提交了,相反,那么则需要在页面中定位到对应的二级索引记录,进行回表进行上述的逻辑过程。
显式锁:
需开发者通过SQL语句主动声明加锁与释放。
全局锁:
全局锁也就是对整个数据库的实例进行加锁。当需要整个库处于只读时,其他线程对该数据库的实例进行数据更新语句、数据定义语句等就会被阻塞。全局锁可以用于全库逻辑备份。
写操作的锁机制:
删除操作:
先通过B+树索引(主键或者二级索引)定位目标记录位置,对其执行锁定读,也就是加上排他锁,防止其他事务的访问。然后将记录的删除标识从0置为1,让该条记录移入垃圾链表。
更新操作:
键值不变且空间也不变:
通过B+树进行定位目标记录位置,将其执行锁定读,在原记录的位置进行修改操作。
键值不变但是空间变化:
通过B+树进行定位目标记录位置,将其执行锁定读,然后将该记录移入垃圾链表中,再重新插入一条新纪录。新插入的记录由隐式锁进行保护。
键值改变:
在原记录上做了删除操作之后再进行一次插入操作,加锁需按照删除操作和插入操作规则进行。
插入操作:
新记录插入时不立即加显式锁,通过隐式锁的结构来保护新记录不会被其他事务访问。
死锁:
死锁是数据库并发控制中常见的问题,指两个或多个事务互相持有并等待对方释放资源,导致无限期阻塞的状态。在MySQL中,死锁主要发生在InnoDB存储引擎的行级锁场景下。
死锁的四个必要条件:
1.互斥条件:资源只能被一个事务独占使用,其他事务无法同时访问同个资源。
2.请求与保持条件:事务在持有至少一个资源的同时,请求新的资源且不释放已持有资源。
3.不可剥夺条件:资源只能由持有它的事务主动释放,不能被强制剥夺。
4.循环等待条件:事物之间形成了环形等待链,如事务A等待事务B,而事务B等待事务A。
死锁的避免方法:
合理设计索引,减少扫描的行,从而降低锁竞争。
尽量减少事务的内部操作,避免大事务占用锁,缩短锁定资源的时间,减少锁冲突的概率。
避免长时间持有锁的SQL语句在事务前面部分,所有事务以相同顺序访问资源,避免交叉操作。
死锁的处理方法:
1.等待超时,当其中一个事务超时,就将其回滚,另外的事务就能继续进行。
2.使用死锁检测进行死锁处理,InnoDB提供了wait-for graph算法来实现死锁检测,每当加锁请求无法立即满足需要等待时,就会触发wait-for graph算法。通过事务等待链表中记录的事务信息和锁的信息链表绘制一个事务等待的有向图,当有向图出现了环,表明出现了死锁的情况。此时InnoDB就会选择undo日志量最小的事务进行回滚,让其他事务继续执行。每一个新的阻塞线程,都要判断是否导致了死锁出现,耗费时间。