数据库的事务及隔离级别
在操作数据库时,经常需要将一系列操作打包成一个原子操作:要么全部执行,要么全部不执行。一个最常被用来举例子的场景是银行转账问题,假设有两个用户 A 和 B,A 要向 B 转账 100 元需要进行以下操作:
- 查询 A 的余额;
- 若余额大于 100,则减去 100,并写回数据库;
- B 的余额加上 100,并写回数据库;
这时候若第二步执行成功,但第三步失败了,会导致 A 平白无故损失 100 元,引发不一致的情况。因此需要进行特殊保护。
很多数据库提供了事务的概念。所谓「事务」,就是发送给数据库的一系列操作的集合。这些操作需满足 ACID 特性,即:
- A:原子性(Atomicity),事务作为一个整体被执行,包含在其中的对数据库的操作要么全部被执行,要么都不执行。
- C:一致性(Consistency),事务应确保数据库的状态从一个一致状态转变为另一个一致状态。一致状态的含义是数据库中的数据应满足完整性约束。
- I:隔离性(Isolation),多个事务并发执行时,一个事务的执行不应影响其他事务的执行。
- D:持久性(Durability),已被提交的事务对数据库的修改应该永久保存在数据库中。
以最常见的 MySQL 为例,假设有两张表:
用户表 user
| id | uid | uname |
|---|---|---|
| 1 | 1 | A |
| 2 | 2 | B |
| 3 | 3 | C |
余额表 money
| id | uid | money |
|---|---|---|
| 1 | 1 | 500 |
| 2 | 2 | 200 |
| 3 | 3 | 100 |
则对应的事务语句为:
BEGIN;
select money from money,user where money.uid = user.uid and uname = 'A';
#suppose Amoney is greater than 100
update money set money = Amoney-100 where uid = (select uid from user where uname = 'A');
select money from money,user where money.uid = user.uid and uname = 'B';
update money set money = Bmoney+100 where uid = (select uid from user where uname = 'B');
COMMIT;
事务的隔离级别
事务解决了原子性的问题。但如果用户 A 同时给 B 和 C 分别转了 100 元,来看看会有什么问题:
| 事务1 | 事务2 |
|---|---|
| BEGIN | BEGIN |
| select A: money==500 | |
| select A: money==500 | |
| update A: money==400 | |
| update A: money==400 | |
| select B: money==200 | |
| select C: money==100 | |
| update B: money==300 | |
| update C: money=200 | |
| COMMIT | COMMIT |
最后的结果是,B 和 C 的账户分别增加了 100 元,但 A 的账户只减少了 100 元,而不是 200!原因在于事务 1 写回 A 的余额之前,事务 2 已经读取了 A 的余额,导致事务 2 的 update 语句覆盖了事务 1 update 的结果。
因此,人们规定了四种隔离级别来在不同层次上防止这些情况:
未提交读(read uncommitted)
未提交读其实就是上面写到的这种情况:事务 2 读到了被事务 1 修改的数据,但事务 1 没有 COMMIT,而是 ROLLBACK,导致「脏读」。这是一种安全性最低的隔离级别,一般很少有这种实现。
已提交读(read committed)
已提交读可以预防「脏读」,其原理就是一个事务无法读到被另一个事务修改但没有提交的数据。如下:
| 事务1 | 事务2 |
|---|---|
| BEGIN | BEGIN |
| select A: money==500 | |
| update A: money==400 | |
| select B: money==200 | |
| update B: money==300 | |
| select A: money==500 | |
| COMMIT | |
| select A: money==400 | |
| update A: money==300 | |
| select C: money==100 | |
| update C: monty==200 | |
| COMMIT |
已提交读是大多数数据库默认的隔离级别(MySQL InnoDB 引擎除外),它可以解决「脏读」的问题,但也带来了「不可重复读」的问题:即两次对 A 的余额读取的结果不一样。
可重复读(repeatable read)
MySQL 的 InnoDB 引擎默认设置为此级别。其保证了数据在一个事务连续的两次读取中结果相同:
| 事务1 | 事务2 |
|---|---|
| BEGIN | BEGIN |
| select A: money==500 | |
| update A: money==400 | |
| select B: money==200 | |
| update B: money==300 | |
| delete A: A is nil | |
| select A: money==500 | |
| COMMIT | |
| select A: money==500 | |
| update A: Error! A is not exists! |
事务 2 在事务 1 COMMIT 前后去查询用户 A 的余额,都明确告知存在,且值为 500,但当事务 2 尝试更新用户 A 的余额时,会报错:用户 A 不存在!这就出现了「幻读」。
可串行化(serializable)
可串行化是最高的隔离级别,当然也是性能最低的。但它保证了数据的完全一致性,不会再出现「脏读」、「不可重复读」、「幻读」等现象。总结为下表:
| 隔离级别 | 未提交读 | 已提交读 | 可重复读 | 可串行化 |
|---|---|---|---|---|
| 脏读 | 是 | 否 | 否 | 否 |
| 不可重复读 | 是 | 是 | 否 | 否 |
| 幻读 | 是 | 是 | 是 | 否 |