diff --git a/notes/数据库系统原理.md b/notes/数据库系统原理.md index 153a79c1..5407c48b 100644 --- a/notes/数据库系统原理.md +++ b/notes/数据库系统原理.md @@ -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-长事务与版本链) * [参考资料](#参考资料) @@ -130,6 +133,8 @@ MySQL 默认采用自动提交模式。也就是说,如果不显式使用 STAR

+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)