数据库锁设计的初衷是处理并发问题。作为多用户共享的资源,当出现并发访问的时候,数据库需要合理地控制资源的访问规则。而锁就是用来实现这些访问规则的重要数据结构。
MySQL 锁的分类
根据加锁的范围,MySQL 里面的锁大致可以分成全局锁、表级锁和行级锁三类。
全局锁
全局锁就是对整个数据库实例加锁。可以通过下面命令操作全局锁:
-- 全局读锁
FLUSH TABLES WITH READ LOCK;
-- 释放锁
UNLOCK TABLES;
加全局读锁后,整个库就处于只读状态了,之后其他线程的以下语句会被阻塞:
- 数据更新语句(数据的增删改)。
- 数据定义语句(包括建表、修改表结构等)。
- 更新类事务的提交语句。
全局锁主要应用于做全库逻辑备份,这样在备份数据库期间,不会因为数据或表结构的更新,而出现备份文件的数据与预期的不一样。
加上全局锁后,整个数据库都是只读状态,这期间业务只能读取而不能更新数据,会导致业务停滞。如果数据库引擎支持事务的『可重复读隔离级别』,在备份数据库之前开启事务,会先创建读视图,整个事务执行期间都在用这个读视图,由于 MVCC 的支持,备份期间业务依然可以对数据进行更新操作。
备份数据库的工具是 mysqldump,在使用 mysqldump 时加上 -single-transaction 参数的时候,就会在备份数据库之前先开启事务。这种方法只适用于支持『可重复读隔离级别的事务』的存储引擎。InnoDB 存储引擎默认就是可重复读隔离级别,可以采用这种方式来备份数据库。对于不支持事务的存储引擎如 MyISAM,在备份时只能使用全局锁的方式。
表级锁
MySQL 里面表级别的锁有两种:一种是表锁,一种是元数据锁(METADATA LOCK)。
表锁
可以通过下面命令操作表锁:
-- 表级别的共享锁,也就是读锁;允许当前会话读取被锁定的表,但阻止其他会话对这些表进行写操作。
LOCK TABLES table_name READ;
-- 表级别的独占锁,也就是写锁;允许当前会话对表进行读写操作,但阻止其他会话对这些表进行任何操作(读或写)。
LOCK TABLES table_name WRITE;
-- 释放当前会话的所有表锁。
UNLOCK TABLES;
在还没有出现更细粒度的锁的时候,表锁是最常用的处理并发的方式,不过尽量避免在使用 InnoDB 引擎的表使用表锁,因为表锁的颗粒度太大,会影响并发性能。InnoDB 有更细粒度的行级锁支持。
元数据锁 MDL
MDL 不需要显式使用,在访问一个表的时候会被自动加上:
- 对一张表进行增删改查操作时,加的是 MDL 读锁。
- 对一张表做结构变更操作的时候,加的是 MDL 写锁。
MDL 的作用是保证读写的正确性。当用户对表执行增删改查操作时,防止其他线程对这个表结构做了变更。
行级锁
MySQL中的行锁是 InnoDB 引擎实现高并发控制的核心机制,主要作用于单行数据。根据锁定的范围和目的,行锁可分为以下类型:
记录锁(Record Locks)
记录锁锁住的是一条记录,有共享锁(S 锁)和独占锁(X 锁)之分。S 锁满足读读共享,读写互斥;X 锁满足写写互斥、读写互斥。
普通的 SELECT 语句是不会对记录加锁的(MVCC 机制保证),因为它属于快照读。如果要在查询时对记录加行锁,可以使用下面这两个方式,这种查询会加锁的语句称为锁定读。注意这两条语句必须在事务中,因为当事务提交了,锁就会被释放。
-- 对读取的记录加共享锁 (S 锁)
SELECT ... LOCK IN SHARE MODE;
-- 对读取的记录加独占锁(X 锁)
SELECT ... FOR UPDATE;
S 锁和 X 锁的兼容性如下:
- 当一个事务对一条记录加了 S 锁后,其他事务也可以继续对该记录加 S 锁(S 锁与 S 锁兼容),但是不可以对该记录加 X 锁(S 锁与 X 锁不兼容)。
- 当一个事务对一条记录加了 X 锁后,其他事务既不可以对该记录加 S 锁(S 锁与 X 锁不兼容),也不可以对该记录加 X 锁(X 锁与 X 锁不兼容)。
| X 型记录锁 | S 型记录锁 | |
|---|---|---|
| X 型记录锁 | 不兼容 | 不兼容 |
| S 型记录锁 | 不兼容 | 兼容 |
间隙锁(Gap Locks)
间隙锁只存在于可重复读隔离级别,作用于索引记录之间的间隙(如 id=5 和 id=10 之间的区间),目的是为了解决可重复读隔离级别下幻读的现象。
间隙锁也存在 X型间隙锁和 S 型间隙锁,间隙锁之间是兼容的。即两个事务可以同时持有包含共同间隙范围的间隙锁,并不存在互斥关系,因为间隙锁的目的是防止插入幻影记录而提出的。
临键锁(Next-Key Locks)
临键锁是 Record Lock + Gap Lock 的组合,锁定一个范围,并且锁定记录本身。左开右闭区间(如 (5, 10] 表示锁定 5 到 10 的区间和 id=10 的记录),目的是同时防止幻读和当前记录被修改。
插入意向锁(Insert Intention Locks)
一个事务在插入一条记录的时候,需要判断插入位置是否已被其他事务加了间隙锁。如果有的话,插入操作就会发生阻塞,直到拥有间隙锁的那个事务提交为止,在此期间会生成一个插入意向锁,表明有事务想在某个区间插入新记录,但是现在处于等待状态。插入意向锁的目的是提高并发插入效率,允许多个事务在同一间隙不同位置插入。
死锁问题
MySQL死锁是数据库中的经典问题,发生在两个或多个事务彼此等待对方持有的资源(通常是锁),形成一个循环等待链,导致所有相关事务都无法继续执行。当出现死锁以后,有两种策略:
- 一种策略是,直接进入等待,直到超时。这个超时时间可以通过参数 innodb_lock_wait_timeout 来设置。
- 另一种策略是,发起死锁检测,发现死锁后,主动回滚死锁链条中的某一个事务,让其他事务得以继续执行。
将参数 innodb_deadlock_detect 设置为 on开启死锁检测, InnoDB 引擎会自动检测死锁并回滚代价最小的一个事务(如 Undo 量最小的事务)来打破僵局。
MySQL 的两阶段锁协议
在 InnoDB 事务中,行锁是在需要的时候才加上的,但并不是不需要了就立刻释放,而是要等到事务结束时才释放这个就是『两阶段锁协议』。
MySQL 死锁出现的典型场景
不同顺序的更新或删除操作
事务 A 和事务 B 都需要修改相同的两行或多行数据,但它们以相反的顺序访问这些行。
- 事务A:UPDATE table SET … WHERE id = 1; (获得 id=1 的行锁)
- 事务B:UPDATE table SET … WHERE id = 2; (获得 id=2 的行锁)
- 事务A:UPDATE table SET … WHERE id = 2; (尝试获取 id=2 的行锁,但被事务 B 持有,事务 A 等待)
- 事务B:UPDATE table SET … WHERE id = 1; (尝试获取 id=1 的行锁,但被事务 A 持有,事务 B 等待)
事务 A 等待 B 释放 id=2 的锁,B 等待 A 释放 id=1 的锁,循环等待,死锁发生。
其它相关问题
为什么 MDL 写锁被占用会阻塞后续的所有的读写操作?
事务中的 MDL 锁在语句执行开始时申请,但是语句结束后并不会马上释放,而会等到整个事务提交后再释放。申请 MDL 锁的操作会形成一个队列,队列中写锁获取优先级高于读锁,一旦出现 MDL 写锁等待,会阻塞后续该表的所有读写操作。
所以为了能安全的对表结构进行变更,在对表结构变更前,先要看看数据库中的长事务,是否有事务已经对表加上了 MDL 读锁,如果可以考虑 KILL 掉这个长事务,然后再做表结构的变更。
MySQL DDL 无锁变更的原理和流程?
MySQL DDL 无锁变更旨在避免传统 DDL 操作中因锁表导致的业务中断问题。其核心原理是通过创建临时表、数据同步、逐步切换的方式,在保证数据一致性的前提下实现对表结构的修改。
无锁 DDL 的核心思想是『双表同步』 ,通过以下步骤实现:
- 临时表创建:根据新表结构创建临时表,并在其上执行 DDL 操作(如新增字段、修改列类型等)。
- 数据同步:将原表的全量数据拷贝到临时表,并捕获原表在拷贝期间的增量数据(如通过 Binlog 或触发器)。
- 原子切换:在数据完全同步后,通过原子操作(如重命名表名)将原表与临时表替换,完成结构变更。
参考『阿里云 DMS 无锁结构变更』和『几种无锁结构变更方案的对比』。