MySQL锁
MySQL锁
Innodb锁分类
全局锁
全局锁是怎么用的?
要使用全局锁,则要执行这条命令:
1 |
|
执行后,整个数据库就处于只读状态了,这时其他线程执行以下操作,都会被阻塞:
- 对数据的增删改操作,比如 insert、delete、update等语句;
- 对表结构的更改操作,比如 alter table、drop table 等语句。
如果要释放全局锁,则要执行这条命令:
1 |
|
当然,当会话断开了,全局锁会被自动释放。
全局锁应用场景是什么?
全局锁主要应用于做全库逻辑备份,这样在备份数据库期间,不会因为数据或表结构的更新,而出现备份文件的数据与预期的不一样。
加全局锁又会带来什么缺点呢?
加上全局锁,意味着整个数据库都是只读状态。
那么如果数据库里有很多数据,备份就会花费很多的时间,关键是备份期间,业务只能读数据,而不能更新数据,这样会造成业务停滞。
既然备份数据库数据的时候,使用全局锁会影响业务,那有什么其他方式可以避免?
有的,如果数据库的引擎支持的事务支持可重复读的隔离级别,那么在备份数据库之前先开启事务,会先创建 Read View,然后整个事务执行期间都在用这个 Read View,而且由于 MVCC 的支持,备份期间业务依然可以对数据进行更新操作。
因为在可重复读的隔离级别下,即使其他事务更新了表的数据,也不会影响备份数据库时的 Read View,这就是事务四大特性中的隔离性,这样备份期间备份的数据一直是在开启事务时的数据。
备份数据库的工具是 mysqldump,在使用 mysqldump 时加上 –single-transaction
参数的时候,就会在备份数据库之前先开启事务。这种方法只适用于支持「可重复读隔离级别的事务」的存储引擎。
InnoDB 存储引擎默认的事务隔离级别正是可重复读,因此可以采用这种方式来备份数据库。
但是,对于 MyISAM 这种不支持事务的引擎,在备份数据库时就要使用全局锁的方法。
表级锁(Table Lock)
元数据锁
我们不需要显示的使用 MDL,因为当我们对数据库表进行操作时,会自动给这个表加上 MDL:
- 对一张表进行 CRUD 操作时,加的是 MDL 读锁;
- 对一张表做结构变更操作的时候,加的是 MDL 写锁;
MDL 是为了保证当用户对表执行 CRUD 操作时,防止其他线程对这个表结构做了变更。
当有线程在执行 select 语句( 加 MDL 读锁)的期间,如果有其他线程要更改该表的结构( 申请 MDL 写锁),那么将会被阻塞,直到执行完 select 语句( 释放 MDL 读锁)。
反之,当有线程对表结构进行变更( 加 MDL 写锁)的期间,如果有其他线程执行了 CRUD 操作( 申请 MDL 读锁),那么就会被阻塞,直到表结构变更完成( 释放 MDL 写锁)。
MDL 不需要显示调用,那它是在什么时候释放的?
MDL 是在事务提交后才会释放,这意味着事务执行期间,MDL 是一直持有的。
那如果数据库有一个长事务(所谓的长事务,就是开启了事务,但是一直还没提交),那在对表结构做变更操作的时候,可能会发生意想不到的事情,比如下面这个顺序的场景:
- 首先,线程 A 先启用了事务(但是一直不提交),然后执行一条 select 语句,此时就先对该表加上 MDL 读锁;
- 然后,线程 B 也执行了同样的 select 语句,此时并不会阻塞,因为「读读」并不冲突;
- 接着,线程 C 修改了表字段,此时由于线程 A 的事务并没有提交,也就是 MDL 读锁还在占用着,这时线程 C 就无法申请到 MDL 写锁,就会被阻塞,
那么在线程 C 阻塞后,后续有对该表的 select 语句,就都会被阻塞,如果此时有大量该表的 select 语句的请求到来,就会有大量的线程被阻塞住,这时数据库的线程很快就会爆满了。
为什么线程 C 因为申请不到 MDL 写锁,而导致后续的申请读锁的查询操作也会被阻塞?
这是因为申请 MDL 锁的操作会形成一个队列,队列中写锁获取优先级高于读锁,一旦出现 MDL 写锁等待,会阻塞后续该表的所有 CRUD 操作。
所以为了能安全的对表结构进行变更,在对表结构变更前,先要看看数据库中的长事务,是否有事务已经对表加上了 MDL 读锁,如果可以考虑 kill 掉这个长事务,然后再做表结构的变更
表锁
如果我们想对学生表(t_student)加表锁,可以使用下面的命令:
1 |
|
需要注意的是,表锁除了会限制别的线程的读写外,也会限制本线程接下来的读写操作。
也就是说如果本线程对学生表加了「共享表锁」,那么本线程接下来如果要对学生表执行写操作的语句,是会被阻塞的,当然其他线程对学生表进行写操作时也会被阻塞,直到锁被释放。
要释放表锁,可以使用下面这条命令,会释放当前会话的所有表锁:
1 |
|
另外,当会话退出后,也会释放所有表锁。
不过尽量避免在使用 InnoDB 引擎的表使用表锁,因为表锁的颗粒度太大,会影响并发性能
以下语句也会自动加表级锁
1 |
|
因为没有在name上加索引,所以默认使用全表扫描,加的是表锁
InnoDB在使用过程中只要不通过索引检索数据时,全部是表锁。
MyISAM在执行查询语句(SELECT)前,会自动给涉及的所有表加读锁,在执行更新操作(UPDATE、DELETE、INSERT等)前,会自动给涉及的表加写锁,这个过程并不需要用户干预,因此用户一般不需要直接用LOCK TABLE命令给MyISAM表显式加锁。
优点:开销小,加锁快;不会出现死锁;
缺点:锁定粒度大,发生锁冲突的概率最高,并发度最低
意向锁(Intention Lock)
- 在使用 InnoDB 引擎的表里对某些记录加上「共享锁」之前,需要先在表级别加上一个「意向共享锁」;
- 在使用 InnoDB 引擎的表里对某些纪录加上「独占锁」之前,需要先在表级别加上一个「意向独占锁」;
也就是,当执行插入、更新、删除操作,需要先对表加上「意向独占锁」,然后对该记录加独占锁。
而普通的 select 是不会加行级锁的,普通的 select 语句是利用 MVCC 实现一致性读,是无锁的。
不过,select 也是可以对记录加共享锁和独占锁的,具体方式如下:
1 |
|
意向共享锁和意向独占锁是表级锁,不会和行级的共享锁和独占锁发生冲突,而且意向锁之间也不会发生冲突,只会和共享表锁(*lock tables … read*)和独占表锁(*lock tables … write*)发生冲突。
表锁和行锁是满足读读共享、读写互斥、写写互斥的。
如果没有「意向锁」,那么加「独占表锁」时,就需要遍历表里所有记录,查看是否有记录存在独占锁,这样效率会很慢。
那么有了「意向锁」,由于在对记录加独占锁前,先会加上表级别的意向独占锁,那么在加「独占表锁」时,直接查该表是否有意向独占锁,如果有就意味着表里已经有记录被加了独占锁,这样就不用去遍历表里的记录。
所以,意向锁的目的是为了快速判断表里是否有记录被加锁。
自增锁(Auto-Increment Lock)
自增锁的主要目的是确保在多个并发事务中,每次插入新行时自增列的值是唯一的。这样可以避免多个事务同时插入数据时出现冲突。
例如,MySQL 中使用的 AUTO_INCREMENT 关键字就是一种自增列的实现方式。当定义了 AUTO_INCREMENT 的列时,MySQL 会为该列自动分配唯一的递增值。在事务中插入新行时,系统会自动获取并管理这个自增锁,确保生成的值不会冲突。
在MYSQL 5.1.22版本前,自增列使用AUTO_INC Locking方式来实现,即采用一种特殊的表锁机制来保证并发插入下自增操作依然是串行操作,为提高插入效率,该锁会在插入语句完成后立即释放,而不是插入语句所在事务提交时释放。该设计并发性能太差,尤其在大批量数据在一条语句中插入时(INSERT SELECT ), 会导致该语句长时间持有这个“表锁”,从而阻塞其他事务的插入操作。
在MYSQL 5.1.22版本开始,InnoDB存储引使用一种轻量级互斥锁(Mutex)来控制自增列增长,并提供innodb_autoinc_lock_mode参数来控制。
自增长方式可分为下面四类:
1 |
|
innodb_autoinc_lock_mode参数取值
innodb_autoinc_lock_mode=0 传统锁定模式
5.1.22之前的方式,也就是所有INSERT-LIKE操作都用AUTO-inc locking。
innodb_autoinc_lock_mode=1 连续锁定模式
这个参数是5.1.22之后出现的也是之后的默认值,对于SIMPLE INSERT,使用轻量级互斥锁,对于BULK INSERT,使用AUTO-inc locking。
“Simple insert”操作能在插入前知道插入的记录数量,因此无需在整个插入操作过程中持有表级别的AUTO-INC锁,通过轻量级互斥锁来控制INSERT操作获取自增值的过程,并在INSERT操作获取到自增值后快速释放互斥锁,通过降低锁颗粒度和锁持续周期,实现”Simple insert”操作并发执行。当其他事物对表持有AUTO-INC锁时,”Simple insert”操作也会升级使用AUTO-INC锁并被阻塞。
在语句复制格式下**(BINLOG_FORMAT=STATEMENT),BINLOG中没有记录主库执行过程中获取到的所有自增值及其对应行的信息,要保证”Bulk insert”操作主从复制数据一致就必须保证语句在主库和从库执行时获取到相同自增值,而因此只能通过控制“获取连续自增值”的方式来实现,同时为避免受其他事务插入操作影响,就必须在表级别加锁且保证持有锁至语句结束。**
在行复制格式下**(BINLOG_FORMAT=ROW)**,主库BINLOG中保存有记录的所有列信息包括自增列值,因此无需通过AUTO-INC锁来保证主从数据一致。
innodb_autoinc_lock_mode=2 交错锁定模式
指不管什么情况都使用轻量级互斥的锁,效率最高,但是复制只能使用row-basereplication,因为statement-base replication会出现问题。
为什么不能使用statement-base replication?
首先要了解MySQL的Binlog,Binlog 一般用于 MySQL 的数据复制,通俗一点就是用于主从同步。在 MySQL 中 Binlog 的格式有 3 种,分别是:
- Statement 基于语句,只记录对数据做了修改的SQL语句,能够有效的减少binlog的数据量,提高读取、基于binlog重放的性能
- Row 只记录被修改的行,所以Row记录的binlog日志量一般来说会比Statement格式要多。基于Row的binlog日志非常完整、清晰,记录了所有数据的变动,但是缺点是可能会非常多,例如一条
update
语句,有可能是所有的数据都有修改;再例如alter table
之类的,修改了某个字段,同样的每条记录都有改动。 - Mixed Statement和Row的结合,怎么个结合法呢。例如像
alter table
之类的对表结构的修改,采用Statement格式。其余的对数据的修改例如update
和delete
采用Row格式进行记录。
如果 MySQL 采用的格式为 Statement
,那么 MySQL 的主从同步实际上同步的就是一条一条的 SQL 语句。如果此时我们采用了交叉模式,那么并发情况下 INSERT
语句的执行顺序就无法得到保障。
在MySQL 8.0版本前,参数BINLOG_FORMAT的默认值为STATEMENT,参数innodb_autoinc_lock_mode的默认值为1。
在MySQL 8.0版本后,参数BINLOG_FORMAT的默认值被调整为ROW格式,参数innodb_autoinc_lock_mode的默认值为2。
自增值间隙
在MySQL中,获取自增值的操作是非事务性,获取自增值的操作产生的锁在语句执行过程中或执行完成里便被释放而不会持续到事务提交和回滚,获取到自增值也不会随事务回滚而回滚,因此不能依赖MySQL自增列来实现表中列值连续无间隙。表中自增列作为代理键,只能用来标识和定位记录,而不应该承载业务逻辑,因此也不建议对自增列值进行显式更新。
行级锁(Record Lock)
InnoDB 引擎是支持行级锁的,而 MyISAM 引擎并不支持行级锁。
前面也提到,普通的 select 语句是不会对记录加锁的,因为它属于快照读。如果要在查询时对记录加行锁,可以使用下面这两个方式,这种查询会加锁的语句称为锁定读。
1 |
|
上面这两条语句必须在一个事务中,因为当事务提交了,锁就会被释放,所以在使用这两条语句的时候,要加上 begin、start transaction 或者 set autocommit = 0。
共享锁(S锁)满足读读共享,读写互斥。独占锁(X锁)满足写写互斥、读写互斥。

行级锁的类型主要有三类:
- Record Lock,记录锁,也就是仅仅把一条记录锁上;
- Gap Lock,间隙锁,锁定一个范围,但是不包含记录本身;
- Next-Key Lock:Record Lock + Gap Lock 的组合,锁定一个范围,并且锁定记录本身。
记录锁(Record Lock)
Record Lock 称为记录锁,记录锁锁定的是索引记录。即使表没有定义索引,InnoDB也会创建一个隐藏的聚集索引,并使用这个索引来锁定记录
而且记录锁是有 S 锁和 X 锁之分的:
- 当一个事务对一条记录加了 S 型记录锁后,其他事务也可以继续对该记录加 S 型记录锁(S 型与 S 锁兼容),但是不可以对该记录加 X 型记录锁(S 型与 X 锁不兼容);
- 当一个事务对一条记录加了 X 型记录锁后,其他事务既不可以对该记录加 S 型记录锁(S 型与 X 锁不兼容),也不可以对该记录加 X 型记录锁(X 型与 X 锁不兼容)。
举个例子,当一个事务执行了下面这条语句:
1 |
|
事务会对表中主键 id = 1 的这条记录加上 X 型的记录锁,如果这时候其他事务对这条记录进行删除或者更新操作,那么这些操作都会被阻塞。注意,其他事务插入一条 id = 1 的新记录并不会被阻塞,而是会报主键冲突的错误,这是因为主键有唯一性的约束。
当事务执行 commit 后,事务过程中生成的锁都会被释放。
间隔锁(Gap Lock)
Gap Lock 称为间隙锁,只存在于可重复读隔离级别,目的是为了解决可重复读隔离级别下幻读的现象。
假设,表中有一个范围 id 为(3,5)间隙锁,那么其他事务就无法插入 id = 4 这条记录了,这样就有效的防止幻读现象的发生。
img
间隙锁虽然存在 X 型间隙锁和 S 型间隙锁,但是并没有什么区别,间隙锁之间是兼容的,即两个事务可以同时持有包含共同间隙范围的间隙锁,并不存在互斥关系,因为间隙锁的目的是防止插入幻影记录而提出的。
临键锁(Next-Key Lock)
Next-Key Lock 称为临键锁,是 Record Lock + Gap Lock 的组合,锁定一个范围,并且锁定记录本身。
假设,表中有一个范围 id 为(3,5] 的 next-key lock,那么其他事务即不能插入 id = 4 记录,也不能修改和删除 id = 5 这条记录。
img
所以,next-key lock 即能保护该记录,又能阻止其他事务将新记录插入到被保护记录前面的间隙中。
next-key lock 是包含间隙锁+记录锁的,如果一个事务获取了 X 型的 next-key lock,那么另外一个事务在获取相同范围的 X 型的 next-key lock 时,是会被阻塞的。
比如,一个事务持有了范围为 (1, 10] 的 X 型的 next-key lock,那么另外一个事务在获取相同范围的 X 型的 next-key lock 时,就会被阻塞。
虽然相同范围的间隙锁是多个事务相互兼容的,但对于记录锁,我们是要考虑 X 型与 S 型关系,X 型的记录锁与 X 型的记录锁是冲突的。
插入意向锁(Insert Intention Locks)
一个事务在插入一条记录的时候,需要判断插入位置是否已被其他事务加了间隙锁(next-key lock 也包含间隙锁)。
如果有的话,插入操作就会发生阻塞,直到拥有间隙锁的那个事务提交为止(释放间隙锁的时刻),在此期间会生成一个插入意向锁,表明有事务想在某个区间插入新记录,但是现在处于等待状态。
举个例子,假设事务 A 已经对表加了一个范围 id 为(3,5)间隙锁。

当事务 A 还没提交的时候,事务 B 向该表插入一条 id = 4 的新记录,这时会判断到插入的位置已经被事务 A 加了间隙锁,于是事物 B 会生成一个插入意向锁,然后将锁的状态设置为等待状态(PS:MySQL 加锁时,是先生成锁结构,然后设置锁的状态,如果锁状态是等待状态,并不是意味着事务成功获取到了锁,只有当锁状态为正常状态时,才代表事务成功获取到了锁),此时事务 B 就会发生阻塞,直到事务 A 提交了事务。
插入意向锁名字虽然有意向锁,但是它并不是意向锁,它是一种特殊的间隙锁,属于行级别锁。
如果说间隙锁锁住的是一个区间,那么「插入意向锁」锁住的就是一个点。因而从这个角度来说,插入意向锁确实是一种特殊的间隙锁。
插入意向锁与间隙锁的另一个非常重要的差别是:尽管「插入意向锁」也属于间隙锁,但两个事务却不能在同一时间内,一个拥有间隙锁,另一个拥有该间隙区间内的插入意向锁(当然,插入意向锁如果不在间隙锁区间内则是可以的)。
也就是说一个事务的插入意向锁与其他事务的间隙锁是冲突的
###乐观锁和悲观锁
悲观锁
- 悲观锁的基本思想是,在整个数据访问的过程中,始终保持对数据的独占性,即认为在并发环境下会有冲突,因此在事务开始时就对数据进行加锁,其他事务需要等待锁的释放。
- 悲观锁的典型实现是数据库中的行级锁或表级锁,通过在事务中使用
SELECT FOR UPDATE
(行级锁)或LOCK TABLES
(表级锁)等语句来获得锁,以确保在事务进行读写操作时其他事务不能同时进行读或写。
乐观锁
- 乐观锁的基本思想是,假定在绝大多数情况下,事务之间的冲突是比较少见的,因此可以先不加锁进行操作,而在更新数据时再去检查是否有其他事务的修改。
- 乐观锁的实现通常是通过在数据表中引入一个版本号或时间戳字段,每次更新时增加版本号,当事务提交时,检查版本号是否发生变化,如果发生变化则说明有其他事务修改过,需要处理冲突。
MySQL 执行 insert 操作时会涉及到什么锁?
假设我们有一个简单的表 users
:
1 |
|
当执行如下插入操作时:
1 |
|
在 InnoDB 中,以下锁会被使用:
- 申请意向排他锁(IX):在表级别申请意向排他锁,以表明将对某些行加排他锁。
- 申请插入意向锁:在插入位置申请插入意向锁,允许多个事务在不同位置并发插入。
- 加记录锁:在新插入的记录上加记录锁,防止其他事务在相同记录上进行并发修改。
插入失败会发生什么情况?
- 间隙锁(Gap Lock):如果插入操作失败是由于唯一性约束冲突(例如,尝试插入一个已经存在的主键值),InnoDB 可能会对冲突的记录加间隙锁,以防止其他事务插入相同的值。这种锁定机制用于防止幻读。
- 死锁(Deadlock):虽然 InnoDB 的锁机制设计旨在减少锁争用和死锁,但在并发插入时仍然需要注意避免复杂的事务间的死锁问题。
MySQL死锁的发生
使用存储引擎 Innodb,隔离级别为可重复读(RR)。
接下来,我用实战的方式来带大家看看死锁是怎么发生的。
我建了一张订单表,其中 id 字段为主键索引,order_no 字段普通索引,也就是非唯一索引:
1 |
|
然后,先 t_order
表里现在已经有了 6 条记录:

假设这时有两事务,一个事务要插入订单 1007 ,另外一个事务要插入订单 1008,因为需要对订单做幂等性校验,所以两个事务先要查询该订单是否存在,不存在才插入记录,过程如下:
可以看到,两个事务都陷入了等待状态(前提没有打开死锁检测),也就是发生了死锁,因为都在相互等待对方释放锁。
为什么会发生死锁
事务 A 在执行下面这条语句的时候:
1 |
|
会在在二级索引(INDEX_NAME : index_order)上加 X 型的 next-key 锁,锁范围是(1006, +∞]
next-key 锁的范围 (1006, +∞],是怎么确定的?
根据我的经验,如果 LOCK_MODE 是 next-key 锁或者间隙锁,那么 LOCK_DATA 就表示锁的范围最右值,此次的事务 A 的 LOCK_DATA 是 supremum pseudo-record,表示的是 +∞。然后锁范围的最左值是 t_order 表中最后一个记录的 index_order 的值,也就是 1006。因此,next-key 锁的范围 (1006, +∞]。
当事务 B 往事务 A next-key 锁的范围 (1006, +∞] 里插入 id = 1008 的记录就会被锁住:
1 |
|
因为当我们执行以下插入语句时,会在插入间隙上获取插入意向锁,而插入意向锁与间隙锁是冲突的,所以当其它事务持有该间隙的间隙锁时,需要等待其它事务释放间隙锁之后,才能获取到插入意向锁。而间隙锁与间隙锁之间是兼容的,所以所以两个事务中 select ... for update
语句并不会相互影响。
案例中的事务 A 和事务 B 在执行完后 select ... for update
语句后都持有范围为(1006,+∞]
的next-key 锁,而接下来的插入操作为了获取到插入意向锁,都在等待对方事务的间隙锁释放,于是就造成了循环等待,导致死锁。
如何排查死锁?
查询最近一次死锁日志
1 |
|
这条命令的输出包含大量的信息,包括最近的死锁检测。如果最近发生过死锁,会在输出的某个部分显示死锁相关的信息。
使用information_schema数据库
查看当前正在进行的事务
1 |
|
查看当前锁定的事务
1 |
|
查看当前等锁的事务
1 |
|
查看进程列表
使用以下命令可以查看当前正在运行的进程列表:
1 |
|
在结果中,你可以查看每个进程的状态、执行的SQL语句以及锁的信息。特别是,如果某个进程的状态为Locked,那么它可能涉及死锁。
错误日志
配置 innodb_print_all_deadlocks
选项,将死锁信息记录到错误日志中,便于后续分析
1 |
|
性能监控工具
使用性能监控工具(如Percona Monitoring and Management, PMM, 或者MySQL Enterprise Monitor)可以帮助你实时监控数据库的性能和锁的情况,从而更容易地发现和解决死锁问题。
如何避免死锁?
死锁的四个必要条件:互斥、占有且等待、不可强占用、循环等待。只要系统发生死锁,这些条件必然成立,但是只要破坏任意一个条件就死锁就不会成立。
在数据库层面,有两种策略通过「打破循环等待条件」来解除死锁状态:
设置事务等待锁的超时时间。当一个事务的等待时间超过该值后,就对这个事务进行回滚,于是锁就释放了,另一个事务就可以继续执行了。在 InnoDB 中,参数
innodb_lock_wait_timeout
是用来设置超时时间的,默认值时 50 秒。当发生超时后,就出现下面这个提示:
开启主动死锁检测。主动死锁检测在发现死锁后,主动回滚死锁链条中的某一个事务,让其他事务得以继续执行。将参数
innodb_deadlock_detect
设置为 on,表示开启这个逻辑,默认就开启。当检测到死锁后,就会出现下面这个提示:
上面这个两种策略是「当有死锁发生时」的避免方式。
我们可以回归业务的角度来预防死锁,对订单做幂等性校验的目的是为了保证不会出现重复的订单,那我们可以直接将 order_no 字段设置为唯一索引列,利用它的唯一性来保证订单表不会出现重复的订单,不过有一点不好的地方就是在我们插入一个已经存在的订单记录时就会抛出异常。