基本简介

锁是计算机协调多个进程或线程并发访问某一资源的机制。锁保证数据并发访问的一致性、有效性。锁冲突也是影响数据库并发访问性能的一个重要因素。锁是Mysql在服务器层和存储引擎层的的并发控制。
加锁是消耗资源的,锁的各种操作,包括获得锁、检测锁是否是否已解除、释放锁等。

MySQL并发访问的情况

读-读情况

即并发事务相继读取相同的记录。读取操作本身不会对记录有任何影响,并不会引起什么问题,所以允许这种情况的发生。

写-写情况

在这种情况下会发生脏写的问题,任何一种隔离级别都不允许这种问题的发生。所以在多个未提交事务相继对一条记录做改动时,需要让它们排队执行,这个排队的过程其实是通过锁来实现的。
当一个事务想对记录做改动时,事务首先会看看内存中有没有与这条记录关联的锁结构,一开始是没有锁结构和记录进行关联的,当没有的时候就会在内存中生成一个锁结构与之关联。image.png
image.png
当事务T1改动了这条记录后,就生成了一个锁结构与该记录关联,因为之前没有别的事务为这条记录加锁,所以is_waiting属性就是false,我们把这个场景就称之为获取锁成功,或者加锁成功,然后就可以继续执行操作了。
在事务T1提交之前,如果另一个事务T2也想对该记录做改动,那么T2先看看有没有锁结构与这条记录关联,发现有一个锁结构与之关联后,也生成了一个锁结构与这条记录关联,不过锁结构的is_waiting属性值为true,表示当前事务需要等待,我们把这个场景就称之为获取锁失败,或者加锁失败。
image.png
在事务T1提交之后,就会把该事务生成的锁结构释放掉,然后看看还有没有别的事务在等待获取锁,发现了事务T2还在等待获取锁,所以把事务T2对应的锁结构的is_waiting属性设置为false,然后把该事务对应的线程唤醒,让它继续执行,此时事务T2就算获取到锁了。
image.png

读-写情况

读-写或写-读,即一个事务进行读取操作,另一个进行改动操作。这种情况下可能发生脏读、不可重复读、幻读的问题。
方案一:使用MVCC。
方案二:读、写操作都采用加锁的方式。

锁分类

从数据操作的类型划分

基本简介

  • 共享锁(读锁):英文用S表示。事务获取共享锁后,对数据可以读,但不能写。针对同一份数据,多个读操作可以同时进行而不会相互影响(select)。
  • 排他锁(写锁) :英文用X表示。当加上排他锁的数据正在被写时,其他事务不能读取它,也不能写。当前写操作未完成之前,会阻塞其他读和写操作(update,insert,delete)。

image.png

读锁转化为排他锁

在采用加锁方式解决脏读、不可重复读、幻读这些问题时,读取一条记录时只获取该记录的S锁,其实是不严谨的,有时候需要在读取记录时就获取记录的X锁,来禁止别的事务读写该记录。

  1. SELECT ... LOCK IN SHARE MODE;
  2. SELECT ... FOR SHARE; #8.0新增语法

如果事务执行了该语句,那它会为读取到的记录加S锁,允许别的事务继续获取这些记录的S锁,而不能获取X锁。如果想获得X锁,那么就会被阻塞,直到当前事务提交,将S锁释放掉。

  1. SELECT ... FOR UPDATE;

如果当前事务执行了该语句,那么它会为读取到的记录加X锁,这样既不允许别的事务获取这些记录的S锁,也不允许获取这些记录的X锁。如果别的事务想要获取这些记录的S锁或者X锁,那么它们会阻塞,直到当前事务提交之后将这些记录上的X锁释放掉。
也就是说,读锁是可以变成排他锁的,我们说读锁也被称为共享锁是因为大部分情况下读锁就是共享锁。
image.png

从数据操作的粒度划分

基本简介

MySQL不同的存储引擎支持不同的锁机制,所有的存储引擎都以自己的方式显现了锁机制,服务器层完全不了解存储引擎中的锁实现。

  • MyISAM 和 MEMORY 存储引擎采用的是表级锁(table-level locking)。
  • BDB 存储引擎采用的是页面锁(page-level locking),但也支持表级锁。
  • InnoDB 存储引擎既支持行级锁(row-level locking),也支持表级锁,但默认情况下是采用行级锁。

默认情况下,表锁和行锁都是自动获得的, 不需要额外的命令。
但是在有的情况下, 用户需要明确地进行锁表或者进行事务的控制, 以便确保整个事务的完整性,这样就需要使用事务控制和锁定语句来完成。

不同粒度锁的比较

表级锁

该锁会锁定整张表,它是MySQL中最基本的锁策略,并不依赖于存储引擎〈不管你是MySQL的什么存储引擎,对于表锁的策略都是一样的),并且表锁是开销最小的策略(因为粒度比较大)。由于表级锁一次会将整个表锁定,所以可以很好的避免死锁问题。当然,锁的粒度大所带来最大的负面影响就是出现锁资源争用的概率也会最高,导致并发率大打折扣。
开销小,加锁快;不会出现死锁;锁定粒度大,发生锁冲突的概率最高,并发度最低。这些存储引擎通过总是一次性同时获取所有需要的锁以及总是按相同的顺序获取表锁来避免死锁。表级锁更适合于以查询为主,并发用户少,只有少量按索引条件更新数据的应用,如Web 应用。
MySQL表级锁共有两种模式:表共享读锁(Table Read Lock)和表独占写锁(Table Write Lock)
image.png

行级锁

开销大,加锁慢;会出现死锁;锁定粒度最小,发生锁冲突的概率最低,并发度也最高。最大程度的支持并发,同时也带来了最大的锁开销。在 InnoDB 中,除单个 SQL 组成的事务外,锁是逐步获得的,这就决定了在 InnoDB 中发生死锁是可能的。行级锁只在存储引擎层实现,而Mysql服务器层没有实现。 行级锁更适合于有大量按索引条件并发更新少量不同数据,同时又有并发查询的应用,如一些在线事务处理(OLTP)系统。

页面锁

就是在页的粒度上进行锁定。开销和加锁时间界于表锁和行锁之间,因为一个页中可以有多个行记录。会出现死锁,锁定粒度界于表锁和行锁之间,并发度一般。
每个层级的锁数量是有限制的,因为锁会占用内存空间,锁空间的大小是有限的。当某个层级的锁数量超过了这个层级的阈值时,就会进行锁升级。锁升级就是用更大粒度的锁替代多个更小粒度的锁,比如InnoDB中行锁升级为表锁,这样做的好处是占用的锁空间降低了,但同时数据的并发度也下降了。

从对待锁的态度划分

可以将锁分成乐观锁和悲观锁,这两种锁是看待数据并发的思维方式,不是锁,而是锁的设计思想。

悲观锁

悲观锁是一种思想,顾名思义,就是很悲观,对数据被其他事务的修改持保守态度,会通过数据库自身的锁机制来实现,从而保证数据操作的排它性。
悲观锁总是假设最坏的情况,每次去拿数据的时候都认为别人会修改,所以每次在拿数据的时候都会上锁,这样别人想拿这个数据就会被阻塞,直到它释放锁。比如行锁,表锁等,读锁,写锁等,都是在做操作之前先上锁,当其他线程想要访问数据时,都需要阻塞挂起。Java中synchronized和ReentrantLock等独占锁就是悲观锁思想的实现。
注意: select … for update语句执行过程中所有扫描的行都会被锁上,因此在MySQL中用悲观锁必须确定使用了索引,而不是全表扫描,否则将会把整个表锁住。
最经典的例子就是超卖:两个事务都减少了商品的数量,如果不用锁,最终可能会出现商品数量为负数的情况。
悲观锁不适用的场景较多,它存在一些不足,因为悲观锁大多数情况下依靠数据库的锁机制来实现,以保证程序的并发访问性,同时这样对数据库性能开销影响也很大,特别是长事务而言,这样的开销往往无法承受,这时就需要乐观锁。

乐观锁

乐观锁认为对同一数据的并发操作不会总发生,属于小概率事件,不用每次都对数据上锁,但是在更新的时候会判断一下在此期间别人有没有去更新这个数据,也就是不采用数据库自身的锁机制,而是通过程序来实现。在程序上,我们可以采用版本号机制或者CAS机制实现。乐观锁适用于多读的应用类型,这样可以提高吞吐量。在Java中 java.util.concurrent.atomic包下的原子变量类就是使用CAS实现的。

版本号机制

在表中设计一个版本字段 version,第一次读的时候,会获取version字段的取值。然后对数据进行更新或删除操作时,会执行UPDATE … SET version=version+1 WHERE version=当前版本号。此时如果已经有事务对这条数据进行了更改,修改就不会成功(WHERE语句找不到)。
类似SVN版本管理系统,当我们修改代码进行提交时,首先会检查当前版本号与服务器上的版本号是否一致。

时间戳机制

时间戳和版本号机制一样,也是在更新提交的时候,将当前数据的时间戳和更新之前取得的时间戳进行比较,如果两者一致则更新成功,否则就是版本冲突。
你能看到乐观锁就是程序员自己控制数据并发操作的权限,基本是通过给数据行增加一个戳(版本号或者时间戳),从而证明当前拿到的数据是否最新。
image.png

按加锁的方式划分

隐式锁

image.png
隐式锁的逻辑过程如下:

  1. InnoDB的每条记录中都一个隐含的trx_id字段,这个字段存在于聚簇索引的B+Tree中。
  2. 在操作一条记录前,首先根据记录中的trx_id检查该事务是否是活动的事务(未提交或回滚)。如果是活动的事务,说明发生锁冲突,给它一个锁,is_waiting是false,给自己一个锁,is_wating是true。如果是已经提交的事务,也就是没有冲突,不加锁(等待下一个事务给自己加锁),跳到步骤4。
  3. 等待加锁成功,被唤醒,或者超时。
  4. 写数据,并将自己的trx_id写入trx_id字段。

    显示锁

    通过特定的语句进行加锁。

    两者都能用的显式加锁方法

    基本介绍
    Mysql也支持 lock tables 和 unlock tables,这都是在服务器层(MySQL Server层)实现的,和存储引擎无关,它们有自己的用途,并不能替代事务处理:
  • LOCK TABLES 可以锁定用于当前线程的表。如果表被其他线程锁定,则当前线程会等待,直到可以获取所有锁定为止。
  • UNLOCK TABLES 可以释放当前线程获得的任何锁定。当前线程执行另一个 LOCK TABLES 时,
    或当与服务器的连接被关闭时,所有由当前线程锁定的表被隐含地解锁。

lock/unlock tables

  • 在用 LOCK TABLES 对 InnoDB 表加锁时要注意,要将 AUTOCOMMIT 设为 0,否则MySQL 不会给表加锁。
  • 事务结束前,不要用 UNLOCK TABLES 释放表锁,因为 UNLOCK TABLES会隐含地提交事务。
  • COMMIT 或 ROLLBACK 并不能释放用 LOCK TABLES 加的表级锁,必须用UNLOCK TABLES 释放表锁。 ```sql SET AUTOCOMMIT = 0; LOCK TABLES t1 WRITE, t2 READ; —给t1表加写锁,给t2表加读锁

COMMIT; UNLOCK TABLES; —释放锁

  1. <a name="xqh01"></a>
  2. ## 其他的一些锁
  3. <a name="O8w0l"></a>
  4. ### 意向锁
  5. **参见InnoDB的意向锁。**
  6. <a name="Y9sOn"></a>
  7. ### 自增锁
  8. **自增锁(AUTO-INC锁)是当向使用含有AUTO_INCREMENT列(自增列)的表中插入数据时需要获取的一种特殊的表级锁,在执行插入语句时就在表级别加一个AUTO-INC锁,然后为每条待插入记录的AUTO_INCREMENT修饰的列分配递增的值,在该语句执行结束后,再把AUTO-INC锁释放掉。一个事务在持有AUTO-INC锁的过程中,其他事务的插入语句都要被阻塞可以保证一个语句中分配的递增值是连续的。也正因为此,其并发性显然并不高。**
  9. <a name="ot7cO"></a>
  10. ### 元数据锁
  11. **MySQL5.5引入了meta data lock,简称MDL锁,属于表锁范畴。MDL的作用是,保证读写的正确性。比如,如果一个查询正在遍历一个表中的数据,而执行期间另一个线程对这个表结构做变更,增加了一列,那么查询线程拿到的结果跟表结构对不上,肯定是不行的。**<br />**因此,当对一个表做增删改查操作的时候,加MDL读锁。当要对表做结构变更操作的时候,加MDL写锁。读锁之间不互斥,因此你可以有多个线程同时对一张表增删改查。读写锁之间、写锁之间是互斥的,用来保证变更表结构操作的安全性,解决了DML和DDL操作之间的一致性问题。**<br />**不需要显式使用,在访问一个表的时候会被自动加上。**<br />![image.png](https://cdn.nlark.com/yuque/0/2022/png/23175776/1647951503876-95ed52d8-e013-4e64-b582-4a0f30f19720.png#clientId=uc400ffbc-64cc-4&crop=0&crop=0&crop=1&crop=1&from=paste&height=169&id=u71e10729&margin=%5Bobject%20Object%5D&name=image.png&originHeight=337&originWidth=1734&originalType=binary&ratio=1&rotation=0&showTitle=false&size=95809&status=done&style=none&taskId=ue05d9cbf-ea51-40dd-8610-aa035e2480d&title=&width=867)<br />**A会对表加上一个MDL读锁,之后B要加MDL写锁会被blocked,因为A的MDL读锁还没有释放。C申请的MDL读锁会被B阻塞。**
  12. <a name="eVA9C"></a>
  13. ### 全局锁
  14. **全局锁就是对整个数据库实例加锁。当你需要让整个库处于只读状态的时候,可以使用这个命令,之后其他线程的以下语句会被阻塞:数据更新语句(数据的增删改)、数据定义语句(包括建表、修改表结构等)和更新类事务的提交语句。全局锁的典型使用场景是:做全库逻辑备份。**
  15. ```sql
  16. Flush tables with read lock

MyISAM 表锁

基本介绍

  • 表共享读锁(Table Read Lock): 不会阻塞其他用户对同一表的读请求,但会阻塞对同一表的写请求。
  • 表独占写锁(Table Write Lock): 会阻塞其他用户对同一表的读和写操作;

MyISAM 表的读操作与写操作之间,以及写操作之间是串行的。当一个线程获得对一个表的写锁后, 只有持有锁的线程可以对表进行更新操作。 其他线程的读、 写操作都会等待,直到锁被释放为止。
默认情况下,MyISAM的写锁比读锁具有更高的优先级:当一个锁释放时,这个锁会优先给写锁队列中等候的获取锁请求,然后再给读锁队列中等候的获取锁请求。
这也正是 MyISAM 表不太适合于有大量更新操作和查询操作应用的原因,因为,大量的更新操作会造成查询操作很难获得读锁,从而可能永远阻塞。同时,一些需要长时间运行的查询操作,也会使写线程 “饿死”,应用中应尽量避免出现长时间运行的查询操作(在可能的情况下可以通过使用中间表等措施对SQL语句做一定的 “分解” ,使每一步查询都能在较短时间完成,从而减少锁冲突。如果复杂查询不可避免,应尽量安排在数据库空闲时段执行,比如一些定期统计可以安排在夜间执行)。
可以设置改变读锁和写锁的优先级:

  • 通过指定启动参数low-priority-updates,使MyISAM引擎默认给予读请求以优先的权利。
  • 通过执行命令SET LOW_PRIORITY_UPDATES=1,使该连接发出的更新请求优先级降低。
  • 通过指定INSERT、UPDATE、DELETE语句的LOW_PRIORITY属性,降低该语句的优先级。
  • 给系统参数max_write_lock_count设置一个合适的值,当一个表的读锁达到这个值后,MySQL就暂时将写请求的优先级降低,给读进程一定获得锁的机会。

    加表锁方法

    正常方法

    MyISAM 在执行查询语句 (SELECT) 前,会自动给涉及的表加读锁,在执行更新操作
    (UPDATE、DELETE、INSERT 等)前,会自动给涉及的表加写锁,这个过程并不需要用户干预。因此,用户一般不需要直接用 LOCK TABLE 命令给 MyISAM 表显式加锁。

    在自动加锁的情况下,MyISAM 总是一次获得 SQL 语句所需要的全部锁,这也正是 MyISAM 表不会出现死锁(Deadlock Free)的原因。


    并发插入

    MyISAM存储引擎支持并发插入,以减少给定表的读和写操作之间的争用。
    如果MyISAM表在数据文件中间没有空闲块,则行始终插入数据文件的末尾。 在这种情况下,你可以自由混合并发使用MyISAM表的INSERT和SELECT语句而不需要加锁——在其他线程进行读操作的时候,同时将行插入到MyISAM表中。 文件中间的空闲块可能是从表格中间删除或更新的行而产生的。 如果文件中间有空闲块,则并发插入会被禁用,但是当所有空闲块都填充有新数据时,它又会自动重新启用。 要控制此行为,可以使用MySQL的concurrent_insert系统变量。
    如果你使用LOCK TABLES显式获取表锁,则可以请求READ LOCAL锁而不是READ锁,以便在锁定表时,其他会话可以使用并发插入。

  • 当concurrent_insert设置为0时,不允许并发插入。

  • 当concurrent_insert设置为1时,如果MyISAM表中没有空闲块,MyISAM允许在一个线程读表的同时,另一个线程从表尾插入记录。这也是MySQL的默认设置。
  • 当concurrent_insert设置为2时,无论MyISAM表中有没有空闲块,都允许在表尾并发插入记录。

    查询表级锁争用情况

    可以通过检查 table_locks_waited 和 table_locks_immediate 状态变量来分析系统上的表锁的争夺,如果 Table_locks_waited 的值比较高,则说明存在着较严重的表级锁争用情况:

    1. mysql> SHOW STATUS LIKE 'Table%';
    2. +-----------------------+---------+
    3. | Variable_name | Value |
    4. +-----------------------+---------+
    5. | Table_locks_immediate | 1151552 |
    6. | Table_locks_waited | 15324 |
    7. +-----------------------+---------+

    InnoDB锁

    InnoDB的表锁

    一般情况下,不会使用InnoDB存储引擎提供的表级别的S锁和X锁。只会在一些特殊情况下,比方说崩溃恢复过程中用到。当然我们也可以手动添加:
    image.png

    InnoDB的意向锁

    基本简介

    InnoDB支持多粒度锁,它允许行级锁和表级锁共存。意向锁的存在就是为了协调行锁和表锁的关系,它是一种不与行级锁冲突的表级锁,表明”某个事务正在某些行持有了锁,或者该事务准备去持有锁”。
    意向锁有两种 ——

  • 意向共享锁(IS):当一个事务想要给一个数据行加共享锁时,必须先取得该表的 IS 锁。

  • 意向排他锁(IX):当一个事务想要给一个数据行加排他锁时,必须先取得该表的 IX 锁。

意向锁是由存储引擎自己维护的,用户无法手动操作意向锁,在为数据行加共享/排他锁之前,InooDB会先获取该数据行所在数据表的对应意向锁。意向锁的目的就是告诉其他事务,已经有事务锁定了表中的某些记录。

解决的问题

假设事务A拿到了表中一行的读锁,让这一行只能读,不能写。之后,事务B申请整个表的写锁。如果事务B申请成功,那么理论上它就能修改表中的任意一行,这与A持有的行锁是冲突的。数据库需要避免这种冲突,也就是让B的申请被阻塞,直到A释放了行锁。数据库要怎么判断这个冲突呢?
step1:判断表是否已被其他事务用表锁锁住(此时没有,因为A对应的是个行锁)
step2:判断表中的每一行是否已被行锁锁住。
注意step2,这样的判断方法效率实在不高,因为需要遍历整个表。于是就有了意向锁。在意向锁存在的情况下,事务A必须先申请表的意向共享锁,成功后再申请某一行的行锁。上面的判断可以改成:
step1:不变。
step2:发现表上有意向共享锁,说明表中有些行被共享行锁锁住了,因此,事务B申请表的写锁会被阻塞。
这样效率就会提高很多。

InnoDB的行锁

InnoDB的加锁方法

隐式锁定

  • 意向锁是 InnoDB 自动加的, 不需用户干预。
  • 对于 UPDATE、 DELETE 和 INSERT 语句, InnoDB会自动给涉及数据集加排他锁 (X)。
  • 对于普通 SELECT 语句,InnoDB 不会加任何锁。
  • 锁只有在执行 commit 或者 rollback 的时候才会释放,并且所有的锁都是在同一时刻被释放。

    显示锁定

    事务可以通过以下语句显式给记录集加共享锁或排他锁:
    1. select ... lock in share mode //共享锁
    2. select ... for update //排他锁
    select … lock in share mode
    lock in share mode 子句的作用就是将查找到的数据的索引项加上一个 共享锁,这个就是表示其他的事务只能对这些数据进行简单的 select 操作,并不能够进行 DML 操作。
    使用场景:为了确保自己查到的数据没有被其他的事务正在修改,也就是说确保查到的数据是最新的数据。但是自己不一定能够修改数据,因为有可能其他的事务也对这些数据用了 in share mode 方式上了 S 锁。
    select … for update
    在执行这个 select 查询语句的时候,会将对应的索引加上排他锁(X 锁),也就是说这个语句对应的锁就相当于 update 带来的效果。
    select … for update 的使用场景:为了让自己查到的数据确保是最新数据,并且查到后的数据自己要修改,不希望其他事务读取,需要用到 for update 子句。
    性能影响
    select lock in share mode 语句是一个给查找的数据上一个共享锁(S 锁)的功能,它允许其他的事务也对该数据上S锁,但是不能够允许对该数据进行修改。如果不及时的 commit 或者 rollback 也可能会造成大量的事务等待。
    select for update 语句,相当于一个 update 语句。在业务繁忙的情况下,如果事务没有及时的commit 或者 rollback 可能会造成其他事务长时间的等待,从而影响数据库的并发使用效率。

    InnoDB行锁实现方式

    InnoDB 行锁是通过给索引上的索引项加锁来实现的,这一点 MySQL 与 Oracle 不同,后者是通过在数据块中对相应数据行加锁来实现的。InnoDB 这种行锁实现特点意味着:只有通过索引条件检索数据,InnoDB 才使用行级锁,否则,InnoDB 将使用表锁!
    不论是使用主键索引、唯一索引或普通索引,InnoDB 都会使用行锁来对数据加锁。
    只有执行计划真正使用了索引,才能使用行锁:即便在条件中使用了索引字段,但是否使用索引来检索数据是由 MySQL 通过判断不同执行计划的代价来决定的,如果 MySQL 认为全表扫描效率更高,比如对一些很小的表,它就不会使用索引,这种情况下 InnoDB 将使用表锁,而不是行锁。因此,在分析锁冲突时,别忘了使用 explain 检查 SQL 的执行计划,以确认是否真正使用了索引。
    由于 MySQL 的行锁是针对索引加的锁,不是针对记录加的锁,所以如果多个事务是访问不同行的记录, 但是使用相同的索引键, 是会出现锁冲突的(后使用这些索引的事务需要等待先使用索引的事务释放锁后,才能获取锁)。

    InnoDB记录锁

    记录锁就是把一条记录锁上,官方的名称为:LOCK_REC_NOT_GAP。是使用最多的行锁。记录锁有S锁和X锁之分,兼容性规则相同。
    image.png

InnoDB间隙锁

基本介绍

当我们用范围条件而不是相等条件检索数据,并请求共享或排他锁时,InnoDB会给符合条件的已有数据记录的索引项加锁。对于键值在条件范围内但并不存在的记录,叫做 “间隙 (GAP) “,InnoDB也会对这个“间隙”加锁(为了解决幻读问题),这种锁机制就是所谓的间隙锁(Next-Key锁)。
image.png

  1. select * from student where id < 8 lock in share mode; #究竟是(3,8)还是(-∞,8)

图中id值为8的记录加了gap锁,意味着id列的值(3,8)这个区间的新记录是不允许立即插入的。比如,有另外一个事务想插入id=5的记录,它定位到该新记录的下一条记录的id值为8,而id=8的记录上又有一个gap锁,所以就会阻塞插入操作,直到拥有这个gap锁的事务提交了之后,id列的值在区间(3,8)中的新记录才可以被插入。
那么如果想让(20,+∞)这个范围不被插入:数据页的两条特殊记录 —— Infimum记录,表示该页面中最小的记录 和 Supremum记录,表示该页面中最大的记录 就会排上用场。我们可以给索引中的最后一条记录所在页面的Supremum记录加上一个gap锁。
image.png

  1. select * from student where id > 20 lock in share mode;
  2. #因为是select,因此加的是读锁,(20,+∞)还是可以获取读锁

很显然,在使用范围条件检索并锁定记录时,InnoDB这种加锁机制会阻塞符合条件范围内键值的并发插入,这往往会造成严重的锁等待。因此,在实际应用开发中,尤其是并发插入比较多的应用,我们要尽量优化业务逻辑,尽量使用相等条件来访问更新数据,避免使用范围条件。

使用间隙锁目的

  1. 防止幻读,以满足相关隔离级别的要求。
  2. 满足恢复和复制的需要:

MySQL 通过 binlog 录入执行成功的 INSERT、UPDATE、DELETE 等更新数据的 SQL 语句,并由此实现 MySQL 数据库的恢复和主从复制。MySQL 的恢复机制(复制其实就是在 Slave Mysql 不断做基于 BINLOG 的恢复)有以下特点:
一:MySQL 的恢复是 SQL 语句级的,也就是重新执行 BINLOG 中的 SQL 语句。
二:MySQL 的 Binlog 是按照事务提交的先后顺序记录的, 恢复也是按这个顺序进行的。
由此可见,MySQL 的恢复机制要求:在一个事务未提交前,其他并发事务不能插入满足其锁定条件的任何记录,也就是不允许出现幻读(如果不加间隙锁的话,其他事务有可能会插入数据到间隙中,造成幻读)。

InnoDB临键锁

有时候我们既想锁住某条记录,又想阻止其他事务在该记录前边的间隙插入新记录,所以InnoDB就提出了一种称之为Nelt-Key Locks的锁,官方的类型名称为: LOCK_ORDINARY,我们也可以简称为next-key锁。InnoDB默认的锁就是Next-Key Locks。next-key锁的本质就是一个记录锁和一个gap锁的合体,它既能保护该条记录,又能阻止别的事务将新记录插入被保护记录前边的间隙。image.png

  1. begin;
  2. select * from student where id <= 8 and id > 3 for update; #小于等号就是临键锁的作用

InnoDB插入意向锁

一个事务在插入一条记录时需要判断一下插入位置是不是被别的事务加了gap锁(以及记录锁,临键锁),如果有的话,插入操作需要等待,直到拥有gap锁的那个事务提交。但是InnoDB规定事务在等待的时候也需要在内存中生成一个锁结构,表明有事务想在某个间隙中插入新记录,但是现在在等待。
InnoDB就把这种类型的锁命名为Insert Intention Locks,官方的类型名称为:LOCK_INSERT_INTENTION,我们称为插入意向锁,也是一种Gap锁,不是意向锁,因为它是行级锁,意向锁是表级锁。它在insert中产生,位置是对应的间隙。
插入意向锁之间互不排斥,所以即使多个事务在同一区间插入多条记录,只要记录本身(主键、唯一索引)不冲突,那么事务之间就不会出现冲突等待。
假设T1为id=8的记录加了一个gap锁,然后T2和T3分别想向student表中插入id=4,id=5的两条记录,它们需要等待id=8记录的gap锁释放。示意图如下:
image.png
从图中可以看到,由于T1持有gap锁,所以T2和T3需要生成一个插入意向锁的锁结构并且处于等待状态。当T1提交后会把它获取到的锁都释放掉,这样T2和T3就能获取到对应的插入意向锁了(本质上就是把插入意向锁对应锁结构的is_waiting属性改为false),T2和T3之间也并不会相互阻塞,它们可以同时执行插入操作。事实上插入意向锁并不会阻止别的事务继续获取该记录上任何类型的锁。

获取行级锁争用情况

可以通过检查 InnoDB_row_lock 状态变量来分析系统上的行锁的争夺情况:

  1. mysql> show status like 'innodb_row_lock%';
  2. +-------------------------------+-------+
  3. | Variable_name | Value |
  4. +-------------------------------+-------+
  5. | InnoDB_row_lock_current_waits | 0 |
  6. | InnoDB_row_lock_time | 0 |
  7. | InnoDB_row_lock_time_avg | 0 |
  8. | InnoDB_row_lock_time_max | 0 |
  9. | InnoDB_row_lock_waits | 0 |
  10. +-------------------------------+-------+
  11. 5 rows in set (0.01 sec)

image.png

死锁

死锁产生

  • 死锁是指两个或多个事务在同一资源上相互占用,并请求锁定对方占用的资源,从而导致恶性循环(A说我先要拿到B的资源才能解锁,B说我先要拿到A的资源才能解锁)。
  • 当事务试图以不同的顺序锁定资源时,就可能产生死锁。多个事务同时锁定同一个资源时也可能会产生死锁。
  • 锁的行为和顺序和存储引擎相关。以同样的顺序执行语句,有些存储引擎会产生死锁有些不会——死锁有双重原因:真正的数据冲突 以及 存储引擎的实现方式。

image.png

检测死锁

数据库系统实现了各种死锁检测和死锁超时的机制。InnoDB存储引擎能检测到死锁的循环依赖并立即返回一个错误。

死锁处理

使用死锁检测

InnoDB目前提供了 wait-for graph算法来主动进行死锁检测,每当加锁请求无法立即满足并进入等待时,wait-for graph算法就会被触发。一般将持有最少行级排他锁的事务进行回滚。image.png
从这两个链表我们可以得知锁的等待信息。比如 T1在row1里等待T2释放X锁,T2在row2里等待 T1释放S锁,还有T4的S锁(虽然它也在等待)…… 据此我们可以做出一个wait-for graph(等待图):image.png
一旦检测到回路就说明有死锁,这时候InnoDB存储引擎会选择回滚undo量最小的事务,让其他事务继续执行(innodb_deadlock_detect=on表示开启这个逻辑)。
缺点:每个新的被阻塞的线程,都要判断是不是由于自己的加入导致了死锁,这个操作时间复杂度是o(n),n是并发线程的数量。image.png
还可以考虑通过将某个数据改为由数据计算,来减少锁冲突。比如,连锁超市账户总额的记录,可以通过记录的总和算出,而不是每次修改一条数据都要改那一行的总额。

等待,直到超时

死锁会影响性能而不是会产生严重错误,因为InnoDB会自动检测死锁状况并回滚其中一个受影响的事务。在高并发系统上,当许多线程等待同一个锁时,死锁检测可能导致速度变慢。 有时当发生死锁时,禁用死锁检测(使用innodb_deadlock_detect配置选项)可能会更有效。
当一个事务等待时间超过设置的阈值时,就将其回滚。这时可以依赖innodb_lock_wait_timeout 设置进行事务回滚。
缺点:对于在线服务来说,这个等待时间往往是无法接受的,也不能减少阈值,因为会误伤到普通的锁等待。

MyISAM避免死锁

在自动加锁的情况下,MyISAM 总是一次获得 SQL 语句所需要的全部锁,所以 MyISAM 表不会出现死锁。

InnoDB避免死锁

  • 可以在事务开始时为预期要修改的每个行 使用SELECT … FOR UPDATE语句来获取必要的锁,即使这些行的更改语句是在之后才执行的。
  • 在事务中,如果要更新记录,应该直接申请足够级别的锁,即排他锁,而不应先申请共享锁。如果先申请共享锁,那么其他事务可能也获得了相同记录的共享锁,从而造成锁冲突,甚至死锁。
  • 避免大事务,尽量把大事务拆成多个小事务来处理。
  • 调整SQL执行顺序,避免 update/delete 长时间持有锁的SQL在事务前面。
  • 合理设计索引,使业务SQL尽可能通过索引定位更少的行,减少锁竞争。
  • 如果事务需要修改或锁定多个表,则应在每个事务中以相同的顺序使用加锁语句。 在应用中,如果不同的程序会并发存取多个表,应尽量约定以相同的顺序来访问表,这样可以大大降低产生死锁的机会。
  • 在并发比较高的系统中,不要显式加锁。
  • 改变事务隔离级别。如果业务允许,将隔离级别调低也是比较好的选择。比如从RR调整到RC,可以避免很多因为gap锁造成的死锁。

如果出现死锁,可以用 SHOW INNODB STATUS 命令来确定最后一个死锁产生的原因。返回结果中包括死锁相关事务的详细信息,如引发死锁的 SQL 语句,事务已经获得的锁,正在等待什么锁,以及被回滚的事务等。据此可以分析死锁产生的原因和改进措施。

锁的内存结构

image.png

锁所在的事务信息

不论是表锁还是行锁,都是在事务执行过程中生成的,哪个事务生成了这个锁结构,这里就记录这个事务的信息。
锁所在的事务信息在内存结构中只是一个指针,通过指针可以找到内存中关于该事务的更多信息,比方说事务id等。

索引信息

对于行锁来说,需要记录一下加锁的记录是属于哪个索引的,这里也是一个指针。

表锁/行锁信息

表锁结构和行锁结构在这个位置的内容是不同的 —— 表锁:记载着是对哪个表加的锁,还有其他的一些信息。 行锁: Space ID:记录所在表空间。Page Number:记录所在页号。n_bits:代表最后那一堆比特位使用了多少比特。

type_mode

image.png
参考:https://achang.blog.csdn.net/article/details/122744306
image.png

其他信息

为了更好的管理系统运行过程中生成的各种锁结构而设计了各种哈希表和链表。

一堆比特位

比特位的数量是由上边提到的n_bits属性表示的。InnoDB数据页中的每条记录在记录头信息中都包含一个heap_no属性,伪记录Infimum的heap_no值为0,Supremum的heap_no值为1,之后每插入一条记录,heap_no值就增1。
对于行锁来说,一条记录就对应着一个比特位。一个页面中包含很多记录,用不同的比特位来区分到底是哪一条记录加了锁,为此行锁结构的末尾放置了一堆比特位。