在操作数据库时,经常需要将一系列操作打包成一个原子操作:要么全部执行,要么全部不执行。一个最常被用来举例子的场景是银行转账问题,假设有两个用户 A 和 B,A 要向 B 转账 100 元需要进行以下操作:
这时候若第二步执行成功,但第三步失败了,会导致 A 平白无故损失 100 元,引发不一致的情况。因此需要进行特殊保护。
很多数据库提供了事务的概念。所谓「事务」,就是发送给数据库的一系列操作的集合。这些操作需满足 ACID 特性,即:
以最常见的 MySQL 为例,假设有两张表:
id | uid | uname |
---|---|---|
1 | 1 | A |
2 | 2 | B |
3 | 3 | C |
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 的结果。
因此,人们规定了四种隔离级别来在不同层次上防止这些情况:
未提交读其实就是上面写到的这种情况:事务 2 读到了被事务 1 修改的数据,但事务 1 没有 COMMIT,而是 ROLLBACK,导致「脏读」。这是一种安全性最低的隔离级别,一般很少有这种实现。
已提交读可以预防「脏读」,其原理就是一个事务无法读到被另一个事务修改但没有提交的数据。如下:
事务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 的余额读取的结果不一样。
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 不存在!这就出现了「幻读」。
可串行化是最高的隔离级别,当然也是性能最低的。但它保证了数据的完全一致性,不会再出现「脏读」、「不可重复读」、「幻读」等现象。总结为下表:
隔离级别 | 未提交读 | 已提交读 | 可重复读 | 可串行化 |
---|---|---|---|---|
脏读 | 是 | 否 | 否 | 否 |
不可重复读 | 是 | 是 | 否 | 否 |
幻读 | 是 | 是 | 是 | 否 |