8.11.4 元数据锁

MySQL使用元数据锁来管理对数据库对象的并发访问,以此来保证数据一致性。元数据锁不止针对表,还针对表结构、存储过程,表空间,使用GET_LOCK()函数(见12.15 “Locking Functions”)获取的用户锁,以及使用锁服务获取到的锁,见章节5.6.9.1 “The Locking Service”。

Performance Schema数据库中的metadata_locks表提供了元数据锁的相关信息,这对查看哪些会话持有锁,哪些在等待锁很有用。要查看详情,请查阅27.12.13.3,“The metadata_locks Table”。

元数据所确实会有一些开销,这些开销会随着查询量的增加而增加。访问同一个对象的查询越多,元数据竞争就越激烈。

元数据锁不是表结构定义缓存的替代品,它的互斥锁和锁和LOCK_open锁也不同。下面会讨论一些关于元数据锁如何运行的相关信息。

  • 元数据锁获取。
  • 元数据锁释放。

元数据锁获取 #

如果一个锁有多个等待者,优先级高的锁请求将会被优先满足。这里有一个例外,与系统变量 max_write_lock_count有关。写锁请求的优先级比读锁请求高。 但是,如果max_write_lock_count被设置成一个比较低的值(比如,10),且在一个读请求等待时已经处理了10个写请求,那么读请求会被优先处理。正常情况下这种情况不会发生,因为max_write_lock_count的默认值是一个很大的值。

语句是一个一个的获取元数据锁,不是同时获取,并在执行过程中进行死锁检测。

DML语句通常按照表在语句中出现的顺序获取锁。

DDL语句,LOCK TABLES,以及其它相似语句尝试通过按名称顺序显式获取有名表上的锁来减少并发DDL操作带来的死锁。如果不是显式使用的表(比如比如要锁定的外键涉及的锁),获取锁的顺序可能不同。

例如,RENAME是一个按照名称顺序获取锁的DDL语句:

  • RENAME TABLE 语句修改tbla修改为其他名称,并将tblc重命名为tbla:
RENAME TABLE tbla TO tbld, tblc TO tbla;

这个语句按照tbla,tblc和tbld的顺序获取元数据锁(因为按照顺序,tbld在tblc之后)。

  • 下面这个语句也会讲tbla命名为其它,并且将tblc修改为tbla:
RENAME TABLE tbla to tblb, tblc to tbla;

这个语句按照tbla,tblb,tblc的顺序获取元数据锁(因为按照顺序,tblb在tblc之前)。

这两个语句都会获取tbla和tblb上的锁,顺序相同,但不同的是获取另一张表的锁的顺序是在tblc之前还是之后。

当多个事务并发执行时,元数据锁的加锁顺序可能会导致操作结果的不同。下面这个例子展示了这种情况。

假设有两张表结构相同的表x和x_new。有三个客户端执行的语句涉及这两张表: Client 1:

LOCK TABLE x WRITE, x_new WRITE;

这个语句按照名称的顺序获取x和x_new上的写锁。

Client 2:

INSERT INTO X VALUES(1);

这个语句请求x上的写锁并被阻塞。

Client 3:

RENAME TABLE x to x_old, x_new TO x;

这个语句按照x,x_new和x_old的名称顺序请求排它锁,但是在获取x上的锁时被阻塞并等待。

Client 1:

UNLOCK TABLES;

这个语句释放了x和x_new上的写锁。Client3对x的排它锁请求比Client2对x的写锁请求优先级更高,所以Client3会获取x上的锁,然后锁定x_new和x_old,进行重命名,然后释放锁。然后Client2才能获取x上的锁,进行insert,然后释放锁。

锁的获取顺序使得RENAME TABLE比INSERT更早执行。Client2中insert语句插入的表是被Client3重命名为x的原名为x_new的表:

mysql> SELECT * FROM x;
+------+
| i    |
+------+
|    1 |
+------+

mysql> SELECT * FROM x_old;
Empty set (0.00 sec)

现在,假设有两张表x和new_x,表结构相同。也有三个客户端执行的语句涉及这两张表: Client 1:

LOCK TABLE x WRITE, new_x WRITE;

这个语句按照名称顺序获取new_x和x上的锁。 Client2:

INSERT INTO x VALUES(1);

这个语句请求x上的写锁并被阻塞。 Client3:

RENAME TABLE x TO old_x, new_x TO x;

这个语句按照new_x,old_x,x的名称顺序请求排它锁,但是在获取new_x上的锁时被阻塞并等待。 Client1:

UNLOCK TABLES;

这个语句释放了x和new_x上的写锁。对于x,唯一被阻塞的请求来自于Client2,所以Client2会获取到锁,执行insert,然后释放锁。对于new_x,唯一被阻塞的请求来自于Client3,所以Client3会获取到new_x和old_x上的锁。在Client2插入完成并释放锁之前,rename操作会被阻塞。然后Client3会获取到x上的锁,进行重命名操作,然后释放锁。

在这个例子中,锁的获取顺序导致INSERT操作比RENAME TABLE操作更早执行。插入操作插入的是原始x表,然后x表被rename操作重命名为old_x:

mysql> SELECT * FROM x;
Empty set (0.00 sec)

mysql> SELECT * FROM old_x;
+------+
| i    |
+------+
|    1 |
+------+

如果像上面例子中那样,并发执行语句时获取锁的顺序导致应用执行结果的不同,你可以通过调整表名来控制锁的获取顺序。

元数据锁会扩散到外键相关的表,这是为了防止在相关表上并发执行DML和DDL语句时发生冲突。当父表被更新时,如果更新了外键的元数据信息,就会获取子表上的元数据锁。外键元数据是子表拥有的。

元数据锁释放 #

为了保证事务的序列化特性,服务器不会允许在一个显式或隐式开启的事务中使用某表时,在另一个会话中对这张表进行DDL操作。数据库服务器通过获取被事务访问的表上的元数据锁,并在事务结束时释放相关锁来实现这一点。某表上的元数据锁会阻止对这张表的表结构进行修改。这种锁模式使得在一个事务结束之前,DDL语句不能操作这个事务中使用到的表。

这个原则不止针对事务型的表,也针对非事务表。假设一个会话会使用事务型表t和非事务型表nt,如下:

START TRANSACTION;
SELECT * FROM t;
SELECT * FROM nt;

数据库服务器会持有t和nt上的元数据锁,直到事务结束。如果另一个会话尝试对这两张表进行DDL操作或写锁操作,会被阻塞,直到事务被释放。例如,另一个会话尝试执行如下操作并被阻塞:

DROP TABLE t;
ALTER TABLE T ...;
DROP TABLE nt;
ALTER TABLE nt ...;
LOCK TABLE t ... WRITE;

这种规则也适用于LOCK TABLES … READ. 就是说,如果一张表已经被LOCK TABLES … READ语句锁定,那么在另一个会话中,显式/隐式开启的事务中对相关表进行更新时,会被阻塞。

如果一个已经获取到元数据锁的语句在语法上有效,但是在执行时失败了,它并不会提前释放。锁仍然会等到事务结束时才会被释放,因为失败的语句被写入binnary log,锁会保证日志的一致性。

在自动提交模式下,每一个语句都是一个事务,这个语句获取到的元数据锁会一直持有,直到事务结束。

一个PREPARE语句获取到的元数据锁会在语句被预处理后立即释放,即使预处理是在一个多语句事务中发生的。

从MySQL8.0.13开始,对于处于PREPARED状态的XA事务,元数据锁会被一直持有,直到XA COMMIT或XA ROLLBACK语句被执行,即使中间发生了客户端断开连接和服务器重启。