Update 数据库系统原理.md

补充写偏差、语句级回滚及当前读机制说明,调整隔离级别与 Next-Key Locks 加锁细节;新增 MVCC 与幻读配图并重新绘制相关原理图。
This commit is contained in:
CyC2018
2026-10-30 20:19:12 +08:00
parent 73c259da54
commit 7049ebf8d5
14 changed files with 173 additions and 68 deletions
Binary file not shown.

Before

Width:  |  Height:  |  Size: 129 KiB

After

Width:  |  Height:  |  Size: 129 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 91 KiB

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 111 KiB

After

Width:  |  Height:  |  Size: 115 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 92 KiB

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 137 KiB

After

Width:  |  Height:  |  Size: 143 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 119 KiB

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 241 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 177 KiB

After

Width:  |  Height:  |  Size: 182 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 136 KiB

After

Width:  |  Height:  |  Size: 137 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 129 KiB

After

Width:  |  Height:  |  Size: 127 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 162 KiB

After

Width:  |  Height:  |  Size: 174 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 170 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 126 KiB

After

Width:  |  Height:  |  Size: 125 KiB

+173 -68
View File
@@ -49,6 +49,8 @@
* [A6 没有索引时的加锁](#a6-没有索引时的加锁)
* [A7 元数据锁的等待](#a7-元数据锁的等待)
* [A8 长事务与版本链](#a8-长事务与版本链)
* [A9 写偏差与可串行化](#a9-写偏差与可串行化)
* [A10 语句出错后的提交](#a10-语句出错后的提交)
* [参考资料](#参考资料)
<!-- GFM-TOC -->
@@ -67,35 +69,33 @@
事务被视为不可分割的最小单元,事务的所有操作要么全部提交成功,要么全部失败回滚。
回滚可以用回滚日志(Undo Log)来实现,回滚日志记录着事务所执行的修改操作,在回滚时反向执行这些修改操作即可。
需要注意的是,InnoDB 中一条语句出错时通常只回滚这条语句,事务仍然开着,要由应用执行 ROLLBACK(死锁这类错误才回滚整个事务)。例如转账时给账户 2 加 200 成功、给账户 1 减 200 违反约束而出错,应用如果照常 COMMIT,账户 2 多出的 200 就被提交了(MySQL 8.0 实测,见[附录 A10](#a10-语句出错后的提交))。
#### 2. 一致性(Consistency)
数据库在事务执行前后都保持一致性状态,即满足所有的约束和业务规则。例如转账前后,两个账户的余额总和保持不变。
数据库在事务执行前后都保持一致性状态,即满足所有的约束和业务规则。例如转账前后,两个账户的余额总和保持不变。它和 CAP 中的一致性(线性一致性,多个副本看起来像只有一份数据)不是同一个概念,见 [CAP](分布式.md#三cap)。
#### 3. 隔离性(Isolation)
一个事务所做的修改在最终提交以前,对其它事务是不可见的。
多个事务并发执行时互不干扰,例如一个事务所做的修改在最终提交以前,对其它事务是不可见的。隔离的程度由隔离级别决定,见 [四、隔离级别](#四隔离级别)。
#### 4. 持久性(Durability)
一旦事务提交,则其所做的修改将会永远保存到数据库中。即使系统发生崩溃,事务执行的结果也不能丢失。
系统发生崩溃可以用重做日志(Redo Log)进行恢复,从而实现持久性。与回滚日志记录数据的逻辑修改不同,重做日志记录的是数据页的物理修改。
----
事务的 ACID 特性概念简单,但不是很好理解,主要是因为这几个特性不是一种平级关系:
- 只有满足一致性,事务的执行结果才是正确的;
- 在无并发的情况下,事务串行执行,隔离性一定能够满足,此时只要能满足原子性,就一定能满足一致性;
- 在无并发的情况下,事务串行执行,隔离性一定能够满足,此时只要事务本身的逻辑正确并且满足原子性,就能满足一致性;
- 在并发的情况下,多个事务并行执行,事务不仅要满足原子性,还需要满足隔离性,才能满足一致性;
- 事务满足持久性是为了能应对系统崩溃的情况。
在 InnoDB 中,这四个特性分别这样实现:
- 原子性:undo log 记录每次修改之前的值,回滚时用它恢复;
- 持久性:修改数据页之前先写 redo log,默认配置下事务提交时把 redo log 刷到磁盘,崩溃后重放 redo log 恢复已提交的修改,见 [MySQL 的日志](MySQL.md#七日志);
- 原子性:回滚日志(undo log)记录每次修改之前的值,回滚时用它恢复;
- 持久性:数据页写回磁盘之前,先把记录数据页修改的重做日志(redo log)写到磁盘(预写日志),默认配置下事务提交时把 redo log 刷到磁盘,崩溃后重放 redo log 恢复已提交的修改,见 [MySQL 的日志](MySQL.md#七日志);
- 隔离性:写操作加锁,普通的读操作通过 MVCC 读取快照,见 [多版本并发控制](#五多版本并发控制);
- 一致性:由以上三者和数据库的约束(主键、唯一索引、外键)共同保证,业务规则还需要应用自己维护。
@@ -115,6 +115,12 @@ MySQL 默认采用自动提交模式。也就是说,如果不显式使用 STAR
<div align="center"> <img src="pics/txn-lost-update.png" width="780px"/> </div><br>
InnoDB 在可串行化以下的隔离级别都不会自动避免这种先读后写的丢失修改(附录 A1 中两个会话都是可重复读),常用的做法有三种:
- 在 UPDATE 中直接计算(`SET balance = balance - 30`),第二个 UPDATE 会等待第一个的行锁,再基于 70 计算;
- 先用 SELECT ... FOR UPDATE 加锁读取,第二个事务的读取要等第一个提交;
- 用版本号实现乐观锁,见 [乐观锁与悲观锁](Java%20并发.md#4-乐观锁与悲观锁)。
### 脏读
脏读指一个事务读到了另一个事务还没有提交的数据。例如:T<sub>1</sub> 修改一个数据但未提交,T<sub>2</sub> 随后读取这个数据。如果 T<sub>1</sub> 撤销了这次修改,那么 T<sub>2</sub> 读取的数据是脏数据。
@@ -133,7 +139,7 @@ MySQL 默认采用自动提交模式。也就是说,如果不显式使用 STAR
<div align="center"> <img src="pics/txn-phantom.png" width="780px"/> </div><br>
InnoDB 在可重复读下如何避免幻读,见 [六、Next-Key Locks](#六next-key-locks)。
单独区分幻读,是因为加锁读时两者的解决方法不同:不可重复读只要锁住读过的行,而幻读中新插入的行在读取时还不存在,行锁锁不住,要锁住整个范围(间隙)。InnoDB 在可重复读下如何避免幻读,见 [六、Next-Key Locks](#六next-key-locks)。
----
@@ -143,7 +149,7 @@ InnoDB 在可重复读下如何避免幻读,见 [六、Next-Key Locks](#六nex
### 封锁粒度
MySQL 中提供了两种封锁粒度:行级锁以及表级锁。
InnoDB 支持行级锁和表级锁,MyISAM 只支持表级锁。
应该尽量只锁定需要修改的那部分数据,而不是所有的资源。锁定的数据量越少,发生锁争用的可能就越小,系统的并发程度就越高。
@@ -204,9 +210,9 @@ MySQL 中提供了两种封锁粒度:行级锁以及表级锁。
##### 1.1 一级封锁协议
事务 T 要修改数据 A 时必须加 X 锁,直到 T 结束才释放锁。
事务 T 要修改数据 A 时,必须在读取 A 之前加 X 锁,直到 T 结束才释放锁。
可以解决丢失修改问题,因为不能同时有两个事务对同一个数据进行修改,那么事务的修改就不会被覆盖。
可以解决丢失修改问题:另一个同样要修改 A 的事务也得先加 X 锁,要等 T 结束后才能读取 A,读到的是 T 修改后的值,不会覆盖 T 的修改。
<div align="center"> <img src="pics/lock-level-1.png" width="780px"/> </div><br>
@@ -214,7 +220,7 @@ MySQL 中提供了两种封锁粒度:行级锁以及表级锁。
在一级的基础上,要求读取数据 A 时必须加 S 锁,读取完马上释放 S 锁。
可以解决脏读问题,因为如果一个事务在对数据 A 进行修改,根据 1 级封锁协议,会加 X 锁,那么就不能再加 S 锁了,也就是不会读入数据。
可以解决脏读问题:一个事务修改 A 时按一级封锁协议持有 X 锁直到结束,其它事务这期间加不上 S 锁,也就读不到未提交的修改。但读完立即释放 S 锁,两次读取之间 A 仍可能被修改,所以还会出现不可重复读。
<div align="center"> <img src="pics/lock-level-2.png" width="780px"/> </div><br>
@@ -232,21 +238,22 @@ MySQL 中提供了两种封锁粒度:行级锁以及表级锁。
可串行化调度是指,通过并发控制,使得并发执行的事务结果与某个串行执行的事务结果相同。串行执行的事务互不干扰,不会出现并发一致性问题。
事务遵循两段锁协议是保证可串行化调度的充分条件。例如以下操作满足两段锁协议,它是可串行化调度。
事务遵循两段锁协议是保证可串行化调度的充分条件。例如以下事务的加锁和解锁顺序满足两段锁协议。
```text
lock-x(A)...lock-s(B)...lock-s(C)...unlock(A)...unlock(C)...unlock(B)
```
但不是必要条件,例如以下操作不满足两段锁协议,但它还是可串行化调度。
但不是必要条件,例如下面的 T<sub>1</sub> 释放 A 之后又申请 B,不满足两段锁协议;T<sub>2</sub> 在这期间修改了 A,但没有访问 B,这个调度的结果与先执行 T<sub>1</sub> 再执行 T<sub>2</sub> 相同。
```text
lock-x(A)...unlock(A)...lock-s(B)...unlock(B)...lock-s(C)...unlock(C)
T1: lock-x(A)...unlock(A) lock-s(B)...unlock(B)
T2: lock-x(A)...unlock(A)
```
### MySQL 隐式与显式锁定
MySQL 的 InnoDB 存储引擎采用两段锁协议,会根据隔离级别在需要的时候自动加锁,所有的锁都在事务提交或回滚时一起释放,这被称为隐式锁定。
MySQL 的 InnoDB 存储引擎采用两段锁协议,会根据隔离级别在需要的时候自动加锁,这被称为隐式锁定。行锁一般持有到事务提交或回滚时才释放(提交读下不满足条件的行会提前释放,见[提交读](#提交读read-committed))。
InnoDB 也可以使用特定的语句进行显式锁定:
@@ -289,7 +296,7 @@ C 只是一条普通的查询,也被阻塞了。这是因为 B 在等待 MDL
间隙锁之间互不冲突:两个事务可以同时锁住同一个间隙,间隙锁只阻止插入。并发写入多时这会带来两个问题:插入被其它事务的间隙锁阻塞;两个事务先锁住同一个间隙再各自插入,会互相等待对方的间隙锁而死锁,常见于“先查有没有,没有再插入”,例子见 [MySQL 死锁](计算机操作系统%20-%20死锁.md#2-mysql-死锁)。可以直接依赖唯一索引插入并处理重复键错误,或者改用不加间隙锁的提交读。
以上都是悲观锁。数据库中的乐观锁一般用版本号实现,不需要加锁,见 [乐观锁与悲观锁](Java%20并发.md#4-乐观锁与悲观锁)。
以上都是悲观锁。数据库中的乐观锁一般用版本号实现:读取时不加锁,更新时用 `WHERE version = ?` 检查版本有没有变化,影响 0 行表示被别人改过,需要重试,见 [乐观锁与悲观锁](Java%20并发.md#4-乐观锁与悲观锁)。
## 四、隔离级别 🔥🔥
@@ -299,9 +306,9 @@ C 只是一条普通的查询,也被阻塞了。这是因为 B 在等待 MDL
### 提交读(READ COMMITTED)
一个事务只能读取已经提交的事务所做的修改。换句话说,一个事务所做的修改在提交之前对其它事务是不可见的。
一个事务只能读取已经提交的事务所做的修改。
InnoDB 在提交读下不加间隙锁(只在检查外键和唯一键时使用),UPDATE、DELETE 扫描过但不满足条件的行,判断完条件就释放锁。插入不再被间隙锁阻塞,死锁也少得多,所以不少公司把隔离级别改为提交读,代价是会出现不可重复读和幻读,并且 binlog 必须使用 ROW 格式(见 [binlog](MySQL.md#binlog))。
InnoDB 在提交读下不加间隙锁(只在检查外键和唯一键时使用),UPDATE、DELETE 扫描过但不满足条件的行,判断完条件就释放锁。插入不再被间隙锁阻塞,死锁也少得多,所以不少公司把隔离级别改为提交读,代价是会出现不可重复读和幻读,并且 binlog 不能使用 STATEMENT 格式(设为 MIXED 时会自动按行记录,见 [binlog](MySQL.md#binlog))。
### 可重复读(REPEATABLE READ)
@@ -311,7 +318,15 @@ InnoDB 在提交读下不加间隙锁(只在检查外键和唯一键时使用
保证并发执行的结果与某种串行执行的结果相同,不会出现并发一致性问题。
InnoDB 的实现方式是在关闭自动提交时,把普通的 SELECT 都变成加共享锁的 SELECT ... FOR SHARE,读和写会互相阻塞,并发度最低:可串行化的事务读过的行,另一个事务要修改就得等它结束(见[附录 A2](#a2-可串行化与快照读))。
InnoDB 的实现方式是在显式开始的事务中(BEGIN,或者关闭了自动提交),把普通的 SELECT 都变成加共享锁的 SELECT ... FOR SHARE,读和写会互相阻塞,并发度最低:可串行化的事务读过的行,另一个事务要修改就得等它结束。自动提交的单条 SELECT 仍然是快照读,不加锁(见[附录 A2](#a2-可串行化与快照读))。
有了可重复读仍然需要可串行化,是因为“先读、再按读到的结果写”的事务仍会出错:除了[丢失修改](#丢失修改),还有写偏差(Write Skew),即两个事务读同一批数据、各自修改不同的行,合起来违反业务规则。
例如群规定至少保留一个管理员,管理员 1001 和 1002 同时退出,两个事务都先查到 2 个管理员,再各自取消自己的身份。可重复读下两者都提交成功,群里没有管理员了;可串行化下两个读取都加了共享锁,更新时互相等待而死锁,其中一个被回滚(MySQL 8.0 实测,见[附录 A9](#a9-写偏差与可串行化))。
不用可串行化时,要锁住参与判断的所有行:上例中查人数时加 FOR UPDATE,后退出的一方等前一个提交后读到只剩 1 人,就会拒绝退出。只校验自己那一行版本号的乐观锁防不住写偏差,因为两个事务改的是不同的行。
可串行化读写互相阻塞、死锁多,实际很少用,一般用可重复读或提交读,在先读后写的地方自己加锁;未提交读会出现脏读,基本不用。
----
@@ -326,7 +341,7 @@ 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 格式。
MySQL 默认使用可重复读与 binlog 有关:提交读不加间隙锁,按语句重放时从库可能得到与主库不同的结果,所以 InnoDB 的提交读只能按行记录 binlog;而 MySQL 5.1.4 及以前,binlog 只有记录语句的 STATEMENT 格式。
InnoDB 用 MVCC 和锁实现这四种隔离级别:
@@ -334,8 +349,8 @@ InnoDB 用 MVCC 和锁实现这四种隔离级别:
| --- | --- | --- |
| 未提交读 | 直接读最新版本 | 只锁记录 |
| 提交读 | 每条语句创建新的 ReadView | 只锁记录 |
| 可重复读 | 第一次读时创建 ReadView,整个事务复用 | Next-Key Lock |
| 可串行化 | 关闭自动提交时变为加共享锁的 FOR SHARE | Next-Key Lock |
| 可重复读 | 第一次快照读时创建 ReadView,整个事务复用 | Next-Key Lock |
| 可串行化 | 显式事务中变为加共享锁的 FOR SHARE | Next-Key Lock |
查看和修改隔离级别:
@@ -348,23 +363,23 @@ MySQL 8.0 已移除旧的变量名 tx_isolation。SET TRANSACTION 不写 SESSION
## 五、多版本并发控制 🔥🔥
多版本并发控制(Multi-Version Concurrency Control, MVCC)是 MySQL 的 InnoDB 存储引擎实现隔离级别的一种具体方式,用于实现提交读和可重复读这两种隔离级别。而未提交读隔离级别总是读取最新的数据行,要求很低,无需使用 MVCC。可串行化隔离级别需要对所有读取的行都加锁,单纯使用 MVCC 无法实现。
多版本并发控制(Multi-Version Concurrency Control, MVCC)是 MySQL 的 InnoDB 存储引擎实现隔离级别的一种具体方式,用于实现提交读和可重复读这两种隔离级别。而未提交读隔离级别总是读取最新的数据行,要求很低,无需使用 MVCC。InnoDB 的可串行化需要对读取的行加锁,只用 MVCC 无法实现。
### 基本思想
在封锁一节中提到,加锁能解决多个事务同时执行时出现的并发一致性问题。在实际场景中读操作往往多于写操作,因此又引入了读写锁来避免不必要的加锁操作,例如读和读没有互斥关系。读写锁中读和写操作仍然是互斥的,而 MVCC 利用了多版本的思想,写操作更新最新的版本快照,而读操作去读旧版本快照,没有互斥关系,这一点和 CopyOnWrite 类似。
在 MVCC 中事务的修改操作(DELETE、INSERT、UPDATE)会为数据行新增一个版本快照。
读写锁让读和读不再互斥,但读和写仍然互斥:一个事务在修改某一行时,其它事务连读都要等待。实际场景中读远多于写,MVCC 为每一行保留多个版本,写操作生成新版本,读操作读取旧版本,读和写互不阻塞,这一点和 CopyOnWrite 类似。
脏读的原因是事务读取到其它事务未提交的修改,MVCC 规定只能读取已经提交的快照来解决脏读。不可重复读的原因是两次读取之间其它事务提交了修改,可重复读隔离级别下事务始终读取同一个快照来解决这个问题。当然一个事务可以读取自身未提交的快照,这不算是脏读。
### 版本号
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-的分配))。
InnoDB 为每个事务分配一个递增的事务 ID(TRX_ID),每个数据行都记录着最后修改它的事务 ID。行中的这类信息放在隐藏列中:6 字节的 DB_TRX_ID 是最后修改它的事务 ID,7 字节的 DB_ROLL_PTR 是指向 undo log 中旧版本的回滚指针;表没有主键和非空唯一索引时,还有 6 字节的 DB_ROW_ID 作为聚簇索引的键。
需要注意的是,事务 ID 不是在 BEGIN 时分配的,而是在事务第一次修改数据(或执行 SELECT ... FOR UPDATE)时才分配;只读的事务不分配真正的事务 ID,也就不会出现在其它事务的 ReadView 中(见[附录 A3](#a3-事务-id-的分配))。
### Undo 日志
MVCC 的多版本指的是多个版本的快照,快照存储在 Undo 日志中,该日志通过回滚指针 ROLL_PTR 把一个数据行的所有快照连接起来。
MVCC 的多版本指的是多个版本的快照。旧版本并不单独保存整行,而是由 undo log 中记录的旧值还原出来,回滚指针 ROLL_PTR 把一个数据行的各个版本串成一条版本链。
例如在 MySQL 创建一个表 t,包含主键 id 和一个字段 x。先插入一个数据行,然后对该数据行执行两次更新操作。
@@ -374,11 +389,11 @@ UPDATE t SET x="b" WHERE id=1;
UPDATE t SET x="c" WHERE id=1;
```
因为没有使用 `START TRANSACTION` 将上面的操作当成一个事务来执行,根据 MySQL 的 AUTOCOMMIT 机制,每个操作都会被当成一个事务来执行,所以上面的操作总共涉及到三个事务,假设它们的 ID 依次为 101、102、103。快照中除了记录事务版本号 TRX_ID 和操作之外,还记录了一个 bit 的 DEL 字段,用于标记是否被删除。
因为没有使用 `START TRANSACTION` 将上面的操作当成一个事务来执行,根据 MySQL 的 AUTOCOMMIT 机制,每个操作都会被当成一个事务来执行,所以上面的操作总共涉及到三个事务,假设它们的 ID 依次为 101、102、103。
<div align="center"> <img src="pics/mvcc-undo-chain.png" width="760px"/> </div><br>
INSERT、UPDATE、DELETE 操作会创建一个日志,并将事务版本号 TRX_ID 写入。DELETE 可以看成是一个特殊的 UPDATE,还会额外将 DEL 字段设置为 1。
INSERT、UPDATE、DELETE 都会写 undo log,记录撤销这次修改需要的信息。例如事务 103 的 UPDATE 先对 id = 1 这一行加 X 锁,把旧值 'b' 写入 undo log,再在原处把 x 改为 'c',DB_TRX_ID 设为 103,DB_ROLL_PTR 指向这条 undo 记录。DELETE 并不立即删除这一行,只设置删除标记,旧版本仍能沿版本链找到。
旧版本不会一直保留。INSERT 产生的 undo log 只用于回滚,事务提交后就可以丢弃;UPDATE、DELETE 产生的 undo log 还要供其它事务读取旧版本,等到没有任何 ReadView 还需要它时,才由后台的 purge 线程清理,被标记删除的行也在这时真正删除。
@@ -388,29 +403,30 @@ INSERT、UPDATE、DELETE 操作会创建一个日志,并将事务版本号 TRX
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 之间的关系,从而判断数据行快照是否可以使用:
快照读时,根据数据行快照的 TRX_ID 与 TRX_ID_MIN 和 TRX_ID_MAX 之间的关系,从而判断数据行快照是否可以使用:
- TRX_ID 等于当前事务自己的 ID,表示是本事务修改的,可以使用;
- TRX_ID \< TRX_ID_MIN,表示该数据行快照是在当前所有未提交事务之前进行更改的,因此可以使用;
- TRX_ID \>= TRX_ID_MAX,表示该数据行快照是在 ReadView 创建之后被更改的,因此不可使用;
- TRX_ID_MIN \<= TRX_ID \< TRX_ID_MAX,如果 TRX_ID 在 TRX_IDs 列表中,表示该数据行快照对应的事务还未提交,则该快照不可使用。否则表示已经提交,可以使用。
提交读和可重复读的判断规则相同,区别在于 ReadView 的创建时机:提交读在每次 SELECT 时都创建新的 ReadView,因此能读到其它事务新提交的修改;可重复读只在事务第一次 SELECT 时创建 ReadView,之后一直复用,因此多次读取的结果相同。
提交读和可重复读的判断规则相同,区别在于 ReadView 的创建时机:提交读在每次快照读时都创建新的 ReadView,因此能读到其它事务新提交的修改;可重复读只在事务第一次快照读时创建 ReadView,之后一直复用,因此多次读取的结果相同。BEGIN 本身不创建 ReadView,BEGIN 之后、第一次快照读之前提交的修改仍然可见;START TRANSACTION WITH CONSISTENT SNAPSHOT 则在开始时立即创建(见[附录 A2](#a2-可串行化与快照读))。
在数据行快照不可使用的情况下,需要沿着 Undo Log 的回滚指针 ROLL_PTR 找到下一个快照,再进行上面的判断。
在数据行快照不可使用的情况下,需要沿着 undo log 的回滚指针 ROLL_PTR 找到下一个快照,再进行上面的判断。
接着上面的例子,假设事务 103 修改了 x 但还没有提交,另一个事务这时开始读取这一行:
接着上面的例子,假设事务 103 修改了 x 但还没有提交,另一个事务这时开始读取这一行(系统中修改其它行的事务 104 已提交、105 未提交):
<div align="center"> <img src="pics/mvcc-readview.png" width="760px"/> </div><br>
事务 103 提交之后,这个可重复读的事务再读仍然是 'b',而新开始的提交读事务读到 'c'(见[附录 A2](#a2-可串行化与快照读))。
事务 103 提交之后,两种隔离级别的结果就不同了(MySQL 8.0 实测,见[附录 A2](#a2-可串行化与快照读)):
<div align="center"> <img src="pics/mvcc-rc-vs-rr.png" width="780px"/> </div><br>
### 快照读与当前读
#### 1. 快照读
MVCC 的 SELECT 操作是快照中的数据,不需要进行加锁操作。
普通的 SELECT 读取的是 ReadView 可见的快照,不需要加锁。
```sql
SELECT * FROM table ...;
@@ -418,15 +434,11 @@ SELECT * FROM table ...;
#### 2. 当前读
MVCC 其它会对数据库进行修改的操作(INSERT、UPDATE、DELETE)需要进行加锁操作,从而读取最新的数据。可以看到 MVCC 并不是完全不用加锁,而只是避免了 SELECT 的加锁操作。
INSERT、UPDATE、DELETE 和加锁的 SELECT 读取的是最新版本,并且要加锁,这是因为修改必须基于最新的数据。例如可重复读的事务 A 读到余额 100,B 把它改为 70 并提交,A 再读仍是 100,但执行 SET balance = balance - 30 后得到的是 40,不会覆盖 B 的修改(MySQL 8.0 实测,见[附录 A4](#a4-快照读与当前读混用))。
```sql
INSERT;
UPDATE;
DELETE;
```
当前读等到锁之后,会按最新版本重新判断 WHERE 条件。例如 5 个事务同时执行 UPDATE t SET b = b + 1 WHERE b = 5,第一个拿到锁的把 b 改为 6,其余的等它提交后读到 6,不再满足条件,都影响 0 行(见[附录 A4](#a4-快照读与当前读混用))。
在进行 SELECT 操作时,可以强制指定进行加锁操作。以下第一个语句需要加 S 锁,第二个需要加 X 锁。MySQL 8.0 起推荐用 FOR SHARE 代替 LOCK IN SHARE MODE。
SELECT 也可以指定为当前读,以下第一个语句加 S 锁,第二个加 X 锁:
```sql
SELECT * FROM table WHERE ? FOR SHARE;
@@ -437,17 +449,11 @@ SELECT * FROM table WHERE ? FOR UPDATE;
Next-Key Locks 是 MySQL 的 InnoDB 存储引擎的一种锁实现。
在可重复读(REPEATABLE READ)隔离级别下,MVCC 使普通的 SELECT(快照读)不会读到其它事务新插入的行,但是 UPDATE、DELETE、SELECT ... FOR UPDATE 等当前读仍然可能出现幻读,Next-Key Locks 就是为了解决这个问题而存在的。InnoDB 使用 MVCC + Next-Key Locks 在可重复读隔离级别下解决了幻读问题。
在可重复读(REPEATABLE READ)隔离级别下,MVCC 使普通的 SELECT(快照读)不会读到其它事务新插入的行,但是 UPDATE、DELETE、SELECT ... FOR UPDATE 等当前读仍然可能出现幻读,Next-Key Locks 锁住查询范围内的间隙,阻止其它事务在范围内插入,从而解决这个问题。
需要注意的是,这要求一个事务中的读取全部是快照读或者全部是当前读,两者混用时仍然会出现幻读。例如表 acct 原有 id 为 1、2 的两行(MySQL 8.0 实测,见[附录 A4](#a4-快照读与当前读混用)):
需要注意的是,这要求一个事务中的读取全部是快照读或者全部是当前读,两者混用时仍然会出现幻读。例如表 acct 原有 id 为 1、2 的两行,balance 都是 100(MySQL 8.0 实测,见[附录 A4](#a4-快照读与当前读混用)):
```text
A:SELECT * FROM acct WHERE balance >= 100; -- id 1、2
B:INSERT INTO acct VALUES (3, 100); -- 自动提交
A:SELECT * FROM acct WHERE balance >= 100; -- 仍然是 id 1、2
A:UPDATE acct SET balance = balance + 10 WHERE balance >= 100; -- Rows matched: 3
A:SELECT * FROM acct WHERE balance >= 100; -- id 1、2、3
```
<div align="center"> <img src="pics/txn-phantom-mixed-read.png" width="780px"/> </div><br>
事务 A 的 UPDATE 是当前读,看到了事务 B 新插入并已提交的第 3 行,并且修改了它;这一行最新版本的事务 ID 变成了 A 自己,所以之后的快照读也能看到它。如果 A 一开始就用 SELECT ... FOR UPDATE 加锁读取,B 的插入会被 Next-Key Locks 阻塞,直到 A 结束或者锁等待超时。
@@ -455,7 +461,9 @@ A:SELECT * FROM acct WHERE balance >= 100; -- id 1、2、3
锁定一个记录上的索引,而不是记录本身。
如果表没有定义主键,InnoDB 会选第一个所有列都 NOT NULL 的唯一索引作为聚簇索引;连这样的索引也没有时,就在隐藏的 6 字节行 ID 上创建聚簇索引(GEN_CLUST_INDEX),因此 Record Locks 依然可以使用。
通过二级索引执行 FOR UPDATE、UPDATE 时,除了二级索引上的记录,回表找到的主键索引记录也会加记录锁,所以其它事务按主键修改这一行同样要等待(见[附录 A5](#a5-next-key-locks-的加锁范围) 中的 PRIMARY X,REC_NOT_GAP)。
没有定义主键的表也有聚簇索引(用非空唯一索引或隐藏的 6 字节行 ID,见 [B+Tree 索引](MySQL.md#1-btree-索引)),因此 Record Locks 依然可以使用。
### Gap Locks
@@ -465,11 +473,13 @@ A:SELECT * FROM acct WHERE balance >= 100; -- id 1、2、3
SELECT c FROM t WHERE c BETWEEN 10 and 20 FOR UPDATE;
```
插入时 InnoDB 要先在插入位置所在的间隙上加插入意向锁(Insert Intention Lock),它和其它事务持有的间隙锁冲突,所以插入要等待;插入意向锁之间不冲突,多个事务在同一个间隙中插入不同的值互不阻塞。
### Next-Key Locks
它是 Record Locks 和 Gap Locks 的结合,不仅锁定一个记录上的索引,也锁定索引之间的间隙。它锁定一个前开后闭区间,例如一个索引包含以下值:10, 11, 13, and 20,那么就需要锁定以下区间:
它是 Record Locks 和 Gap Locks 的结合,不仅锁定一个记录上的索引,也锁定索引之间的间隙。它锁定一个前开后闭区间,例如一个索引包含 10、11、13、20 这几个值,这个索引上可能的 Next-Key Lock 有以下几个区间:
```sql
```text
(-∞, 10]
(10, 11]
(11, 13]
@@ -477,11 +487,13 @@ SELECT c FROM t WHERE c BETWEEN 10 and 20 FOR UPDATE;
(20, +∞)
```
最后一个区间锁在 supremum 伪记录上,实际只锁住 20 之后的间隙。
以表 t(id, c) 为例,c 上有普通索引,有 c 为 10、11、13、20 的四行。在可重复读下执行两种加锁读,实际加的锁和对插入的影响如下(从 performance_schema.data_locks 中查看,见[附录 A5](#a5-next-key-locks-的加锁范围)):
<div align="center"> <img src="pics/next-key-locks-v2.png" width="760px"/> </div><br>
按 c = 11 查询时,c 不是唯一索引,后面还可能有 c = 11 的记录,所以除了 (10, 11] 的 Next-Key Lock,还要锁住到下一条记录 13 之间的间隙;如果 c 是唯一索引,就只锁 c = 11 这一条记录。
按 c = 11 查询时,c 不是唯一索引,后面还可能有 c = 11 的记录,所以除了 (10, 11] 的 Next-Key Lock,还要锁住到下一条记录 13 之间的间隙;如果 c 是唯一索引并且 c = 11 这条记录存在,就只锁这一条记录;查询不存在的 c = 12 时,仍会在 (11, 13) 上加间隙锁,阻止插入 12。
当前读的条件列没有索引时,InnoDB 只能扫描整个聚簇索引,扫描过的每一行都要加锁。例如表 t_nl 有 5 行,d 列没有索引,执行 UPDATE t_nl SET d = d + 1 WHERE d = 30 只修改 1 行:在可重复读下锁住了全部 5 行和最后的间隙,其它事务插入新行、修改 id 为 5 的行都要等待,效果相当于锁住了整张表;在提交读下只锁住 1 行,插入和修改都不受影响(MySQL 8.0 实测,见[附录 A6](#a6-没有索引时的加锁))。所以 UPDATE、DELETE 的条件列要建索引。
@@ -510,16 +522,16 @@ SELECT c FROM t WHERE c BETWEEN 10 and 20 FOR UPDATE;
不符合范式的关系,会产生很多异常,主要有以下四种异常:
- 冗余数据:例如 `学生-2` 出现了两次。
- 修改异常:修改了一个记录中的信息,但是另一个记录中相同的信息却没有被修改。
- 删除异常:删除一个信息,那么也会丢失其它信息。例如删除了 `课程-1` 需要删除第一行和第三行,那么 `学生-1` 的信息就会丢失。
- 冗余数据:例如 `学生-2` 出现了两次;
- 修改异常:修改了一个记录中的信息,但是另一个记录中相同的信息却没有被修改,例如学院-2 换了院长,只改了第二行;
- 删除异常:删除一个信息,那么也会丢失其它信息,例如删除了 `课程-1` 需要删除第一行和第三行,那么 `学生-1` 的信息就会丢失;
- 插入异常:例如想要插入一个学生的信息,如果这个学生还没选课,那么就无法插入。
### 范式 🔥
范式理论是为了解决以上提到四种异常。
范式理论是为了解决以上提到的四种异常。
高级别范式的依赖于低级别的范式,1NF 是最低级别的范式。
高级别的范式以满足低级别的范式为前提,1NF 是最低级别的范式。
#### 1. 第一范式 (1NF)
@@ -603,6 +615,8 @@ Sname, Sdept 和 Mname 都部分依赖于键码,当一个学生选修了多门
| 学院-1 | 院长-1 |
| 学院-2 | 院长-2 |
范式减少了冗余,代价是查询时要连接更多的表。实际设计中常常有意保留冗余,称为反范式。例如在文章表中保存评论数,查询时不必每次 COUNT,但新增和删除评论时要同时更新它,否则就会出现上面的修改异常。
## 八、ER 图
实体-联系图(Entity-Relationship Diagram)用来进行关系型数据库的概念设计,有三个组成部分:实体(矩形)、属性(椭圆)、联系(菱形)。
@@ -619,7 +633,7 @@ Sname, Sdept 和 Mname 都部分依赖于键码,当一个学生选修了多门
### 表示出现多次的关系
一个实体在联系出现几次,就要用几条线连接。
一个实体在联系中出现几次,就要用几条线连接。
下图表示课程的先修关系,联系中出现两个 Course 实体,一个是先修课程,一个是后续课程,因此需要用两条线来表示。
@@ -660,6 +674,7 @@ INSERT INTO account VALUES (1, 100), (2, 100);
B: UPDATE account SET balance = 150 WHERE id = 1; COMMIT
A: SELECT balance FROM account WHERE id = 1 -> 150
改为 balance = balance - 30 / + 50:B 的 UPDATE 等待 A 的行锁,最终 -> 120
改为先 SELECT ... FOR UPDATE 再写回:B 的 SELECT 等待 A 提交(1.07 s),读到 70,写回 120,最终 -> 120
脏读(B 为 READ UNCOMMITTED)
A: BEGIN; UPDATE account SET balance = 200 WHERE id = 1
@@ -683,6 +698,7 @@ INSERT INTO account VALUES (1, 100), (2, 100);
```text
A 为 REPEATABLE READ:B 的 UPDATE 立即成功
A 为 SERIALIZABLE: B 的 UPDATE 报错 ERROR 1205 Lock wait timeout exceeded
A 为 SERIALIZABLE,但不开始事务,只执行一条自动提交的 SELECT:B 的 UPDATE 立即成功
```
ReadView:表 t 中插入 x = 'a' 并更新为 'b'(都自动提交),会话 C 执行 BEGIN; UPDATE t SET x = 'c' 但不提交,此时会话 R 以 REPEATABLE READ 开始事务读取:
@@ -695,6 +711,20 @@ R: SELECT x FROM t WHERE id = 1 -> b
新的 READ COMMITTED 事务读取 -> c
```
另一次运行中,R 和一个 READ COMMITTED 事务 Q 都在 C 提交前开始并读取一次,C 提交后再各读一次:
```text
R(REPEATABLE READ):b,b
Q(READ COMMITTED): b,c
```
ReadView 的创建时机:A 以 REPEATABLE READ 开始事务后,B 把 account 中 id = 1 的 balance 从 100 改为 200 并自动提交,A 再第一次读取:
```text
A 用 BEGIN 开始 -> 200
A 用 START TRANSACTION WITH CONSISTENT SNAPSHOT 开始 -> 100
```
### A3 事务 ID 的分配
```text
@@ -702,6 +732,13 @@ BEGIN; SELECT * FROM account; innodb_trx.trx_id = 281479674465408
UPDATE account SET balance = 1; innodb_trx.trx_id = 3095
```
另开新事务,BEGIN 后只执行一条加锁读:
```text
SELECT * FROM account WHERE id = 1 FOR SHARE trx_id = 281479674466992
SELECT * FROM account WHERE id = 1 FOR UPDATE trx_id = 1725476
```
### A4 快照读与当前读混用
表 acct 原有 id 为 1、2 的两行,balance 都是 100。A 为 REPEATABLE READ:
@@ -716,6 +753,26 @@ A: SELECT * FROM acct WHERE balance >= 100 -> 1, 2, 3(ba
A 先执行 SELECT ... FROM acct WHERE balance >= 100 FOR UPDATE 时,B 的 INSERT 在 innodb_lock_wait_timeout = 1 下报错 ERROR 1205。
当前读基于最新版本计算:表 account 中 id = 1 的 balance 为 100,A 为 REPEATABLE READ:
```text
A: BEGIN; SELECT balance FROM account WHERE id = 1 -> 100
B: UPDATE account SET balance = 70 WHERE id = 1 (自动提交)
A: SELECT balance FROM account WHERE id = 1 -> 100
A: UPDATE account SET balance = balance - 30 WHERE id = 1
A: SELECT balance FROM account WHERE id = 1 -> 40
A: COMMIT 后 -> 40
```
当前读重新判断条件:表 t 中 id = 1 的 b 为 5,5 个 REPEATABLE READ 事务依次开始,各自先快照读一次,再执行 UPDATE t SET b = b + 1 WHERE b = 5,0.5 秒后提交。b 上有没有索引,都只有一个事务影响 1 行,最终 b = 6,下面是没有索引时的输出:
```text
事务 1: 快照读 b = 5,UPDATE 影响 1 行,之后本事务读到 b = 6
事务 2: 快照读 b = 5,等待前一个事务提交后 UPDATE 影响 0 行,之后本事务读到 b = 5
事务 3~5: 同事务 2
最终 b = 6
```
### A5 Next-Key Locks 的加锁范围
```sql
@@ -738,6 +795,7 @@ SELECT * FROM t WHERE c BETWEEN 10 AND 20 FOR UPDATE
插入 c = 9、12、14、21 都阻塞
把 idx_c 改为唯一索引后,c = 11 FOR UPDATE 只有 uk_c X,REC_NOT_GAP 11, 2 和主键上的记录锁,插入 c = 12 成功。
查询不存在的 c = 12 FOR UPDATE 时,行锁只有 uk_c X,GAP 13, 3,插入 c = 12 阻塞。
```
### A6 没有索引时的加锁
@@ -801,11 +859,57 @@ R(旧的 ReadView) v = 0 26.40 ms
R 提交 3 秒后 History list length 为 0
```
### A9 写偏差与可串行化
```sql
CREATE TABLE grp_admin (uid INT PRIMARY KEY, is_admin INT);
INSERT INTO grp_admin VALUES (1001, 1), (1002, 1);
```
A、B 使用相同的隔离级别,先后 BEGIN 并执行 SELECT COUNT(*) FROM grp_admin WHERE is_admin = 1;然后 A 执行 UPDATE grp_admin SET is_admin = 0 WHERE uid = 1001,0.5 秒后 B 对 1002 执行同样的 UPDATE,各自在 UPDATE 完成后 COMMIT。可串行化下 A 的 UPDATE 被 B 读取时加的共享锁阻塞,B 的 UPDATE 又等待 A 的共享锁,InnoDB 检测到死锁后回滚 B,A 的 UPDATE 随即完成:
```text
REPEATABLE READ:A、B 都查到 2 人;A 提交成功,B 提交成功;最终管理员 0 人
SERIALIZABLE: A、B 都查到 2 人;A 提交成功,B 报错 ERROR 1213 Deadlock found;最终管理员 1 人
```
可重复读下改为 SELECT COUNT(*) FROM grp_admin WHERE is_admin = 1 FOR UPDATE,查到 2 人才退出:
```text
1001:等待 0.00 s,查到 2 人,退出
1002:等待 0.42 s(等 1001 提交),查到 1 人,拒绝退出
最终管理员 1 人
```
### A10 语句出错后的提交
```sql
CREATE TABLE account (id INT PRIMARY KEY, balance INT NOT NULL, CHECK (balance >= 0));
INSERT INTO account VALUES (1, 100), (2, 0);
START TRANSACTION;
UPDATE account SET balance = balance + 200 WHERE id = 2;
UPDATE account SET balance = balance - 200 WHERE id = 1; -- 出错
COMMIT;
SELECT * FROM account;
```
第二条 UPDATE 报错后事务没有结束,COMMIT 提交了第一条 UPDATE:
```text
ERROR 3819 (HY000): Check constraint 'account_chk_1' is violated.
+----+---------+
| id | balance |
+----+---------+
| 1 | 100 |
| 2 | 200 |
+----+---------+
```
## 参考资料
- AbrahamSilberschatz, HenryF.Korth, S.Sudarshan, 等. 数据库系统概念 [M]. 机械工业出版社, 2006.
- 施瓦茨. 高性能 MYSQL(第3版)[M]. 电子工业出版社, 2013.
- 史嘉权. 数据库系统概论[M]. 清华大学出版社有限公司, 2006.
- Abraham Silberschatz, Henry F. Korth, S. Sudarshan, 等. 数据库系统概念 [M]. 机械工业出版社, 2006.
- 施瓦茨. 高性能 MySQL(第 3 版) [M]. 电子工业出版社, 2013.
- 史嘉权. 数据库系统概论 [M]. 清华大学出版社有限公司, 2006.
- [The InnoDB Storage Engine](https://dev.mysql.com/doc/refman/5.7/en/innodb-storage-engine.html)
- [MySQL 8.0: Transaction Isolation Levels](https://dev.mysql.com/doc/refman/8.0/en/innodb-transaction-isolation-levels.html)
- [MySQL 8.0: InnoDB Locking](https://dev.mysql.com/doc/refman/8.0/en/innodb-locking.html)
@@ -824,5 +928,6 @@ R 提交 3 秒后 History list length 为 0
- [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: InnoDB Error Handling](https://dev.mysql.com/doc/refman/8.0/en/innodb-error-handling.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)