Update 数据库系统原理.md

补充 MySQL 中的锁(全局锁、表锁、元数据锁、意向锁、自增锁)和元数据锁的等待,附录 A7、A8。
This commit is contained in:
CyC2018
2026-10-29 04:55:48 +08:00
parent 6f60f7b082
commit 5d6ab3eecb
+112 -2
View File
@@ -15,6 +15,7 @@
* [封锁类型](#封锁类型)
* [封锁协议](#封锁协议)
* [MySQL 隐式与显式锁定](#mysql-隐式与显式锁定)
* [MySQL 中的锁](#mysql-中的锁)
* [四、隔离级别](#四隔离级别)
* [未提交读(READ UNCOMMITTED)](#未提交读read-uncommitted)
* [提交读(READ COMMITTED)](#提交读read-committed)
@@ -46,6 +47,8 @@
* [A4 快照读与当前读混用](#a4-快照读与当前读混用)
* [A5 Next-Key Locks 的加锁范围](#a5-next-key-locks-的加锁范围)
* [A6 没有索引时的加锁](#a6-没有索引时的加锁)
* [A7 元数据锁的等待](#a7-元数据锁的等待)
* [A8 长事务与版本链](#a8-长事务与版本链)
* [参考资料](#参考资料)
<!-- GFM-TOC -->
@@ -130,6 +133,8 @@ MySQL 默认采用自动提交模式。也就是说,如果不显式使用 STAR
<div align="center"> <img src="pics/txn-phantom.png" width="780px"/> </div><br>
InnoDB 在可重复读下如何避免幻读,见 [六、Next-Key Locks](#六next-key-locks)。
----
产生并发不一致性问题的主要原因是破坏了事务的隔离性,解决方法是通过并发控制来保证隔离性。并发控制可以通过封锁来实现,但是封锁操作需要用户自己控制,相当复杂。数据库管理系统提供了事务的隔离级别,让用户以一种更轻松的方式处理并发一致性问题。
@@ -250,6 +255,42 @@ SELECT ... FOR SHARE; -- MySQL 8.0 之前写作 LOCK IN SHARE MODE
SELECT ... FOR UPDATE;
```
### MySQL 中的锁
按粒度从大到小,MySQL 中的锁主要有以下几种:
| 粒度 | 锁 | 用途 |
| --- | --- | --- |
| 全局 | 全局读锁(FLUSH TABLES WITH READ LOCK) | 整个实例只读,用于给不支持事务的表做一致性备份 |
| 表 | 表锁(LOCK TABLES ... READ / WRITE) | 显式锁住整张表 |
| 表 | 元数据锁(Metadata Lock, MDL) | 自动加,防止表结构在使用过程中被修改 |
| 表 | 意向锁(IS、IX) | 加行锁之前先加,用来快速判断表中有没有行被锁住,见上文 |
| 表 | 自增锁(AUTO-INC) | 分配自增值,MySQL 8.0 默认的 innodb_autoinc_lock_mode = 2 下插入语句不再使用 |
| 行 | 记录锁、间隙锁、Next-Key Lock、插入意向锁 | InnoDB 的行锁,加在索引上,见 [六、Next-Key Locks](#六next-key-locks) |
InnoDB 的表备份时不需要全局读锁:mysqldump --single-transaction 在一个可重复读的事务中导出,读到的是开始时的快照,不阻塞其它读写。InnoDB 也不会把大量行锁升级为表锁,“锁表”通常是因为条件列没有索引,扫描过的每一行都被锁住了,见 [六、Next-Key Locks](#六next-key-locks) 的末尾。
#### 1. 元数据锁
执行 SELECT、UPDATE 等语句时自动对表加 MDL 读锁,ALTER TABLE 要加 MDL 写锁,读锁和写锁互斥,并且 MDL 一直持有到事务结束。例如事务 A 读过表 t 但没有提交(MySQL 8.0 实测,见[附录 A7](#a7-元数据锁的等待)):
```text
A:BEGIN; SELECT * FROM t WHERE id = 1; -- 不提交,持有 MDL 读锁
B:ALTER TABLE t ADD COLUMN c INT; -- Waiting for table metadata lock
C:SELECT * FROM t WHERE id = 2; -- Waiting for table metadata lock
A:COMMIT; -- B、C 随后执行完成
```
C 只是一条普通的查询,也被阻塞了。这是因为 B 在等待 MDL 写锁,写锁请求的优先级高于读锁,排在 B 后面的读请求都要等待,表上的所有查询看起来就像“锁表”了。MySQL 8.0 中加列默认使用 ALGORITHM=INSTANT,只改元数据、瞬间完成,但同样要先拿到 MDL 写锁。
所以改表前先在 information_schema.innodb_trx 中检查有没有长事务,并给 DDL 设置较短的 lock_wait_timeout(默认是一年)。上例中 B 设置 lock_wait_timeout = 3 后,等待 3 秒报错退出,C 随即执行完成,ALTER 可以稍后重试。
#### 2. 间隙锁带来的问题
间隙锁之间互不冲突:两个事务可以同时锁住同一个间隙,间隙锁只阻止插入。并发写入多时这会带来两个问题:插入被其它事务的间隙锁阻塞;两个事务先锁住同一个间隙再各自插入,会互相等待对方的间隙锁而死锁,常见于“先查有没有,没有再插入”,例子见 [MySQL 死锁](计算机操作系统%20-%20死锁.md#2-mysql-死锁)。可以直接依赖唯一索引插入并处理重复键错误,或者改用不加间隙锁的提交读。
以上都是悲观锁。数据库中的乐观锁一般用版本号实现,不需要加锁,见 [乐观锁与悲观锁](Java%20并发.md#4-乐观锁与悲观锁)。
## 四、隔离级别
### 未提交读(READ UNCOMMITTED)
@@ -285,6 +326,26 @@ InnoDB 的实现方式是在关闭自动提交时,把普通的 SELECT 都变
这是 SQL 标准的规定。MySQL 的 InnoDB 默认使用可重复读,并且用 MVCC 和 Next-Key Locks 在很大程度上避免了幻读,见 [六、Next-Key Locks](#六next-key-locks);Oracle 和 PostgreSQL 默认使用提交读。
MySQL 默认使用可重复读与 binlog 有关:提交读不加间隙锁,按语句重放时从库可能得到与主库不同的结果,所以 InnoDB 的提交读只支持 ROW 格式的 binlog;而 MySQL 5.1.4 及以前,binlog 只有记录语句的 STATEMENT 格式。
InnoDB 用 MVCC 和锁实现这四种隔离级别:
| 隔离级别 | 普通 SELECT | 加锁读、UPDATE、DELETE |
| --- | --- | --- |
| 未提交读 | 直接读最新版本 | 只锁记录 |
| 提交读 | 每条语句创建新的 ReadView | 只锁记录 |
| 可重复读 | 第一次读时创建 ReadView,整个事务复用 | Next-Key Lock |
| 可串行化 | 关闭自动提交时变为加共享锁的 FOR SHARE | Next-Key Lock |
查看和修改隔离级别:
```sql
SELECT @@transaction_isolation; -- REPEATABLE-READ
SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED; -- 当前会话之后的事务都使用提交读
```
MySQL 8.0 已移除旧的变量名 tx_isolation。SET TRANSACTION 不写 SESSION 或 GLOBAL 时,只对下一个事务有效。
## 五、多版本并发控制
多版本并发控制(Multi-Version Concurrency Control, MVCC)是 MySQL 的 InnoDB 存储引擎实现隔离级别的一种具体方式,用于实现提交读和可重复读这两种隔离级别。而未提交读隔离级别总是读取最新的数据行,要求很低,无需使用 MVCC。可串行化隔离级别需要对所有读取的行都加锁,单纯使用 MVCC 无法实现。
@@ -299,7 +360,7 @@ InnoDB 的实现方式是在关闭自动提交时,把普通的 SELECT 都变
### 版本号
InnoDB 为每个事务分配一个递增的事务 ID(TRX_ID),每个数据行都记录着最后修改它的事务 ID。需要注意的是,事务 ID 不是在 BEGIN 时分配的,而是在事务第一次修改数据时才分配;只读的事务不分配真正的事务 ID,也就不会出现在其它事务的 ReadView 中。在 information_schema.innodb_trx 中可以看到,事务只执行过 SELECT 时 trx_id 是一个很大的临时编号,执行 UPDATE 之后才变成真正的事务 ID(见[附录 A3](#a3-事务-id-的分配))。
InnoDB 为每个事务分配一个递增的事务 ID(TRX_ID),每个数据行都记录着最后修改它的事务 ID。行中的这类信息放在隐藏列中:6 字节的 DB_TRX_ID 是最后修改它的事务 ID,7 字节的 DB_ROLL_PTR 是指向 undo log 中旧版本的回滚指针;表没有主键和非空唯一索引时,还有 6 字节的 DB_ROW_ID 作为聚簇索引的键。需要注意的是,事务 ID 不是在 BEGIN 时分配的,而是在事务第一次修改数据时才分配;只读的事务不分配真正的事务 ID,也就不会出现在其它事务的 ReadView 中。在 information_schema.innodb_trx 中可以看到,事务只执行过 SELECT 时 trx_id 是一个很大的临时编号,执行 UPDATE 之后才变成真正的事务 ID(见[附录 A3](#a3-事务-id-的分配))。
### Undo 日志
@@ -319,9 +380,13 @@ UPDATE t SET x="c" WHERE id=1;
INSERT、UPDATE、DELETE 操作会创建一个日志,并将事务版本号 TRX_ID 写入。DELETE 可以看成是一个特殊的 UPDATE,还会额外将 DEL 字段设置为 1。
旧版本不会一直保留。INSERT 产生的 undo log 只用于回滚,事务提交后就可以丢弃;UPDATE、DELETE 产生的 undo log 还要供其它事务读取旧版本,等到没有任何 ReadView 还需要它时,才由后台的 purge 线程清理,被标记删除的行也在这时真正删除。
所以长事务的代价很大:它的 ReadView 让之后的旧版本都不能清理,undo log 越积越多,它自己读数据时还要沿着越来越长的版本链往回找。例如一个可重复读事务读过某一行后一直不提交,另一个会话把这一行更新了 10 万次,这时长事务再读这一行要 26 ms,而新事务只要 0.06 ms(MySQL 8.0 实测,见[附录 A8](#a8-长事务与版本链))。SHOW ENGINE INNODB STATUS 中的 History list length 反映了还没有清理的 undo log 数量,持续增长时要找出长事务(information_schema.innodb_trx 中 trx_started 很早的事务)。
### ReadView
MVCC 维护了一个 ReadView 结构,主要包含了创建 ReadView 时系统中未提交的事务列表 TRX_IDs {TRX_ID_1, TRX_ID_2, ...},该列表的最小值 TRX_ID_MIN,以及下一个将要分配的事务 ID TRX_ID_MAX。
MVCC 维护了一个 ReadView 结构,主要包含了创建 ReadView 时系统中未提交的事务列表 TRX_IDs {TRX_ID_1, TRX_ID_2, ...},该列表的最小值 TRX_ID_MIN,以及下一个将要分配的事务 ID TRX_ID_MAX。InnoDB 源码中这几项分别是 m_ids、m_up_limit_id、m_low_limit_id,另外 m_creator_trx_id 记录创建它的事务自己的 ID。
在进行 SELECT 操作时,根据数据行快照的 TRX_ID 与 TRX_ID_MIN 和 TRX_ID_MAX 之间的关系,从而判断数据行快照是否可以使用:
@@ -698,6 +763,44 @@ A、B 两个会话的锁等待超时设为 1 秒。A 执行 UPDATE t_nl SET d =
B: UPDATE t_nl SET d = 51 WHERE id = 5 -> OK
```
### A7 元数据锁的等待
```sql
CREATE TABLE t (id INT PRIMARY KEY, v INT);
INSERT INTO t VALUES (1, 10), (2, 20);
```
A 开始事务读取 id = 1 后不提交;1 秒后 B 执行 ALTER TABLE t ADD COLUMN c INT, ALGORITHM=INSTANT;再过 1 秒 C 执行 SELECT * FROM t WHERE id = 2。此时查看 PROCESSLIST 和 performance_schema.metadata_locks:
```text
processlist Waiting for table metadata lock ALTER TABLE t ADD COLUMN c INT, ALGORITHM=INSTANT
processlist Waiting for table metadata lock SELECT * FROM t WHERE id = 2
metadata_locks SHARED_READ GRANTED A
metadata_locks SHARED_UPGRADABLE GRANTED B
metadata_locks EXCLUSIVE PENDING B
metadata_locks SHARED_READ PENDING C
```
A 在第 5 秒提交后,B 用时 5.02 s 完成,C 用时 4.02 s 完成。
B 先执行 SET SESSION lock_wait_timeout = 3 时:B 在 3.02 s 后报错 ERROR 1205 Lock wait timeout exceeded,C 随即返回(用时 1.79 s),此时 A 仍未提交。
### A8 长事务与版本链
```sql
CREATE TABLE t (id INT PRIMARY KEY, v INT);
INSERT INTO t VALUES (1, 0);
```
R 以 START TRANSACTION WITH CONSISTENT SNAPSHOT 开始可重复读事务并读取 id = 1,不提交;W 执行 10 万次 UPDATE t SET v = v + 1 WHERE id = 1,每 1000 次提交一次。之后两个会话各执行 5 次 SELECT v FROM t WHERE id = 1,取最快的一次:
```text
History list length:W 更新前 8,更新后 108
R(旧的 ReadView) v = 0 26.40 ms
新的自动提交查询 v = 100000 0.06 ms
R 提交 3 秒后 History list length 为 0
```
## 参考资料
- AbrahamSilberschatz, HenryF.Korth, S.Sudarshan, 等. 数据库系统概念 [M]. 机械工业出版社, 2006.
@@ -716,3 +819,10 @@ A、B 两个会话的锁等待超时设为 1 秒。A 执行 UPDATE t_nl SET d =
- [MySQL locking for the busy web developer](https://www.brightbox.com/blog/2013/10/31/on-mysql-locks/)
- [浅入浅出 MySQL 和 InnoDB](https://draveness.me/mysql-innodb)
- [Innodb 中的事务隔离级别和锁的关系](https://tech.meituan.com/2014/08/20/innodb-lock.html)
- [MySQL 8.0: Metadata Locking](https://dev.mysql.com/doc/refman/8.0/en/metadata-locking.html)
- [MySQL 8.0: Online DDL Operations](https://dev.mysql.com/doc/refman/8.0/en/innodb-online-ddl-operations.html)
- [MySQL 8.0: AUTO_INCREMENT Handling in InnoDB](https://dev.mysql.com/doc/refman/8.0/en/innodb-auto-increment-handling.html)
- [MySQL 8.0: InnoDB Multi-Versioning](https://dev.mysql.com/doc/refman/8.0/en/innodb-multi-versioning.html)
- [MySQL 8.0: Purge Configuration](https://dev.mysql.com/doc/refman/8.0/en/innodb-purge-configuration.html)
- [MySQL 8.0: SET TRANSACTION Statement](https://dev.mysql.com/doc/refman/8.0/en/set-transaction.html)
- [MySQL 5.1: Replication Formats](https://docs.oracle.com/cd/E19078-01/mysql/mysql-refman-5.1/replication.html)