数据库事务

数据库事务的相关概念以及处理

事务的概念

在数据系统的使用过程中,任何问题都可能发生:

等等。这些问题大大增加了应用端的编程复杂度,因此在数据系统一侧抽象出事务的概念,将这些复杂性封装到事务内部处理,对外保证一个安全的使用环境,简化应用侧的编程模型。

简单来说,事务就是将一组语句(或者说操作)打包为一个逻辑单元进行执行,并提供保证,这一组操作要么全部成功(commit,变更应用到数据库)或者全部失败(被动abort或主动rollback),而不会存在中间状态。此外,如果多个客户端并发执行,会涉及到事务隔离的问题,一般来说,数据库允许用户在隔离级别和性能之间做选择。

有了事务在语义上的保证,用户就可以在事务失败后放心地进行重试,直到成功。但任何便利性都有代价,事务就是在一定程度上牺牲了性能和可用性。

注:这里提到的事务失败后重试,是有局限性的。实际过程中重试可能会引发其他问题,需要特别注意:

  1. 如果事务已经成功,只是在确认过程中出现了网络故障导致应用端以为事务失败了,这时触发重试可能会导致事务重复执行,除非有应用级别的去重机制;
  2. 如果错误是由于数据库负载过大引起的,重试只会让问题变得更糟。这种情况下应该限制重试次数,使用指数退避算法,或单独处理负载相关的错误;
  3. 在临时性的错误上重试是有意义的,例如网络异常、死锁等,如果发生永久性错误比如违反数据库约束,重试没有任何意义;
  4. 如果事务在数据库之外有副作用,比如发送邮件等操作,即使事务失败,该副作用也有可能发生,这时重试有可能多次触发该副作用;

事务的ACID属性

原子性 Atomicity

原子性保证了当事务发生错误终止时,将当前事务的所有写入都丢弃,数据库回滚到事务开始前的状态。

一致性 Consistency

一致性指的是,从任意一个时间点查看数据,数据都需要满足业务规定的约束,比如会计系统要求所有账户整理借贷相抵。 但一致性的要求不是数据库本身可以保证的,数据库无法阻止客户端写入不符合约束的数据,因此该属性不应该是数据本身的属性,而应该是应用程序的属性。

隔离性 Isolation

多个客户端同时执行的事务,彼此互不影响,相互隔离,每个事务都认为自己是系统中唯一正在执行的事务,表现出来就是所有事务都依次串行执行。但这么强的隔离性会大大降低数据库的吞吐量,因此实际使用中很少采用,数据库提供了串行化之外的几种弱隔离级别,根据数据库对事务的不同隔离级别,会有不同的隔离表现。

持久性 Durability

持久性保证一旦事务提交,事务写入的数据就永久应用到数据库中,不会发生丢失。这个保证是在数据库自身的能力范围内,如果对数据的安全性有更高的要求,需要采用额外的措施,如强制刷盘、异地复制、定时备份等,但也只能大概率保证数据不丢失(比如5个9),无法绝对保证。

事务的隔离级别

事务隔离级别的定义经历了几个发展阶段,这些不是本篇的主题,因此不再赘述,感兴趣的朋友可以阅读这里

前面提到,为了提高性能,数据库提供了几种弱隔离级别,这些隔离级别是通过可能遇到的并发问题(异相)来定义的。这里我们讨论一下每种异相的定义,以及阻止它的隔离级别,还有如何通过锁实现该隔离级别(也有其他实现方式,这里不展开)。

先给出用到的锁的定义。

脏写 Dirty Write

一个事务对数据进行写操作后,还没有提交,就被另一个事务对相同数据的写操作覆盖。(有资料称之为“第一类丢失更新”)

举例:(x 的初始值为0)

时序 事务A 事务B
T1 begin begin
T2 写x, x=1  
T3   写x, x=2
T4 commit  
T5   commit

这里事务A将x写为1之后,还没有提交,就被事务B覆盖了。

问题

脏写首先会导致事务无法回滚。假设事务A在T4要回滚,这时x已经被事务B写为2了,如果把x回滚为事务A修改之前的初始值0,则事务B的写入就丢失了;如果事务A事务B在T5也要回滚x,x在事务B修改前的值为1,由于事务A回滚,1这个值已经是脏数据,导致事务都没办法回滚,影响了事务的原子性。

另外,脏写还会影响数据一致性。

例如有两个值x和y,要求x和y始终相等,现在有两个事务同时修改x和y:

时序 事务A 事务B
T1 begin begin
T2 写x, x=2  
T3   写x, x=3
T4   写y, y=3
T5 写y, y=2  
T6 commit  
T7   commit

可以看到,最终x=3,y=2,破坏了一致性。

隔离级别

由于脏写破坏了事务的原子性,所以不管什么隔离级别都必须阻止这个问题,因此可以认为最弱的“读未提交RU”需要阻止脏写。

实现

为了阻止脏写,事务需要给写操作加长锁,防止其他事务同时修改。

脏读 Dirty Read

一个事务对数据进行写操作后,还没有提交,就被另一个事务读取到。

举例:假设x的初始值为100

时间 事务A 事务B
T1 begin begin
T2 写x,x=200  
T3   读x,x=200
T4 rollback

可以看到由于事务A回滚,事务B读取到的x=200变成了脏数据。

问题

脏读会影响数据一致性。

例如x=50,y=50,x给y转账40,要求转账前后都满足 x+y=100。

时间 事务A 事务B
T1 begin begin
T2 写x,x=10  
T3   读x,x=10
T4   读y,y=50
T5 写y,y=90 commit
T6 commit  

可以看到事务B读取到的x和y不满足x+y=100,一致性被破坏。

隔离级别

读已提交RR,可以阻止该异相。

实现

为了防止脏写,我们已经对写操作加了长锁,在此基础上,给读操作加上短锁即可避免脏读问题。 需要注意,这里还要给读操作加上短谓词锁,防止读操作在范围查询过程中读到了其他事务未提交的新增数据。

不可重复读 Fuzzy Read / Non-repeatable Read

一个事务多次读取数据的过程中,数据被其他事务修改提交。(因为RC级别读数据加的是短锁,在两个短锁的间隙中数据有可能被其他事务修改)

问题

例如:x初始值为1

时间 事务A 事务B
T1 begin begin
T2 读x,x=1  
T3   写x,x=2
T4   commit
T5 读x,x=2  
T6 commit  

可以看到事务A两次读取x的值不一致,破坏了一致性。

另外,即使没有重复读取某一个值,只要事务两次读取的值之间有一致性约束,就可能出现问题。

我们还以脏读中转账的例子说明,新的执行序列如下表:

时间 事务A 事务B
T1 begin begin
T2   读x,x=50
T3 写x,x=10  
T4 写y,y=90  
T5 commit  
T6   读y,y=90
T7   commit

可以看到,事务B读取的x和y不满足约束 x+y=100;

隔离级别

可重复读RR 阻止了事务修改其他事务正在读取的数据,即不可重复读这个异相。

实现

在RC级别中,我们已经给写操作加了长锁,给读操作加了短锁(对象锁+谓词锁),为了阻止不可重复读的问题,需要给读操作中的记录加上长锁,因此可重复读RR级别的锁实现就是读操作中的记录加长锁(S锁),谓词锁加短锁(S锁),写操作加长锁(X锁)。

幻读 Phantom Read

一个事务通过查询条件读取数据集,另一个事务的写操作改变了匹配该条件的数据集(可能是插入了新数据,或者删除了匹配条件的数据,也可能是通过更新操作让其他操作也匹配了该条件)。

问题

假如有如下执行序列:

r1[P] — w2[insert x in P] — c2 — r1[P] Means Transaction_1 reads data set in P, then Transaction_2 insert a record in P, then Transaction_1 reads data set in P again.

该序列会导致事务1两次读取数据集的结果不一致,破坏了一致性。

隔离级别

可串行化阻止了幻读异相的发生。

实现

在 RR 级别中,我们已经给读操作加了长记录锁和短谓词锁,为了防止幻读,需要将谓词锁也改为长锁,在读取某条件时,将该条件锁定,防止其他事务的写入影响该条件。

丢失更新 Lost Update

后面提到的三种异相,可能会比较陌生。

丢失更新是指一个事务的写入被另一个已提交的事务覆盖(也称为第二类丢失更新,对照脏写)。

问题

举例:两个事务修改计数器 c,让计数器加1。c 的初始值为1。

时间 事务A 事务B
T1 begin begin
T2 读c,c=1 读c,c=1
T3 写c,c=2  
T4 commit  
T5   写c,c=2
T6   commit

可以发现,事务B提交后,计数器的值为2,事务A的提交丢失了。

隔离级别

分析上面的例子,我们发现问题出现在事务B读取c之后,事务A对c进行了修改,上面的可重复读RR级别可以阻止该异相。可重复读在读取的记录上加长锁,可以阻止其他事务在此期间修改数据。在上面的例子中,由于对c加了长读锁,两个事务的写操作都会相互等待对方的读锁释放,形成死锁,如果有死锁检测机制,事务B会自动回滚,不会出现丢失更新的现象。

读偏序 Read Skew

原本多个数据存在一致性的约束,读取却违反了该约束。

问题

我们直接使用不可重复读的第二个例子,读偏序属于不可重复读的一种情况。

r1[x] — w2[x] — w2[y] — c2 — r1[y] — c1 事务1的两次读取见,事务2修改了数据,导致事务1读取到的数据不一致。

隔离级别

从上面的例子可以看出,读偏序属于不可重复读的一种,因此可重复读RR级别可以阻止该异相。

写偏序 Write Skew

写操作违反了一致性约束。

问题

第一个问题:假设x和y是一个人的两个信用卡账户,我们要求x + y不能小于0,而x或者y可以小于0,就是说你的一张信用卡可以是负的,但是全部加起来不能也是负的。

下面事务A和事务B是两次并发的扣款,x初始值为20,y初始值为20。

时间 事务A 事务B
T1 begin begin
T2 read,x=20,y=20 read,x=20,y=20
T3 账户总额40,扣款30  
T4 write,x=-10,y=20  
T5   账户总额40,扣款30
T6   write,x=20,y=-10
T7 commit  
T8   commit

我们发现两个事务提交后,x+y=-20,违反了约束。这个是严格意义上的写偏序,还有由于幻读产生的写偏序。

第二个问题:假设我们要做一个注册用户的功能,要求用户名唯一,并且没有给用户名加唯一索引,也就是说唯一性我们自己来保证。

用户表已有用户名a,b,c,两个用户同时注册用户名d。

时间 事务A 事务B
T1 begin begin
T2 读取所有用户:a,b,c 读取所有用户:a,b,c
T3 发现没有d,插入d  
T4 commit  
T5   发现没有d,插入d
T6   commit

两个事务提交之后,用户名d有了两个,违反了唯一性约束。

这个问题是由于幻读引起的。

隔离级别

上面的两个例子,第一个是严格意义上的写偏序,可以通过可重复读RR阻止(写操作会被读锁阻塞),第二个是幻读引起的,本质上属于幻读问题,只有可串行化级别可以阻止(上面的例子中,由于有谓词锁的存在,事务A的写操作会被阻塞)。

隔离级别汇总

从隔离级别可以阻止的异相角度:

Isolation Level P0
Dirty Write
P1
Drity Read
P2
Fuzzy Read
P3
Phantom Read
Lost Update Read Skew Write Skew  
None
ANSI READ UNCOMMITTED
ANSI READ COMMITTED
ANSI REPEATABLE READ
ANOMALY SERIALIZABLE

从锁的角度:

Isolation Level Read (Share Lock) Write (Mutex Lock)
None None None
ANSI READ UNCOMMITTED None 长锁
ANSI READ COMMITTED 记录锁加短锁
谓词锁加短锁
长锁
ANSI REPEATABLE READ 记录锁加长锁
谓词锁加短锁
长锁
ANOMALY SERIALIZABLE 记录锁加长锁
谓词锁加长锁
长锁

总结

本文整理了事务的相关概念,重点说明了事务的几种弱隔离级别以及基于锁的实现。频繁加锁势必会影响数据库的性能,因此很多数据库实际上使用的是多版本管理的方式,这就引出了另一种隔离级别:快照隔离,后面会单独介绍。

本文参考了: