数据库事务的相关概念以及处理
在数据系统的使用过程中,任何问题都可能发生:
等等。这些问题大大增加了应用端的编程复杂度,因此在数据系统一侧抽象出事务的概念,将这些复杂性封装到事务内部处理,对外保证一个安全的使用环境,简化应用侧的编程模型。
简单来说,事务就是将一组语句(或者说操作)打包为一个逻辑单元进行执行,并提供保证,这一组操作要么全部成功(commit,变更应用到数据库)或者全部失败(被动abort或主动rollback),而不会存在中间状态。此外,如果多个客户端并发执行,会涉及到事务隔离的问题,一般来说,数据库允许用户在隔离级别和性能之间做选择。
有了事务在语义上的保证,用户就可以在事务失败后放心地进行重试,直到成功。但任何便利性都有代价,事务就是在一定程度上牺牲了性能和可用性。
注:这里提到的事务失败后重试,是有局限性的。实际过程中重试可能会引发其他问题,需要特别注意:
- 如果事务已经成功,只是在确认过程中出现了网络故障导致应用端以为事务失败了,这时触发重试可能会导致事务重复执行,除非有应用级别的去重机制;
- 如果错误是由于数据库负载过大引起的,重试只会让问题变得更糟。这种情况下应该限制重试次数,使用指数退避算法,或单独处理负载相关的错误;
- 在临时性的错误上重试是有意义的,例如网络异常、死锁等,如果发生永久性错误比如违反数据库约束,重试没有任何意义;
- 如果事务在数据库之外有副作用,比如发送邮件等操作,即使事务失败,该副作用也有可能发生,这时重试有可能多次触发该副作用;
原子性保证了当事务发生错误终止时,将当前事务的所有写入都丢弃,数据库回滚到事务开始前的状态。
一致性指的是,从任意一个时间点查看数据,数据都需要满足业务规定的约束,比如会计系统要求所有账户整理借贷相抵。 但一致性的要求不是数据库本身可以保证的,数据库无法阻止客户端写入不符合约束的数据,因此该属性不应该是数据本身的属性,而应该是应用程序的属性。
多个客户端同时执行的事务,彼此互不影响,相互隔离,每个事务都认为自己是系统中唯一正在执行的事务,表现出来就是所有事务都依次串行执行。但这么强的隔离性会大大降低数据库的吞吐量,因此实际使用中很少采用,数据库提供了串行化之外的几种弱隔离级别,根据数据库对事务的不同隔离级别,会有不同的隔离表现。
持久性保证一旦事务提交,事务写入的数据就永久应用到数据库中,不会发生丢失。这个保证是在数据库自身的能力范围内,如果对数据的安全性有更高的要求,需要采用额外的措施,如强制刷盘、异地复制、定时备份等,但也只能大概率保证数据不丢失(比如5个9),无法绝对保证。
事务隔离级别的定义经历了几个发展阶段,这些不是本篇的主题,因此不再赘述,感兴趣的朋友可以阅读这里。
前面提到,为了提高性能,数据库提供了几种弱隔离级别,这些隔离级别是通过可能遇到的并发问题(异相)来定义的。这里我们讨论一下每种异相的定义,以及阻止它的隔离级别,还有如何通过锁实现该隔离级别(也有其他实现方式,这里不展开)。
先给出用到的锁的定义。
select * from t where id > 10;
如果加谓词锁,就锁住了 id 从 10 到无穷大的范围,而不关注表里是否有对应的记录;一个事务对数据进行写操作后,还没有提交,就被另一个事务对相同数据的写操作覆盖。(有资料称之为“第一类丢失更新”)
举例:(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”需要阻止脏写。
为了阻止脏写,事务需要给写操作加长锁,防止其他事务同时修改。
一个事务对数据进行写操作后,还没有提交,就被另一个事务读取到。
举例:假设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,可以阻止该异相。
为了防止脏写,我们已经对写操作加了长锁,在此基础上,给读操作加上短锁即可避免脏读问题。 需要注意,这里还要给读操作加上短谓词锁,防止读操作在范围查询过程中读到了其他事务未提交的新增数据。
一个事务多次读取数据的过程中,数据被其他事务修改提交。(因为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锁)。
一个事务通过查询条件读取数据集,另一个事务的写操作改变了匹配该条件的数据集(可能是插入了新数据,或者删除了匹配条件的数据,也可能是通过更新操作让其他操作也匹配了该条件)。
假如有如下执行序列:
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 级别中,我们已经给读操作加了长记录锁和短谓词锁,为了防止幻读,需要将谓词锁也改为长锁,在读取某条件时,将该条件锁定,防止其他事务的写入影响该条件。
后面提到的三种异相,可能会比较陌生。
丢失更新是指一个事务的写入被另一个已提交的事务覆盖(也称为第二类丢失更新,对照脏写)。
举例:两个事务修改计数器 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会自动回滚,不会出现丢失更新的现象。
原本多个数据存在一致性的约束,读取却违反了该约束。
我们直接使用不可重复读的第二个例子,读偏序属于不可重复读的一种情况。
r1[x] — w2[x] — w2[y] — c2 — r1[y] — c1 事务1的两次读取见,事务2修改了数据,导致事务1读取到的数据不一致。
从上面的例子可以看出,读偏序属于不可重复读的一种,因此可重复读RR级别可以阻止该异相。
写操作违反了一致性约束。
第一个问题:假设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 | 记录锁加长锁 谓词锁加长锁 | 长锁 |
本文整理了事务的相关概念,重点说明了事务的几种弱隔离级别以及基于锁的实现。频繁加锁势必会影响数据库的性能,因此很多数据库实际上使用的是多版本管理的方式,这就引出了另一种隔离级别:快照隔离,后面会单独介绍。
本文参考了: