前面的文章已经从存储一路走到了 WAL:
Page → Buffer Pool → WAL → Crash Recovery
到这里,数据库已经能够回答一个重要问题:
“如果数据库突然崩溃,已经提交的数据怎么恢复?”
但还有一个更复杂的问题:
如果很多用户、线程、事务同时修改数据库,数据库怎么保证它们互不干扰?
这就是这次要进入的主题:
Transaction(事务)与并发控制。
一、为什么数据库需要事务?
先看一个最简单的场景。
假设数据库中有:
A = 100
B = 200
现在执行一次转账:
A -= 50
B += 50
理想结果:
A = 50
B = 250
但数据库真正执行时,并不是一个 CPU 指令完成的。
可能是:
读取 A
↓
修改 A
↓
写回 A
读取 B
↓
修改 B
↓
写回 B
如果执行到这里:
A = 50
B = 200
机器突然崩溃怎么办?
数据库恢复以后不能留下这种“只完成了一半”的状态。
因此数据库需要把一组操作看成一个整体:
BEGIN
修改 A
修改 B
COMMIT
要么:
A、B 都修改成功
要么:
A、B 都不应该表现为这次事务已经生效
这就是事务最基本的思想:
把多个数据库操作组织成一个具有一致语义的执行单元。
二、事务最经典的 ACID
数据库事务经常用 ACID 描述。
| 特性 | 含义 |
|---|---|
| Atomicity | 原子性 |
| Consistency | 一致性 |
| Isolation | 隔离性 |
| Durability | 持久性 |
可以把它们理解成四个不同的问题。
Atomicity:要么全做,要么不做
例如:
UPDATE A
UPDATE B
不能最终只看到:
A 修改了
B 没修改
Consistency:事务不能破坏数据库定义的约束
例如:
账户余额 >= 0
事务执行前满足约束:
余额 = 100
事务正常提交以后,也应该满足:
余额 = 50
这里的 Consistency 很重要:
它并不是某个单独的存储组件实现的,而是数据库约束、事务机制、并发控制等共同保证的结果。
Isolation:并发事务之间应该如何互相看到?
假设:
Transaction A
Transaction B
同时修改同一个数据。
A 修改了一半的时候:
A:修改了数据
B:能不能看到?
如果 B 也修改:
A:读取
B:修改
A:再次读取
A 两次读取是否必须得到相同结果?
这些问题就是:
事务隔离(Transaction Isolation)。
Durability:提交成功的数据不能因为崩溃消失
这就是前面的 WAL。
Transaction
↓
COMMIT
↓
WAL 持久化
↓
数据库确认提交
所以可以看到:
ACID
Atomicity → 事务执行机制
Consistency → 约束 + 事务 + 并发控制
Isolation → 锁 / MVCC 等
Durability → WAL / 持久化 / 恢复
它们不是四个完全独立的模块,而是数据库内核不同机制共同形成的事务语义。
三、真正困难的是:多个事务同时运行
假设两个事务同时操作:
Transaction A Transaction B
读取 X
读取 X
X = X + 100
X = X - 50
写回 X
写回 X
假设初始:
X = 1000
理论上:
1000 + 100 - 50 = 1050
但如果两个事务都先读取:
A 读取 X = 1000
B 读取 X = 1000
然后:
A 写入 1100
B 写入 950
最终可能变成:
X = 950
A 的修改被 B 覆盖了。
这就是经典的:
Lost Update(丢失更新)
所以事务并不是简单加一个:
BEGIN();
...
COMMIT();
就结束了。
数据库还必须解决:
多个事务同时执行时,哪些操作可以并行,哪些操作必须互斥?
四、并发执行会产生哪些问题?
数据库理论中有几个非常经典的问题。
1. Dirty Read:脏读
事务 A 修改:
X = 100
↓
X = 200
但是 A 还没有提交。
此时 B 读取:
B 看到 X = 200
然后 A:
ROLLBACK
X 又变回:
X = 100
那么 B 刚才读取到的 200 就是:
Dirty Data
因此叫:
Dirty Read。
2. Non-Repeatable Read:不可重复读
事务 A:
SELECT X
→ 100
然后事务 B:
UPDATE X = 200
COMMIT
A 再次:
SELECT X
→ 200
同一个事务中:
第一次读取:100
第二次读取:200
这就是:
Non-Repeatable Read
3. Phantom Read:幻读
这个问题比前两个更有意思。
事务 A:
SELECT * FROM users
WHERE age > 20;
得到:
100 条
与此同时事务 B:
INSERT INTO users ...
而新数据满足:
age > 20
B 提交。
A 再执行:
SELECT * FROM users
WHERE age > 20;
结果:
101 条
并不是原来的某一行发生了变化,而是:
查询范围内突然多出了一条符合条件的数据。
这就是 Phantom Read。
五、于是数据库引入了 Isolation Level
数据库不能简单地说:
“所有事务完全隔离。”
因为如果真的做到完全串行:
Transaction A
↓
完成
Transaction B
↓
完成
Transaction C
↓
完成
并发能力就基本消失了。
因此数据库提供不同程度的隔离。
经典 SQL 标准中的隔离级别是:
Read Uncommitted
↓
Read Committed
↓
Repeatable Read
↓
Serializable
可以粗略理解为:
| 隔离级别 | 允许看到未提交数据 | 同一事务重复读取可能变化 | 范围查询出现新行 |
|---|---|---|---|
| Read Uncommitted | 可以 | 可以 | 可以 |
| Read Committed | 不应该 | 可以 | 可以 |
| Repeatable Read | 不应该 | 不应该出现普通意义上的变化 | 具体语义依实现而异 |
| Serializable | 不应该 | 不应该 | 不应该产生非串行化结果 |
这里有一个非常重要的认识:
Isolation Level 不是简单的“性能档位”,而是在规定并发事务能够观察到什么样的数据库状态。
而且具体数据库的实现和细节并不完全一样。
六、数据库怎么实现隔离?
核心有两大类思路:
并发控制
│
┌────────┴────────┐
↓ ↓
Lock MVCC
锁机制 多版本并发控制
现实中的数据库通常并不是只使用其中一种。
例如:
锁 + MVCC + WAL + Buffer Pool
共同完成事务系统。
七、第一种思路:Lock
最直观的方式就是:
我要修改这个数据,别人先别动。
例如:
Transaction A
Lock(X)
↓
修改 X
↓
Unlock(X)
B 如果也需要修改 X:
Transaction B
│
↓
Lock(X)
│
↓
等待……
等 A:
Unlock(X)
以后:
B
↓
获得 Lock(X)
↓
继续执行
Shared Lock 和 Exclusive Lock
数据库里经常会看到:
Shared Lock(S Lock)
多个事务可以同时读取:
A ── S Lock(X)
B ── S Lock(X)
X
可以:
A 读
B 读
C 读
Exclusive Lock(X Lock)
修改数据通常需要排他锁:
A ── X Lock(X)
此时:
B 不能读/写
C 不能读/写
具体“能不能读”还取决于数据库的并发控制实现和读操作类型,但核心思想是:
写操作需要对并发访问建立更强的互斥关系。
八、锁虽然简单,但出现了一个新问题:等待
假设:
A 持有 X
B 持有 Y
然后:
A 想要 Y
B 想要 X
于是:
A
│
└── 等待 Y
B
│
└── 等待 X
形成:
A → B
↑ ↓
└───┘
这就是:
Deadlock(死锁)
所以数据库并发控制又出现了新的问题:
怎么检测死锁?
检测到以后谁回滚?
如何避免大量事务互相等待?
这会是后面专门讨论锁机制时的重要内容。
九、于是出现了另一个重要思想:MVCC
如果每次读取都和写事务争抢锁,那么:
大量 Reader
↓
X
↑
Writer
读写之间可能产生大量等待。
数据库于是发展出了一个非常重要的思想:
不要让所有事务都只能看到同一个“当前版本”,而是保存数据的多个版本。
这就是:
MVCC:Multi-Version Concurrency Control
多版本并发控制。
一个非常简化的例子
假设 X 最开始:
X = 100
事务 A 把它修改成:
X = 200
数据库不一定简单地把:
100
直接覆盖掉。
概念上可以存在:
X
Version 1
value = 100
Version 2
value = 200
于是:
Transaction A
↓
看到 Version 2
Transaction B
↓
可能仍然看到 Version 1
关键问题就从:
“这个数据现在是多少?”
变成了:
“对于当前这个事务,我应该看到哪个版本?”
这就是 MVCC 最核心的思想。
十、MVCC 的核心:Visibility
MVCC 并不是简单地:
一个数据保存很多份
真正困难的是:
数据库如何判断一个版本对当前事务是否可见?
可以抽象成:
Row
│
┌───────┴───────┐
↓ ↓
Version 1 Version 2
X = 100 X = 200
│ │
└──────┬────────┘
↓
Transaction Snapshot
│
↓
判断哪个可见
所以 MVCC 的核心实际上是:
Version + Transaction Metadata + Visibility Rule
而不仅仅是“保存旧数据”。
十一、PostgreSQL 和 InnoDB 怎么做?
这里就可以看到:
同样是 MVCC,不同数据库的内部实现并不一样。
PostgreSQL
PostgreSQL 的表数据本身会包含不同版本的 tuple。
概念上:
Heap
Tuple A
↓
旧版本
Tuple A
↓
新版本
tuple 中的事务可见性信息参与判断:
xmin
xmax
...
当旧版本已经不再被任何事务需要以后,PostgreSQL 可以通过 VACUUM 等机制回收空间。
因此 PostgreSQL 的 MVCC 一个非常鲜明的特点是:
旧版本直接存在于 Heap 中。
InnoDB
InnoDB 的思路有所不同。
当前记录位于聚簇索引中,而历史版本主要通过:
Undo Log
保存。
概念上:
Clustered Index
│
↓
Current Version
│
↓
Undo
│
↓
Older Version
│
↓
Older Version
当事务需要读取旧版本时,可以沿着历史信息构造出它应该看到的版本。
所以可以形成一个非常重要的对比:
| PostgreSQL | InnoDB | |
|---|---|---|
| 当前数据 | Heap | Clustered Index |
| 历史版本 | Heap 中的旧 Tuple | Undo 中的历史信息 |
| 回收 | VACUUM 等 | Purge 等机制 |
| MVCC 核心 | Tuple 可见性 | Undo + Read View 等 |
这里只是为了理解架构,实际实现远比这个模型复杂。
十二、MVCC 和 WAL 是什么关系?
这也是前面和这次最应该串起来的地方。
你可以把几个核心机制分别看成:
Database Kernel
│
┌─────────────┼─────────────┐
↓ ↓ ↓
Buffer Pool WAL MVCC
│ │ │
数据放内存 保证恢复 控制可见性
│ │ │
↓ ↓ ↓
Page管理 Crash Recovery 并发事务
它们解决的是不同问题。
Buffer Pool
数据页现在放在哪里?
WAL
如果崩溃了,怎么恢复?
Lock / MVCC
多个事务同时运行时,谁能看到谁的修改?
所以:
事务
│
├── 修改数据
│ ↓
│ Buffer Pool
│
├── 产生 WAL
│ ↓
│ Durability
│
└── 建立可见性
↓
MVCC / Lock
这几个机制最终会组合成完整的事务系统。
十三、从一次 UPDATE 看数据库内部到底发生了什么
现在把前面几天的内容全部串起来。
执行:
UPDATE users
SET age = 20
WHERE id = 100;
表面上只有一句 SQL。
数据库内部却可能经历:
SQL
│
↓
Executor
│
↓
找到目标行
│
↓
Transaction Context
│
┌───────┴───────┐
↓ ↓
Lock/MVCC WAL Record
│ │
↓ ↓
修改 Buffer Pool WAL Buffer
│ │
│ ↓
│ WAL 持久化
│
↓
Dirty Page
│
↓
以后再写回 Data Page
注意这里有一个非常重要的顺序关系:
事务提交时,WAL 的持久化可以先于真正的数据 Page 落盘。
所以:
WAL Disk
↓
已经记录修改
Data Page
↓
可能还在 Buffer Pool
数据库仍然可以告诉客户端:
COMMIT 成功
因为如果之后崩溃:
WAL
↓
Recovery
↓
重新恢复 Data Page
十四、现在回头看:为什么数据库内核这么复杂?
因为数据库实际上同时面对四个世界:
Database
│
┌───────────────┼───────────────┐
↓ ↓ ↓
CPU Memory Disk
│ │ │
│ Buffer Pool │
│ │ │
└───────────────┼───────────────┘
↓
Transaction
│
┌────────┴────────┐
↓ ↓
WAL MVCC
│ │
Durability Visibility
│ │
└────────┬────────┘
↓
Crash + Concurrency
所以数据库不是简单地:
SQL → 查数据 → 返回结果
真正的数据库内核需要同时处理:
数据在哪里?
如何快速找到?
如何缓存?
如何修改?
如何持久化?
崩溃怎么办?
多个事务怎么办?
谁能看到什么?
旧版本什么时候删除?
空间什么时候回收?
这些问题最终交织在一起。
小结
可以先把几个概念建立成一张地图:
Transaction
│
├── Atomicity
│
├── Consistency
│
├── Isolation
│ │
│ ├── Lock
│ │
│ └── MVCC
│
└── Durability
│
└── WAL
而前面学到的内容:
B+Tree
↓
找到数据
Page
↓
组织数据
Buffer Pool
↓
把 Page 放进内存
WAL
↓
保证崩溃恢复
Transaction
↓
组织并发修改
Lock / MVCC
↓
控制事务之间的可见性
到这里,已经从“数据库如何存数据”逐渐进入“数据库如何让很多人同时安全地修改数据”。