1.分类
在 MySQL 里,按照加锁的粒度范围由大到小分为:
- 全局锁:全局锁是对整个数据库实例加锁。全库逻辑备份(mysqldump)会使用。
- 表级锁:对整张表加锁,最常使用的存储引擎 MyISAM 与 InnoDB 都支持表级锁。
- 页级锁:页级锁是介于表锁和行锁之间的一种锁定粒度,锁定表的一个页(通常是数据页)。表级锁速度快,但冲突多,行级冲突少,但速度慢。因此,采取了折衷的页级锁,一次锁定相邻的一组记录。BDB 引擎(MySQL 5.5被弃用)支持页级锁。
- 行级锁:行级锁是最细粒度的锁,锁定表中的单个行。InnoDB 存储引擎支持行级锁。
2.全局锁
全局锁是对整个数据库实例加锁。
全局锁主要应用于做全库逻辑备份,这样在备份数据库期间,不会因为数据或表结构的更新,而出现备份文件的数据与预期的不一样。
要使用全局锁,执行这条命令:
代码语言:javascript复制FLUSH TABLES WITH READ LOCK(FTWRL)
执行后,关闭所有打开的表并使用全局读锁锁定所有数据库的所有表,整个数据库就处于只读状态了,这时其他线程执行以下操作,都会被阻塞:
- 对数据的增删改操作,比如 INSERT、DELETE、UPDATE 等语句。
- 对表结构的更改操作,比如 ALTER TABLE、DROP TABLE 等语句。
如果要释放全局锁,执行这条命令:
代码语言:javascript复制UNLOCK TABLES
当然,当会话断开了,全局锁也会被自动释放。
3.表级锁
对整张表加锁,最常使用的存储引擎 MyISAM 与 InnoDB 都支持表级锁。
MySQL 里面表级别的锁有这几种:
- 表锁
- 元数据锁(MDL)
- 意向锁
- 自增锁(AUTO-INC)
3.1 表锁
获取或释放表锁使用如下语句:
代码语言:javascript复制LOCK TABLES
tbl_name [[AS] alias] lock_type
[, tbl_name [[AS] alias] lock_type] ...
lock_type: {
READ [LOCAL]
| [LOW_PRIORITY] WRITE
}
UNLOCK TABLES
可用的锁类型如下:
- READ [LOCAL]
- 持有锁的会话可以读取该表(但不能写入)。
- 多个会话可以同时获取表的读锁。
- 其他会话无需显式获取 READ 锁即可读取该表。
- LOCAL 修饰符允许其他会话在持有锁时执行无冲突的INSERT语句(并发插入)。但是,如果要在持有锁的同时使用服务器外部的进程操作数据库,则不能使用READ LOCAL。对于 InnoDB 表,READ LOCAL 和 READ 是一样的。
- [LOW_PRIORITY] WRITE
- 持有锁的会话可以读写该表。
- 只有持有锁的会话才能访问该表。 在释放锁之前,其他会话都无法访问它。
- 当持有写锁时,其他会话对该表的锁请求会被阻止。
- LOW_PRIORITY 修饰符无效。 在的 MySQL 8.0 版本之前中,它会影响锁定行为,但现在情况已不再如此。 它现已被弃用,使用它会产生警告。
需要锁的会话必须在单个 LOCK TABLES 语句中获取所有所需表的锁。当获得锁时,会话只能访问被锁定的表。例如,在下面的语句序列中,由于在 LOCK TABLES 语句中没有锁定 t2,因此试图访问 t2 时会出现错误:
代码语言:javascript复制mysql> LOCK TABLES t1 READ;
mysql> SELECT COUNT(*) FROM t1;
----------
| COUNT(*) |
----------
| 3 |
----------
mysql> SELECT COUNT(*) FROM t2;
ERROR 1100 (HY000): Table 't2' was not locked with LOCK TABLES
要释放表锁,可以使用下面这条命令,会释放当前会话的所有表锁:
代码语言:javascript复制UNLOCK TABLES
除了显示使用 UNLOCK TABLES 语句释放表锁,一些场景下会隐式释放锁:
- 当会话结束后,也会释放所有表锁。
- 如果会话在已持有锁的情况下发出 LOCK TABLES 语句来获取锁,则在授予新锁之前,会隐式释放其现有锁。
- 如果会话开始一个事务(例如,使用 START TRANSACTION),则隐式执行 UNLOCK TABLES 释放现有的锁。
不过尽量避免在使用 InnoDB 引擎的表使用表锁,因为表锁的颗粒度太大,会影响并发性能,InnoDB 牛逼的地方在于实现了颗粒度更细的行级锁。
3.2 元数据锁
元数据锁(Metadata 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 掉这个长事务,然后再做表结构的变更。
3.3 意向锁(Intention Lock)
InnoDB 支持多粒度锁,允许行锁和表锁共存。为了实现多粒度级别的锁,InnoDB 使用了意向锁。
意向锁是表级别的锁,主要作用是通知其他会话某个表上是否已经存在了更细粒度的锁(如行级锁或页级锁)。有两种类型的意向锁:
- 意向共享锁 (IS) 表示事务打算在表中的各个行上设置共享锁。
- 意向排他锁 (IX) 表示事务打算对表中的各个行设置排它锁。
也就是,当执行插入、更新、删除操作,需要先对表加上意向排他锁,然后对该记录加排它锁。
而普通的 SELECT 是不会加行级锁的,普通的 select 语句是利用 MVCC 实现一致性读,是无锁的。不过,SELECT 也是可以对记录加共享锁和排他锁的,具体方式如下:
代码语言:javascript复制-- 先在表上加上意向共享锁,然后对读取的记录加共享锁
SELECT ... FOR SHARE;
-- 或
SELECT ... LOCK IN SHARE MODE;
-- 先表上加上意向排他锁,然后对读取的记录加排他锁
SELECT ... FOR UPDATE;
意向共享锁和意向排他锁是表级锁,不会和行级的共享锁和排他锁发生冲突,而且意向锁之间也不会发生冲突,只会和共享表锁(LOCK TABLES … READ)和排他表锁(LOCK TABLES … WRITE)发生冲突。
如果没有「意向锁」,那么加「排他表锁」时,就需要遍历表里所有记录,查看是否有记录存在排他锁,这样效率会很慢。
那么有了「意向锁」,由于在对记录加排他锁前,先会加上表级别的意向排他锁,那么在加「排他表锁」时,直接查该表是否有意向排他锁,如果有就意味着表里已经有记录被加了排他锁,这样就不用去遍历表里的记录。
所以,意向锁的目的是为了快速判断表里是否有记录被加锁。
比如会话 1 获取了某一行的排他锁,并未提交:
代码语言:javascript复制SELECT * FROM goods WHERE id=1 FOR UPDATE;
此时会话在 goods 表存在两把锁:goods 表上的意向排它锁与 id 为 1 的数据行上的排他锁。
会话 2 想要获取 goods 表的共享锁:
代码语言:javascript复制LOCK TABLES goods READ;
此时会话 2 检测会话 1 持有 goods 表的意向排他锁,就可以得知会话 1 必然持有该表中某些数据行的排他锁,那么会话 2 对 goods 表的加锁请求就会阻塞,而无需去检测表中的每一行数据是否存在排他锁。
3.4 AUTO-INC 锁
在 MySQL 中,AUTO-INC 锁不是一个独立的锁类型,而是与自增列(Auto-Increment Column)关联的锁。自增列是一种特殊类型的列,通常用于为每行分配唯一的递增值。当插入新行时,自增列的值会自动递增,从而保证每行具有唯一的标识。
AUTO-INC 锁是在向包含自增列的表中插入新行时使用的锁。其主要作用是协调不同会话对自增列的访问,以确保每次插入都分配了唯一的值,从而防止多个会话之间的竞争条件。
AUTO-INC 锁是特殊的表锁机制,锁不是再一个事务提交后才释放,而是再执行完插入语句后就会立即释放。 在插入数据时,会加一个表级别的 AUTO-INC 锁,然后为被 AUTO_INCREMENT 修饰的字段赋值递增的值,等插入语句执行完成后,才会把 AUTO-INC 锁释放掉。 那么,一个事务在持有 AUTO-INC 锁的过程中,其他事务的如果要向该表插入语句都会被阻塞,从而保证插入数据时,被 AUTO_INCREMENT 修饰的字段的值是连续递增的。
但是, AUTO-INC 锁再对大量数据进行插入的时候,会影响插入性能,因为另一个事务中的插入会被阻塞。
因此,InnoDB 存储引擎提供了个 innodb_autoinc_lock_mode 的系统变量,于控制自增列的锁定模式。
- 0 传统模式(Traditional)
- 1 连续模式(Consecutive)
- 2 交叉模式(Interleaved)
取值 0 表示传统模式。在这种模式下,InnoDB 在插入新行时会锁定整张表,以确保自增列的唯一性。这意味着在插入新行时,其他会话不能插入行到相同的表。
这样一来,传统模式的弊端就自然暴露出来了,如果有多个事务并发的执行 INSERT 操作,AUTO-INC的存在会使得 MySQL 的性能略有下降,因为同时只能执行一条 INSERT 语句。
取值 1 表示连续模式。 因为传统模式存在影响性能的弊端,所以才有了连续模式。
在连续模式下时,如果 INSERT 语句能够提前确定插入的数据量,则可以不用获取自增锁。举个例子,像 INSERT INTO 这种简单的、能提前确认数量的新增语句,就不会使用自增锁,而是使用较为轻量级的 mutex 锁,来防止 ID 重复分配,ID 一旦分配好了,mutex 锁就会被释放。
但是如果 INSERT 语句不能提前确认数据量,则还是会去获取自增锁。例如像 INSERT INTO … SELECT … 这种语句,INSERT 的值来源于另一个 SELECT 语句。
取值 2 表示交叉模式。所有的 INSERT 语句,包含 INSERT 和 INSERT INTO … SELECT ,都不会使用 AUTO-INC 自增锁,而是使用较为轻量的 mutex 锁。这样一来,多条 INSERT 语句可以并发的执行,这也是三种锁模式中扩展性最好的一种。
并发执行所带来的副作用就是单个 INSERT 的自增值并不连续,因为 AUTO_INCREMENT 的值分配会在多个 INSERT 语句中来回交叉的执行。
当 innodb_autoinc_lock_mode = 2 是性能最高的方式,但是当搭配 binlog 的日志格式是 statement 一起使用的时候,在「主从复制的场景」中会发生数据不一致的问题。
在 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 语句的执行顺序就无法得到保障。
可能你还没看出问题在哪儿,INSERT 同时交叉执行,并且 AUTO_INCREMENT 交叉分配将会直接导致主从之间同行的数据主键 ID 不同。而这对主从同步来说是灾难性的。
而后来,MySQL 5.7 将日志存储格式从 Statement 变成了 Row,这样一来,主从之间同步的就是真实的行数据了,而且 主键ID 在同步到从库之前已经确定了,就对同步语句的顺序并不敏感,就规避了上面 Statement 的问题。
基于 MySQL 默认 Binlog 格式从 Statement 到 Row 的变更,MySQL 8.0 将默认模式由连续模式改为交叉模式。
4.页级锁
页级锁定是 MySQL 中比较独特的一种锁定级别,在其他数据库管理软件中也并不是太常见。页级锁定的特点是锁定粒度介于表级锁与行级锁定之间,所以获取锁定所需要的资源开销,以及所能提供的并发处理能力也同样是介于二者之间。
MySQL BDB 引擎支持页级锁,不过该引擎已在 MySQL 5.1 被弃用,所以对于页级锁,知道即可,不用过多了解。
5.行级锁
InnoDB 引擎是支持行级锁的,而 MyISAM 引擎并不支持行级锁。
行级锁的类型主要有三类:
- Record Lock,记录锁,也就是仅仅把一条记录锁上;
- Gap Lock,间隙锁,锁定一个范围,但是不包含记录本身;
- Next-Key Lock:Record Lock Gap Lock 的组合,锁定一个范围,并且锁定记录本身。
5.1 Record Lock
Record Lock 为记录锁,锁住的是一条记录。
记录锁是有 S 锁和 X 锁之分:
- 当一个事务对一条记录加了 S 型记录锁后,其他事务也可以继续对该记录加 S 型记录锁,但是不可以对该记录加 X 型记录锁。
- 当一个事务对一条记录加了 X 型记录锁后,其他事务既不可以对该记录加 S 型记录锁,也不可以对该记录加 X 型记录锁。
例如 id 列为主键或唯一索引列,那么 id 为 1 的记录行会被锁住。
代码语言:javascript复制SELECT * FROM lock_example WHERE id = 1 FOR UPDATE;
需要注意的是:id 列必须为唯一索引列或主键列,否则上述语句加的锁就会变成临键锁。同时查询语句必须为精准匹配(=),不能为 >、<、LIKE 等,否则也会退化成临键锁。
当事务执行 commit 后,事务过程中生成的锁都会被释放。
5.2 Gap Lock
Gap Lock 为间隙锁,锁定一个范围,但不包含记录本身。只存在于可重复读隔离级别,目的是为了解决可重复读隔离级别下幻读的现象。
当我们用范围条件而不是相等条件检索数据,并请求共享或排他锁时,InnoDB 会给符合条件的已有数据的索引项加锁。对于键值在条件范围内但并不存在的记录叫做间隙,InnoDB 也会对这个间隙加锁。
假如 lock_example 表中只有101条记录,其 id 的值分别是1,2,…,100,101,下面的 SQL:
代码语言:javascript复制SELECT * FROM lock_example WHERE id > 100 FOR UPDATE;
InnoDB 不仅会对符合条件的 id 值为 101 的记录加锁,也会对 id 大于101(这些记录并不存在)的“间隙”加锁,那么其他事务就无法插入 id 大于 101 的记录了,这样就有效的防止幻读现象的发生。
5.3 Next-key Lock
Next-key Lock 为临键锁,是 Record Lock Gap Lock 的组合,锁定一个范围,并且锁定记录本身。
通过临键锁可以解决幻读的问题。 每个数据行上的非唯一索引列上都会存在一把临键锁,当某个事务持有该数据行的临键锁时,会锁住一段左开右闭区间的数据。需要强调的一点是,InnoDB 中行级锁是基于索引实现的,临键锁只与非唯一索引列有关,在唯一索引列(包括主键列)上不存在临键锁,会降级为记录锁,即仅锁住索引本身,不是范围。
假设有如下表:
代码语言:javascript复制MySQL,InnoDB,Repeatable-Read:lock_example(id PK, age KEY, name)
id age name
1 10 Lee
3 24 Soraka
5 32 Zed
7 45 Terra
该表中 age 列为非唯一索引列,其潜在的临键锁有:
代码语言:javascript复制(-∞, 10]
(10, 24]
(24, 32]
(32, 45]
(45, ∞]
假如在事务 A 中执行如下命令:
代码语言:javascript复制-- 根据非唯一索引列 UPDATE 某条记录
UPDATE lock_example SET name = Vladimir WHERE age = 24;
-- 或根据非唯一索引列锁住某条记录
SELECT * FROM lock_example WHERE age = 24 FOR UPDATE;
不管执行了上述 SQL 中的哪一句,之后如果在事务 B 中执行以下命令,则该命令会被阻塞:
代码语言:javascript复制INSERT INTO table VALUES(100, 16, 'Ezreal');
很明显,事务 A 在对 age 为 24 的列进行 UPDATE 操作的同时,获取了 (10, 24] 这个区间内的临键锁。
不仅如此,在执行以下 SQL 时,也会陷入阻塞等待:
代码语言:javascript复制INSERT INTO table VALUES(100, 30, 'Tom');
那么我们就可以得知,在根据非唯一索引对记录行进行 UPDATE 、FOR UPDATE、FOR SHARE 或 LOCK IN SHARE MODE 操作时,InnoDB 会获取该记录行的临键锁,并同时获取该记录行下一个区间的间隙锁。即事务 A 在执行了上述的 SQL 后,最终被锁住的记录区间为 (10, 32)。
5.4 插入意向锁
一个事务在插入一条记录的时候,需要判断插入位置是否已被其他事务加了间隙锁(next-key lock 也包含间隙锁)。
如果有的话,插入操作就会发生阻塞,直到拥有间隙锁的那个事务提交为止(释放间隙锁的时刻),在此期间会生成一个插入意向锁(Insert Intention Lock),表明有事务想在某个区间插入新记录,但是现在处于等待状态。
举个例子(表和数据依然是上面例子 lock_example 表),事务 A 先执行,在 age 10 与 24 两条记录中插入一行,还未提交:
代码语言:javascript复制INSERT INTO lock_example VALUES(11, 22, 'Jim');
事务 B 也在 age 10 与 24 两条记录间插入一行:
代码语言:javascript复制INSERT INTO lock_example VALUES(12, 23, 'Bob');
因为是插入操作,这时会判断插入的位置已经被事务 A 加了间隙锁,于是事物 B 会生成一个插入意向锁,然后将锁的状态设置为等待状态(PS:MySQL 加锁时,是先生成锁结构,然后设置锁的状态,如果锁状态是等待状态,并不是意味着事务成功获取到了锁,只有当锁状态为正常状态时,才代表事务成功获取到了锁),此时事务 B 就会发生阻塞,直到事务 A 提交了事务。
插入意向锁名字虽然有意向锁,但是它并不是意向锁,它是一种特殊的间隙锁,属于行级别锁。
如果说间隙锁锁住的是一个区间,那么「插入意向锁」锁住的就是一个点。因而从这个角度来说,插入意向锁确实是一种特殊的间隙锁。
参考文献
MySQL 8.0 Reference Manual :: 13.7.8.3 FLUSH Statement 13.3.6 LOCK TABLES and UNLOCK TABLES Statements MySQL 8.0 Reference Manual :: 8.11.4 Metadata Locking MySQL 8.0 Reference Manual :: 15.7.1 InnoDB Locking 深入剖析 MySQL 自增锁 MySQL 有哪些锁?