赞
踩
目录
锁是计算机协调多个进程或线程并发访问某一资源的机制。在数据库中,除了传统的计算资源(CPU、RAM、I/O等)的争用以外,数据也是一种供多用户共享的资源。为保证数据的一致性,需要对并发操作进行控制,因此产生了锁。同时锁机制也为实现MySQL的各个隔离级别提供了保证。锁冲突也是影响数据库并发访问性能的一个重要因素。所以锁对数据库而言尤其重要,也更加复杂。
并发事务访问相同记录的情况大致可划分为3种:
并发读:并发事务相继读取相同的记录,因为读操作本身不会对记录有任何影响,所以不会产生什么问题。
并发写:并发事务相继对相同的记录做出改动。在这种情况下会产生脏写的问题,任何一种隔离级别都不允许脏写的发生。所以在多个未提交事务相继对一行记录做改动时,需要让这些事务排队执行,此过程就是用锁来实现的。
并发读写:一个事务进行读操作、另一个事务进行写操作。这种情况下可能会发生脏读、不可重复读、幻读。
各个数据库厂商对SQL标准的支持可能不太一样,比如MySQL在REPEATABLE READ(可重复读)隔离级别上就已经解决了幻读问题。
方案一:读操作利用MVCC控制,写操作加锁。
MVCC会生成一个ReadView,通过ReadView找到符合条件的记录版本(历史版本由undo 日志构建)。查询语句只能读到在生成ReadView之前已提交事务所做的更改,在生成ReadView前未提交的事务或在生成ReadView后才开启的事务所做的更改是看不到的。而写操作肯定针对的是最新版本的记录,读操作的历史版本和改动记录的最新版本本身并不冲突。
普通的select操作在READ COMMITTED(读已提交)和REPEATABLE READ(可重复读)隔离级别下会使用到MVCC读取记录。
在READ COMMITTED下,一个事务在执行过程中每次执行select操作时都会生成一个ReadView,ReadView本身就保证了事务不可以读取到未提交事务所做的更改,也就是避免了脏读的现象。
在REPEATABLE READ下,一个事务在执行过程中只有第一次select操作才会生成一个ReadView,之后的select操作都复用这个ReadView,这样就避免了不可重复读和幻读的问题。
方案二:读、写操作都加锁
如果我们的一些业务场景不允许读取记录的旧版本,而是每次都必须去读取记录行最新的版本。此时就需要对读和写操作都进行加锁,以此排队执行。
脏读的产生是因为当前事务读取了另一个未提交事务的写操作,如果另一个事务在写记录的时候就给这条记录加锁,那么读事务就无法继续读取该记录了,所以就不会有脏读的产生。
不可重复读是因为当前事务先读取一行记录,另外一个事务对该记录做了改动后提交,当前事务再次去读时会获得不同的值,如果在当前事务读取记录时就给该记录加锁,那么另一个事务就无法修改该记录,自然也不会发生不可重复读。
幻读是因为当前事务读取了一个范围的记录,另外的事务向该范围插入了新纪录,当前事务再次读取该范围发现了新插入的记录。采用加锁的方式解决幻读就有些麻烦,因为当前事务在第一次读取记录的时候幻影记录并不存在,所以读取的时候加锁会找不到加锁的对象(因为根本不知道给谁加锁)。
在使用加锁的方式解决问题时,由于既要允许并发读不受影响,又要使并发写、并发读写中的操作相互阻塞,所以MySQL实现了一个由两种类型的锁组成的锁系统来解决上述问题。这两种类型的锁通常被称为共享锁(Shared Lock S锁)、排他锁(Exclusive Lock X锁)。
读锁:也称为共享锁。针对同一份数据,多个事务的读操作可以同时进行而不会互相影响。
写锁:也称为排他锁。当前写操作没有完成前,会阻断其他写锁和读锁。这样就能确保在给定时间里,只有一个事务能执行写入,并防止其他用户读取正在写入的同一资源。
锁定读
在采用加锁的方式解决脏读、不可重复读、幻读这些问题时,读取一行记录时需要获取该记录的共享锁,这句话其实是不严谨的,有的时候需要在读取记录时就获取该行记录的排他锁,禁止别的事务读取该记录,也就是说针对于读操作,不仅可以加共享锁,也可以加排他锁。
对读取的记录加共享锁语法:select ... for share
在select语句后加for share,如果当前事务执行了该语句,那么它会为读取到的记录加共享锁,这样允许别的事务继续获取这些记录的共享锁,但是不能获取这些记录的独占锁。如果别的事务想获取这些记录的独占锁,那么他们会阻塞,直到当前事务提交之后将这些记录上的共享锁释放掉。
对读取的记录加排他锁语法:select ... for update
在select语句后加for update,如果当前事务执行了该语句,那么它会为读取的记录加排他锁,这样既不允许别的事务获取这些记录的排他锁,也不允许获取这些记录的共享锁。如果别的事务想要获取这些记录的共享锁或独占锁,那么它们会阻塞,直到当前事务提交之后将这些记录上的独占锁释放掉。
写操作
针对delete:
对一行记录做delete操作的过程其实是先在B+树中定位到这条记录的位置,然后获取这条记录的排他锁,在执行delete mark(将行记录delete mark字段改成1 意味删除)操作。我们也可以把这个定位待删除记录在B+树中位置的过程看成是一个获取排他锁的锁定读。
针对uptae:对一条记录做update分为三种情况
情况1:未修改该记录的键值,并且被更新的列占用的存储空间在修改前未发生变化。(例如name字段定义为varchar(20),update操作将原name='张三'改成'李四')
则先在B+树中定位到这条记录的位置,然后再获取一下该记录的独占锁,最后在原记录位置进行修改操作。我们也可以把这个定位待删除记录在B+树中位置的过程看成是一个获取排他锁的锁定读。
情况2:未修改该记录的键值,并且至少有一个被更新的列占用的存储空间在修改前后发生变化。
则先在B+树中定位到这条记录的位置,然后获取该记录的独占锁,将该记录删除(将该记录移入垃圾链表) ,最后再插入一行新记录。我们也可以把这个定位待删除记录在B+树中位置的过程看成是一个获取排他锁的锁定读。 ,新插入的记录由insert提供的隐式锁进行保护。
情况3:修改了该记录的键值,则相当于在原记录做delete操作之后再insert,加锁操作就按照delete和insert的规则进行了。
针对insert:
一般情况下,新插入一个记录的操作并不加锁,通过一种称之为隐式锁的结构来保护这条新插入的记录在本事务提交前不被别的事务访问。
为了尽可能提高数据库并发量,每次锁定的数据范围越小越好,理论上只锁定当前行会获得最大的系统并发量,但管理锁是非常耗费系统资源的。因此数据库需要在高并发和高性能两方面进行平衡,由此产生了锁粒度的概念。
该锁会锁定整张表,是MySQL中最基本的锁策略,且不依赖于存储引擎,并且表锁是开销最小的策略(因为锁粒度较大)。由于表锁一次会将整个表锁定,所以可以避免死锁。当然锁的粒度过大会导致锁资源争用变高,导致并发率下降。
表级别的共享锁(S锁)、排他锁(X锁)
在对某个表执行select、delete、uptate语句时,innoDB不会为表添加表级别的共享锁或独占锁。在对某个表执行一些诸如alter table、drop table这类DDL语句时,其他事务对这个表执行并发select、update、delete、insert会发生阻塞。同理,某个事务中对某个表执行select、update、delete、insert语句时,在其他会话对这个表执行DDL语句也会发生阻塞。这个过程其实是通过server层使用一种称之为元数据锁的结构来实现的。
一般情况下,不会使用InnoDB提供的表级共享锁和表级独占锁,只会在一些特殊情况下,比如崩溃恢复过程中用到。在系统变量aotucommit = 0,innodb_table_locks = 1,手动获取InnoDB提供的表级共享锁和表级独占锁语法:
lock tables t read:InnoDB会对t表加共享锁
lock tables t write:InnoDB会对t表加独占锁
unlock tables:释放表锁
意向锁
InnoDB支持多粒度锁,它允许行级锁与表级锁共存,而意向锁就是其中的一种表锁。
关于意向锁:
1、意向锁是为了协调行锁和表锁间的关系,支持多粒度(表锁和行锁)的锁并存。
2、意向锁是一种不与行级锁冲突的表级锁
3、表明"某个事务在某些行持有了锁或该事务准备去持有锁"
意向锁分为两种:
意向共享锁:事务有意向对表中某些行记录加共享锁
意向排他锁:事务有意向对表中某些行记录加排他锁
注:意向锁是存储引擎自己维护的,用户无法手动操作意向锁,在为数据行加共享/排他锁前,存储引擎会先获取该数据行所在对应表的意向锁。
意向锁要解决的问题:
现在有两个事务,事务B试图在该表级别共享或排他锁,如果没有意向锁的存在,事务B就需要去检查各个页或行是否存在锁;如果存在意向锁,T2在锁定该表前就不需要检查各个页或行锁,而只需要检查表上的意向锁就好了,简单来说就是给更大一级的空间示意里面的行是否已经上过锁。
在数据表的场景中,如果我们给某一行数据加上了排他锁,数据库会自动给更大一级的空间,比如数据页或数据表上加意向锁,告诉其他人这个数据页或数据表已经有人加上过排他锁了,这样当其他人想要获取数据表排他锁的时候,只需要了解是否有人已经获取了这个表的意向排他锁即可。
如果事务想要获取数据表中某些记录的共享锁,就需要在数据表上添加意向共享锁。
如果事务想要获取数据表中某些记录的排他锁,就需要在数据表上添加意向排他锁。
自增锁
在使用MySQL过程中,我们可以为表的某个列添加auto_increment属性,那么在插入数据的时候MySQL就会自动为我们生成一个数作为主键id。所有插入数据的方式总共分为三类,分别是"Simple inserts"、"Bulk inserts"、"Mixed-mode inserts"。
"Simple inserts":简单插入
可以预先确定要插入行数的语句(当语句被初始处理时),包括没有嵌套子查询的单行和多行insert ... values()。
"Bulk inserts":批量插入
事先不知道要插入的行数的语句。比如insert ... select。
"Mixed-mode inserts":混合模式插入
是"Simple inserts"的语句但是指定部分新行的自动递增值。例如:insert into person(id,name)values(1,'a'),(null,'B'),(3,'C')。
对于数据插入获取自增的值,MySQL采用了自增锁的方式来实现,auto-inc锁是当向使用含有auto_increment列的表中插入数据时需要获取的一种特殊表级锁,在执行插入语句时就在表级别加一个auto_inc锁,然后为每条待插入记录的auto_increment修饰的列分配递增的值,分配后再把auto_inc锁释放掉。一个事务在持有auto_inc锁的过程中,其他事务的插入语句都要被阻塞,可以保证一个语句中分配的递增值是连续的。也正因为这样,对并发性的影响会很大,因为每当我们有auto_increment关键字的主键插入值的时候,每条语句都要对这个表锁进行竞争。InnoDB通过innodb_autoinc_lock_mode参数来提供不同的锁机制,来显著提高并发性能。
innodb_autoinc_lock_mode = 0:传统模式(Traditional)
此模式下,所有类型的insert语句都会获得一个表级auto_inc锁,用于插入具有auto_increment列的表。每当执行insert语句的时候,都会去申请给该表加自增锁,使得语句中生成的auto_increment为顺序,且binlog在重放的时候,可以保证master和salve中数据的auto_increment是相同的。因为是表级锁,当在同一时间多个事务执行insert时,对自增锁的竞争会限制并发能力。
innodb_autoinc_lock_mode = 1:连续模式(Consecutive)
MySQL 8.0之前默认模式。此模式下,"Bulk inserts"仍使用auto_inc表级锁,并保持到语句结束。同一时刻只有一个语句可以持有auto_inc锁。对于"Simple inserts",则通过mutex(轻量锁)控制所需数量行的主键递增来避免使用auto_inc锁,使得auto_inc锁只在分配过程中保持,而不是直到语句完成。如果另一个事务持有auto_inc锁,则"Simple inserts"等同于"Bulk inserts"处理。
innodb_autoinc_lock_mode = 2:交叉模式(Interleaved)
在MySQL 8.0,交叉模式是默认模式。此模式下,所有insert相关语句都不会使用auto_inc锁,并且可以同时执行多个语句。此模式下,自动递增值保证在所有并发执行的所有类型的insert语句中是唯一且单调递增的。但由于多个语句都可以同时生成数字,导致为任何给定语句插入的行生成的值可能不是连续的。
如果执行的语句是"Simple inserts",由于要插入的行数提前知道,所以主键的自增值是连续的。但"Bulk inserts"和"Mixed-mode inserts"在为任何给定语句插入的行生成的值可能存在间隙。
元数据锁
MySQL 5.5引入了meta data lock,简称MDL锁,属于表锁范畴。MDL的作用是,保证读写的正确性。当对一个表做增删改查操作时,加MDL读锁;当要对表做结构变更操作时,加MDL写锁。读锁之间不互斥,因此你可以有多个线程同时对一张表增删改查。读写锁之间、写锁之间是互斥的,用来保证变更表结构的安全性,解决了DML和DDL操作间一致性问题。不需要显示使用,在访问一个表的时候会被自动加上。
记录锁
记录锁也就是仅仅把一条记录锁上,官方的类型名称为:LOCK_REC_NOT_GAP。记录锁有共享锁和排他锁之分。
当一个事务获取了一条记录的共享锁后,其他事务也可以继续获取该记录的共享锁,但不可以继续获取该记录的独占锁。
当一个事务获取了一条记录的独占锁后,其他事务既不可以获取该记录的共享锁,也不可以获取该记录的独占锁。
间隙锁
MySQL在REPEATABLE READ隔离级别下可以解决幻读问题,解决方案有两种,可以使用MVCC方案解决,也可以采用加锁的方案解决。但是在加锁方案解决时有个问题,就是事务在第一次执行读取操作的时,那些幻影记录尚不存在,我们无法给这些幻影记录加上记录锁。InnoDB提出了一种称之为LOCK_GAP的锁,简称gap锁。比如,把id为8的记录加一个gap锁示意图如下:
图中id值为8的记录加了gap锁,意味着不允许别的事务在id值为8的记录前边的间隙插入新记录,其实就是id列为(3,8)这个区间内不允许立即插入新记录。比如,有另外一个事务想再插入一条id为4的新记录,它定位到该条新记录的下一条记录的id为8,而这条记录上又有一个gap锁,所以就会阻塞插入操作,直到拥有这个gap锁的事务提交了之后,id列的值在区间(3,8)中的新记录才允许被插入。
gap锁仅仅是为了防止插入幻影记录而提出的。虽然也有共享gap锁和独占gap锁之称,但是它们起到的作用是相同的。而且如果对一条记录加了gap锁(不论是共享gap锁还是独占gap锁),并不会限制其他事务对这条记录加记录锁或者继续加gap锁。
临键锁
有时候我们既想锁住某条记录,又想阻止其他事务在该记录前边的间隙插入新纪录,针对这种情况,InnoDB提出了一种称之为Next_Key的锁。Next_Key是在InnoDB事务隔离级别可重复读的情况下使用的数据库锁。
Next_Key锁的本质就是一个记录锁和一个gap锁的合体,它既能保护该记录,又能阻止别的事务将新纪录插入被保护记录前边的间隙。
插入意向锁
当一个事务在插入一条记录时需要判断一下插入位置是不是被别的事务加了gap锁,如果有的话,插入操作需阻塞,直到拥有gap锁的事务提交。但InnoDB规定事务在等待时也需要在内存生成一个锁结构,表明有事务想在某个间隙中插入新纪录,但是现在处在等待阶段。InnoDB把这种类型的锁命名为Insert Intention Locks,我们称之为插入意向锁。
插入意向锁专门针对insert,假设插入前,该间隙已经有gap锁,那么Insert会申请插入意向锁。插入意向锁是由insert操作产生的一种间隙锁。该锁用以表示插入意向,当多个事务在同一区间插入位置不同的多条数据时,事务之间不需要互相等待。
插入意向锁的特性可以分为两部分:
(1)插入意向锁是一种特殊的间隙锁--间隙锁可以锁定开区间内的部分记录
(2)插入意向锁互不排斥,所以即使多个事务在同一区间插入多条记录,只要记录本身(主键、唯一索引)不冲突,那么事务间就不会出现冲突等待。
比如把id值为8的那条记录加一个插入意向锁:
比如,T1事务为id值为8的记录加了一个gap锁,然后T2和T3分别想往表中插入id值为4、5的两条记录。
从图中可以看出,由于T1持有gap锁,所以T2和T3需要生成一个插入意向锁的锁结构并且处于等待状态。当T1提交后会把他获取到的锁都释放掉,这样T2和T3就能获取到对应的插入意向锁,T2和T3间也不会相互阻塞,它们可以同时获取到id值为8的插入意向锁,然后执行插入操作。事实上插入意向锁并不会阻止别的事务继续获取该记录上任何类型的锁。
页锁就是以页的粒度进行锁定,锁定的资源比行锁要多,因为一个页中可以有多行记录。当我们使用页锁的时候,会出现数据浪费的现象,但这样最多也只浪费一个页上的数据行。页锁的开销介于表锁和行锁之间,会出现死锁。锁粒度介于表锁和行锁之间,并发度一般。
悲观锁
从对待锁的态度来看锁的话,可以将锁分成乐观锁和悲观锁,从名字也可以看出来这种锁是两种看待数据并发的思维方式。需要注意的是,乐观锁和悲观锁并不是锁,而是锁的设计思想。 悲观锁总是假设每次操作数据库都会出现最坏的情况,每次去拿数据的时候都认为别人会修改,所以每次在拿数据的时候都会上锁,这样别人想拿这个数据就会阻塞,直到它拿到锁(共享资源每次只给一个线程使用,其他线程阻塞,用完之后再把资源转让给其他线程)。
select ... for update是MySQL中的悲观锁。注:select ... for update语句执行过程中所有扫描的行都会被锁上,因此在MySQL中使用悲观锁必须确定使用了索引,而不是全表扫描,否则将会把整个表锁住。
悲观锁不适合的场景较多,因为悲观锁大多数情况下依靠数据库的锁机制来实现,以保证程序的并发访问性,同时这样对数据库性能开销影响也很大,特别是对长事务而言。
乐观锁
乐观锁认为对同一数据的并发操作不会总发生,属于小概率事件,没必要每次都对数据上锁,但是在更新的时候会判读一下在此期间别人有没有去更新这个数据,也就是不采用数据库自身的锁机制,而是通过程序来实现。在程序上,可以采用版本号机制或CAS机制实现。乐观锁适用于多度的应用类型,这样可以提高吞吐量。
1.乐观锁的版本号机制
在表中设计一个版本字段version,第一次读的时候会获取verson字段值。然后对数据进行更新或删除操作时,会执行update ... set version = version + 1 where version = version。此时如果已经有事务对这条数据进行了更改,修改就不会成功。
2.乐观锁的时间戳机制
时间戳机制和版本号机制一样,就是在提交更新的时候,将当前数据的时间戳和更新之前取得的时间戳进行比较,如果两者一致则更新,否则就是版本冲突。
注:如果数据表采用读写分离,当master表中写入的数据没有及时同步到slave表中时,会造成更新一直失效的问题。此时需要强制读取master表中的数据。另外如果对同一条数据进行频繁修改的话,那么就会出现每次修改都只有一个事务能更新成功,在业务感知上就有大量的失败操作。
全局锁
全局锁就是对整个数据库实例加锁。当你需要让整个数据库处于只读状态时,可以使用下面的这个命令,使用后其他线程的以下语句会被阻塞:数据的增删改、数据定义语句和更新类事务的提交语句。
全局锁命令:flush tables with read lock
死锁
产生死锁的必要条件
1.两个或两个以上事务
2.每个事务都已经持有锁并且尝试申请新的锁
3.锁资源同时只能被一个事务持有或者不兼容
4.事务之间因为持有锁和申请锁导致彼此循环等待
死锁的关键在于:两个(或以上)的Session加锁的顺序不一致。
如何处理死锁
方式1:等待,直到超时
即当两个事务互相等待时,当一个事务等待时间超过阈值,就将其回滚,另外事务继续进行。参数innodb_lock_wait_timeout用来设置超时时间。
方式2:使用死锁检测进行死锁处理
innodb还提供了wait-for graph算法来主动进行死锁检测,每当加锁请求无法立即满足需要并进入等待时,wait- for graph算法都会被触发。
如何避免死锁
1.合理设计索引,使业务SQL尽可能通过索引定位更少的行,减少锁竞争
2.调整业务逻辑SQL执行顺序,避免update/delete长时间持有锁的SQL在事务前
3.避免大事务,将大事务拆分成小事务。
4.并发较高的系统中,不要显式的枷锁,特别是在事务里显式加锁。
5.降低隔离级别
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。