事务到底解决了什么问题?

9 阅读11分钟

前面的文章已经从存储一路走到了 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

当事务需要读取旧版本时,可以沿着历史信息构造出它应该看到的版本。

所以可以形成一个非常重要的对比:

PostgreSQLInnoDB
当前数据HeapClustered Index
历史版本Heap 中的旧 TupleUndo 中的历史信息
回收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
   ↓
控制事务之间的可见性

到这里,已经从“数据库如何存数据”逐渐进入“数据库如何让很多人同时安全地修改数据”。