锁机制

在数据库中,除传统的计算资源(如CPU、RAM、I/O等)的争用以外,数据也是一种供许多用户共享的资源。为保证数据的一致性,需要对 并发操作进行控制 ,因此产生了锁。同时 锁机制 也为实现MySQL的各个隔离级别提供了保证。 锁冲突 也是影响数据库 并发访问性能 的一个重要因素。所以锁对数据库而言显得尤其重要,也更加复杂。

MySQL的锁分类如下:

锁的内存结构

InnoDB的锁的内存结构如下:

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

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

  3. 表锁/行锁信息,表锁结构 和 行锁结构 在这个位置的内容是不同的:

  4. 表锁:记载着是对哪个表加的锁,还有其他的一些信息

  5. 行锁:记载了三个重要的信息

    1. Space ID :记录所在表空间。

    2. Page Number :记录所在页号。

    3. n_bits :对于行锁来说,一条记录就对应着一个比特位,一个页面中包含很多记录,用不同的比特位来区分到底是哪一条记录加了锁。为此在行锁结构的末尾放置了一堆比特位,这n_bits 属性代表使用了多少比特位。

  6. type_mode,是一个32位的数,被分成了如下几个部分:

    1. lock_mode,锁的模式,比如是共享、独占、意向、自增

    2. lock_type,锁的类型,表级锁还是行级锁

    3. rec_lock_type,间隙锁、记录锁、临建锁、元数据锁

    4. is_waiting,为了节省空间将该属性放在这,代表事务是否获取到了锁

  7. 其他信息,为了更好的管理系统运行过程中生成的各种锁结构而设计了各种哈希表和链表

  8. 一堆比特位,如果是 行锁结构 的话,在该结构末尾还放置了一堆比特位,比特位的数量是由上边提到的 n_bits 属性表示的。InnoDB数据页中的每条记录在 记录头信息 中都包含一个 heap_no 属性,伪记录 Infimum 的 heap_no 值为 0 , Supremum 的 heap_no 值为 1 ,之后每插入一条记录, heap_no 值就增1。 锁结 构 最后的一堆比特位就对应着一个页面中的记录,一个比特位映射一个 heap_no ,即一个比特位映射到页内的一条记录。

全局锁

全局锁就是对 整个数据库实例加锁。当你需要让整个库处于 只读状态 的时候,可以使用这个命令,之后其他线程的以下语句会被阻塞:数据更新语句(数据的增删改)、数据定义语句(包括建表、修改表结构等)和更新类事务的提交语句。全局锁的典型使用 场景 是:做全库逻辑备份

全局锁命令:

flush tables with read lock

表锁

表锁会锁定整张表,他是MySQL中最基本的锁策略,不依赖存储引擎。由于表锁一次会将整个表锁定,所以可以很好地避免死锁的问题,但是因为锁的粒度太大,出现锁争抢的概率也最高,所以并发效率处理很低。

查看已经添加表锁的表

show open tables where in_use > 0;

表级别的S锁和X锁

使用如下命令可以给表增加S锁或者X锁

  • LOCK TABLES 表名 READ:InnoDB存储引擎会对表加表级别的S锁

  • LOCK TABLES 表名 WRITE:InnoDB存储引擎会对表加表级别的X锁

如果要对表进行解锁,需要使用unlock:

lock tables mylock read;
unlock; -- 释放锁

一般情况下,不会使用InnoDB存储引擎提供的表级别的S锁X锁。只会在一些特殊情况下,比如崩溃恢复中用到。因为表锁不由InnoDB管理,所以InnoDB无法自动检测并处理这种锁的死锁。如果要在InnoDB中使用表锁,还要保证autocommit=0,innodb_table_locks = 1 这两个变量的值。

S锁和X锁的特点:

锁类型当前事物可读当前事务可写当前事务可操作其他表其他事务可读其他事务可写

读锁

×

×

×

写锁

×

×

×

意向锁(Intention Lock)

意向锁允许行级锁和表级锁共存,是InnoDB提供的一种多粒度锁(multiple granularity locking),它的存在主要为了:

  1. 协调行锁与表锁的关系

  2. 是一种不与行级锁冲突的表级锁

  3. 表明某个事务正在某些行持有了锁或该事务有意向准备去去持有锁

意向锁主要解决的问题:

  1. 有T1、T2两个线程

  2. T1对目标表增加了行锁-X锁

  3. T2想要对目标表增加表锁

  4. 如果没有意向锁,那么T2要遍历所有的行,查询是否有行加了行锁,如果有会进行互斥

  5. 如果有了意向锁,T1增加行锁后,会给当前表增加一个意向锁,T2再要给表增加表锁时,会发现有意向锁,避免遍历所有行操作,直接阻塞,大大提高了表锁的性能。

意向锁实际就是一个标记,标记当前表是否有行级锁。这样其他事务要对表进行加锁时,就不用遍历整张表判断是否可以加锁了。

增加行锁会自动增加表意向锁,根据行锁的类型,意向锁也分为两种:

  • 意向共享锁(IS锁),事务有意向对表中的某些行加共享锁(S锁)

    -- 增加行S锁,自动增加IS锁
    SELECT columns FROM table... LOCK IN SHARE MODE; 
  • 意向排他锁(X锁),是有有意向对表中的某项行加排他锁(X锁)

    -- 增加行X锁,自动增加IX锁
    SELECT columns FROM table... FOR UPDATE;

自增锁(AUTO-INC锁)

在使用MYSQL的过程中,我们可以为表的某个列增加AUTO_INCREMENT属性。因为AUTO_INCREMENT字段声明了自动增长,所以在插入语句内是不用赋值的,他会自动增长。

针对自增字段插入自动对自增字段的行为可以将插入语句大致可以分为3种情况:

  1. Simple Insert,简单插入,在插入之前就可以通过一些方式确认自增字段值的SQL:

    INSERT INTO teacher(id, name) VALUE(1, 'aaa');
    INSERT INTO teacher(id, name) VALUE(2, 'bbb'), (3, 'ccc');
    INSERT INTO teacher(name) VALUES('张三');
    INSERT INTO teacher(name) VALUES('李四'), ('王五'), ('赵六');
    REPLACE INTO TABLE (name) VALUES(1,'aa'), (2,'bb');
  2. Bulk inserts,批量插入,在SQL执行前无法得知自增字段的值的语句,InnoDB将每处理一行就给给记录分配一个值,比如:

    INSERT INTO Websites (name, country) SELECT app_name, country FROM apps WHERE id=1;
    LOAD DATA 语句
  3. Mixed-mode Inserts,混合模式插入,部分的数据在SQL执行前可以确认,部分的不可以:

    INSERT INTO teacher(id, name) VALUE(1, 'aaa'), (NULL, 'ccc'), (4, 'ddd'), (NULL, 'eee');

为了保证上述三种模式的插入可以正常工作,所以引入了自增锁。因为自增锁针对自增列进行加锁,目标是自增列的所有行,所以自增锁是表锁。他共有三种模式,每种方式的锁的实现不同,由变量innodb_autoinc_lock_mode决定:

  1. innodb_autoinc_lock_mode = 0,传统锁定模式

    每当执行insert的时候,都会得到一个表级锁(AUTO-INC锁),使得语句中生成的auto_increment为顺序,且在binlog中重放的时候,可以保证master与slave中数据的auto_increment是相同的。因为是表级锁,当在同一时间多个事务中执行insert的时候,对于AUTO-INC锁的争夺会 限制并发 能力。

  2. innodb_autoinc_lock_mode = 1,连续锁定模式

    在 MySQL 8.0 之前,连续锁定模式是 默认 的。在这个模式下,“bulk inserts”仍然使用AUTO-INC表级锁,并保持到语句结束。这适用于所有INSERT ... SELECT,REPLACE ... SELECT和LOAD DATA语句。同一时刻只有一个语句可以持有AUTO-INC锁。对于“Simple inserts”(要插入的行数事先已知),则通过在 mutex(轻量锁) 的控制下获得所需数量的自动递增值来避免表级AUTO-INC锁, 它只在分配过程的持续时间内保持,而不是直到语句完成。不使用表级AUTO-INC锁,除非AUTO-INC锁由另一个事务保持。如果另一个事务保持AUTO-INC锁,则“Simple inserts”等待AUTO-INC锁,如同它是一个“bulk inserts”。

  3. innodb_autoinc_lock_mode = 2,交错锁定模式

    从 MySQL 8.0 开始,交错锁模式是 默认 设置。在此锁定模式下,自动递增值 保证 在所有并发执行的所有类型的insert语句中是 唯一 且 单调递增 的。但是,由于多个语句可以同时生成数字(即,跨语句交叉编号),为任何给定语句插入的行生成的值可能不是连续的。

元数据锁(MDL 锁)

MDL锁不需要显示添加,他会按照以下方式隐式添加:

  1. 当对一个表做增删改查操作的时候,加 MDL读锁

  2. 当要对表做结构变更操作的时候,加 MDL 写锁

读读不互斥、读写/写读互斥

也就是说当发生表结构变更的时候,不能进行任何的增删改查操作,防止出现非预期的情况。

页锁

页锁就是在页的粒度上进行锁定,锁定的数据资源比行锁要多,因为一个页中可以有多个行记录。

  1. 当我们使用页锁的时候,会出现数据浪费的现象,但这样的浪费最多也就是一个页上的数据行。

  2. 页锁的开销介于表锁和行锁之间,会出现死锁。

  3. 锁定粒度介于表锁和行锁之间,并发度一般。

行锁

行锁也称为记录锁,用于锁定某条记录。Mysql本身没有提供对行锁的实现,行锁只在存储引擎中实现,且只有InnoDB才支持行锁。

  • 优点,锁的粒度小,发生锁冲突的概率比较低,可以实现的并发高。

  • 缺点,对锁的开销大,加锁比较慢,容易出现死锁。

记录锁(Record Locks)

记录锁就是将一条记录加锁,他的官方名称为LOCK_REC_NO_GAP,非间隙锁。比如我们将id=8的记录使用记录锁记录,如下所示,因为是无间隙锁,所以对其周围的数据没有影响:

间隙是指上图中,存在的记录之前不存在的记录的位置。比如 3和8之间存在 4~7的间隙。

记录锁也分为两种:

  • X型记录锁

  • S型记录锁

举例:

  1. 事务1对id=3的数据进行操作,默认会给李四这条数据增加一个X记录锁

  2. 事务2对id!=3的数据可以进行正常操作,因为其他行没有加锁

  3. 但是事务2对id=3的数据进行操作时将会阻塞等待锁释放

给行增加记录锁:

select * from EMP for update;          -- 给所有的行增加x记录锁
select * from EMP lock in share mode;  -- 给所有的行增加s记录锁

注意,x锁和s锁互斥,但是x锁不和无锁互斥,比如: select * from xx for updateselect * from xx lock in share互斥,但是与select * from xx 不互斥。

间隙锁(Gap Locks)

间隙锁是为了防止插入幻影记录提出的,给指定的不存在的间隙位置加一个范围锁,防止在操作时插入数据,发生幻读。下图展示了在id值为8的记录上增加间隙锁,意味着不可以在id为8这条记录前面的间隙新插入任何记录(也就是3~8之间),所以间隙锁主要解决幻读的问题:

间隙锁的范围也可以是一个不存在的id记录,比如如下SQL:

-- 给3~6之间增加间隙锁,6是一个不存在的位置
select * from student where id = 7;

如果想要在范围21~25增加间隙锁:

select * from student where id = 26;

如果想要在21 ~ +∞ 上增加间隙锁,可以借助Supermum记录,它表示最大记录,SQL可以这么写:

select * from student where id >= 20;

如果想过要在-∞ ~ 20这个范围内增加间隙,可以借助Infimum记录,他表示最小记录,SQL可以这么写:

select * from student where id <= 21;

如果想要在0~20这个范围内增加间隙:

select * from student where id < 21 and id > 0;

在mysql的事务隔离级别REPEATABLE-READ下,给记录加锁默认会添加间隙锁,可以解决大部分的幻读问题。

间隙锁容易出现死锁的问题,因为他都是操作范围的行,很容易发生交叉锁的问题。

临建锁(Next-Key Locks)

间隙锁影响的是开区间的范围,比如

select * from student where id = 25; -- 影响的范围是 21 ~ 24这个范围

而有时候我们既想 锁住某条记录 ,又想 阻止 其他事务在该记录前边的 间隙插入新记录,所以InnoDB就提出了一种称之为 Next-Key Locks 的锁,官方的类型名称为: LOCK_ORDINARY ,我们也可以简称为next-key锁 。Next-Key Locks是在存储引擎 innodb 、事务级别在 可重复读 的情况下使用的数据库锁,innodb默认的锁就是Next-Key locks

比如,上述范围如果是临建锁:

select * from student where id = 25; -- 影响的范围是 21 ~ 25这个范围

插入意向锁(Insert Intention Locks)

  1. 一个事务在 插入 一条记录时需要判断一下插入位置是不是被别的事务加了 gap锁 ( next-key锁也包含 gap锁),如果有的话,插入操作需要等待,直到拥有 gap锁 的那个事务提交。

  2. 但是InnoDB规定事务在等待的时候也需要在内存中生成一个锁结构,表明有事务想在某个间隙中插入新记录,但是现在正在等待中。

  3. InnoDB就把这种类型的锁命名为 Insert Intention Locks ,官方的类型名称为:LOCK_INSERT_INTENTION ,我们称为 插入意向锁 。

  4. 插入意向锁是一种 Gap锁 ,不是表的意向锁,在insert操作时产生。

  5. 插入意向锁是在插入一条记录行前,由 INSERT 操作产生的一种间隙锁 。事实上插入意向锁并不会阻止别的事务继续获取该记录上任何类型的锁。

锁升级

每个层级(表、页、行锁)的锁数量是有限制的,因为锁会占用内存空间, 锁空间的大小是有限的 。当某个层级的锁数量超过了这个层级的阈值时,就会进行锁升级 。锁升级就是用更大粒度的锁替代多个更小粒度的锁,比如InnoDB 中行锁升级为表锁,这样做的好处是占用的锁空间降低了,但同时数据的并发度也下降了。

乐观锁和悲观锁

从对待锁的态度来看锁的话,可以将锁分成乐观锁和悲观锁,从名字中也可以看出这两种锁是两种看待数据并发的思维方式 。需要注意的是,乐观锁和悲观锁并不是锁,而是锁的设计思想

悲观锁

悲观锁是一种思想, 他对数据被其他事务的修改持保守态度,会通过数据库自身的锁机制来实现,从而保证数据操作的排它性。

悲观锁总是假设最坏的情况,每次去拿数据的时候都认为别人会修改,所以每次在拿数据的时候都会上锁,这样别人想拿这个数据就会 阻塞 直到它拿到锁(共享资源每次只给一个线程使用,其它线程阻塞,用完后再把资源转让给其它线程)。比如行锁,表锁等,读锁,写锁等,都是在做操作之前先上锁,当其他线程想要访问数据时,都需要阻塞挂起。

Java中synchronizedReentrantLock等独占锁就是悲观锁思想的实现。

悲观锁 适合 写操作多 的场景,因为写的操作具有 排它性 。采用悲观锁的方式,可以在数据库层面阻止其他事务对该数据的操作权限,防止读 - 写写 - 写的冲突。

乐观锁

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

在Java中 java.util.concurrent.atomic 包下的原子变量类就是使用了乐观锁的一种实现方式:CAS实现的。

在MySQL数据库中,可以使用如下几种方式实现乐观锁:

  1. 使用版本号机制:在表中设计一个 版本字段 version ,第一次读的时候,会获取 version 字段的取值。然后对数据进行更新或删除操作时,会执行 UPDATE ... SET version=version+1 WHERE version=version。此时如果已经有事务对这条数据进行了更改,修改就不会成功。

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

乐观锁 适合 读操作多 的场景,相对来说写的操作比较少。它的优点在于 程序实现 , 不存在死锁问题,不过适用场景也会相对乐观,因为它阻止不了除了程序以外的数据库操作。

隐式锁和显示锁

隐式锁

显示锁

死锁

在数据库中的死锁案例,以行锁为例:

时间点事务1事务2

1

begin;

begin;

2

update account set money=100 where id = 1;

3

update account set money=100 where id =2;

4

update account set money=200 where id =2; 这里id=2的数据已经被事务2占用所以一直等待

5

update account set money=200 where id = 1; 这里id=1的数据已经被事务1占用所以也一直等待

发生死锁的必要条件:

  1. 两个两个或以上的事务

  2. 每个事务都已经持有锁并且申请新的锁

  3. 锁资源同时只能被同一个事务持有者不兼容

  4. 事务之间因为持有锁和申请锁导致彼此循环等待

解决死锁:设置超时时间

通过属性innodb_block_wait_timeout设置,单位为妙,默认为50s。但是缺点在于时间不好设置,长了会等待很久,短了会导致正常执行的线程中断。

解决死锁:MySQL的死锁检测

死锁检测会主动检测该操作会不会造成死锁,如果会造成死锁将会报错。他的主要原理是构建一个以事务为顶点,锁为边的有向图,并判断有向图是否存在环,存在就说明有死锁。

他的主要机制如下:

  1. 它主要由数据库存储事务等待链表(左图)以及锁信息链表(右图)

  2. 以row2为例,T1先在row2上获取了S锁,T4也是S锁,所以T4不需要等待T1,而T2需要等待T1,T3需要等待T2

  3. 根据上述信息绘制wait for graph等待图:

  4. 当发现有环时,就代表出现了死锁。

开启死锁检测:

innodb_deadlock_detect=on

死锁检测每次都要检测一次所有环路,那么n个线程加入就要检测n次,那么复杂度O(n),所以对CPU也是一种不小的损耗。可以通过以下两种方式解决:

  1. 关闭死锁检测,但是业务可能有损。

  2. 控制并发访问的数量。

如何避免死锁

  1. 合理设计索引,使业务SQL尽量通过索引定位更少的行,减少锁竞争

  2. 调整业务SQL的执行顺序,避免需要加锁的操作放在前面

  3. 避免大事务,尽量将大事务拆分为多个小事务处理,减少锁冲突

  4. 并发场景较大的情况,尽量不要显示加锁

  5. 如果业务员允许,降低隔离级别,比如将隔离级别从可重复的降低为读提交,可以减少间隙锁造成的死锁。

最后更新于