数据库事务正确执行的4个基本要素是:原子性(Atomicity)、一致性(Consistency)、隔离性(Isolation)、持久性(Durability)。
ACID特性
原子性 | 整个事务中的所有操作,要么全部完成,要么全部不完成,不会停滞在中间某一个环节,假若事务在执行过程中发生了错误,那么将会回滚到事务开始执行前的状态,这个事务就像没有被执行过一样。 |
一致性 | 一个事务可以改变封装的状态。事务必须始终保持系统处于一致的状态,不管任何给定的时间内并发的事务有多少。 |
隔离性 | 指两个事务之间的隔离程度(稍后会说到)。 |
持久性 | 在事务完成以后,该事务对数据库所做的更改会持久地保存在数据库中,而且不会被回滚 |
关于隔离性:
隔离性涉及了多个事务的并发状态,并且隔离性分为几个等级。当多个事务并发状态时,就可能产生数据丢失更新的问题。一般情况可能存在两类丢失更新。
假设某互联网账户存在两种消费形式,刷卡或者互联网消费。一对夫妇同时使用这个账户进行消费,老公使用刷卡消费,老婆使用联网消费,请看如下场景:
场景1:
第一类丢失更新
时刻 | 事务(1)老公 | 事务(2)老婆 |
T1 | 查询余额10000元 | //do nothing |
T2 | //do nothing | 查询余额10000元 |
T3 | //do nothing | 网购1000元 |
T4 | 请客吃饭1000元 | //do nothing |
T5 | 提交事务,余额9000元 | //do nothing |
T6 | //do nothing | 取消购买,回滚事务至T2,余额10000元 |
可以看出,整个事务的过程,老公花掉1000元,而老婆最后却查询剩余10000元,明显不符合事实。此类丢失更新,是由于两个事务,一个提交成功,一个回滚,导致的数据不一致,可以成为第一类丢失更新。(目前大多数数据库已经消灭了这种问题),下面来看另一个场景。
场景2:
第二类事务丢失更新
时刻 | 事务(1)老公 | 事务(2)老婆 |
T1 | 查询余额10000元 | //do nothing |
T2 | //do nothing | 查询余额10000元 |
T3 | //do nothing | 网购1000元 |
T4 | 请客吃饭1000元 | //do nothing |
T5 | 提交事务,消费1000,余额9000元 | //do nothing |
T6 | //do nothing | 提交成功,消费1000,余额9000元 |
可以看出,整个事务过程有两笔消费分别是老公的和老婆的,但由于是在不同的事务中,事务之间无法互相干涉,所以最后查询出的余额都是9000元,不符合实际情况。这就是第二类丢失更新问题。
因此,为了克服第二类丢失更新带来的问题,也就是事务之间的协助的一致性,数据库标准规范了事务之间的隔离等级,可以在一定程度上减少出现丢失更新带来的问题。
隔离等级:
隔离等级可以在不同程度上减少丢失更新,隔离等级分为4层,分别是:脏读(dirty read)、读/写提交(read commit)、可重复读写(repeatable read)、序列化(serilizable)。下面分别解释这四个事务隔离等级。
1.脏读:脏读是最低的隔离级别,它允许一个事务去读取另一个事务中未提交的数据,参考下列以脏读为隔离级别的例子
脏读
时刻 | 事务(1)老公 | 事务(2)老婆 | 备注 |
T1 | 查询余额10000元 | //do nothing | XXX |
T2 | //do nothing | 查询余额10000元 | XXX |
T3 | //do nothing | 网购1000元,余额9000 | XXX |
T4 | 请客吃饭1000元,余额8000 | //do nothing | 事务1读取到事务2,余额为9000,再消费1000,余额为8000. |
T5 | 提交事务,消费1000,余额8000元 | //do nothing | 余额依旧为8000 |
T6 | //do nothing | 取消购买,回滚事务 | 此时回滚,余额为8000 |
可以清楚地看到, 其中只有1笔成功的消费,但是最后得到的结果是8000,其原因就是因为脏读,在T4时刻,事务1读取了事务2的余额,而在T6时刻,由于老婆回滚事务(类似第一类丢失更新,但是目前已经不存在这种问题,因此,此时的余额为8000)。
2.读写提交:针对以上脏读所带来的的一些问题,,因此SQL提出了第二个隔离级别,也就是读/写提交。读写提交就是一个事务只能读取另一个事务已经提交的数据。例子:
读/写提交
时刻 | 事务(1)老公 | 事务(2)老婆 | 备注 |
T1 | 查询余额10000元 | //do nothing | XXX |
T2 | //do nothing | 查询余额10000元 | XXX |
T3 | //do nothing | 网购1000元,余额9000 | XXX |
T4 | 请客吃饭1000元,余额9000 | //do nothing | 事务2的余额还未提交,不能读出,因此余额为9000 |
T5 | 提交事务,消费1000,余额9000元 | //do nothing | 余额依旧为9000 |
T6 | //do nothing | 取消购买,回滚事务 | 此时回滚,余额为9000 |
可以看出,此例的事务采用读写提交的隔离级别,一共花费1笔,最后余额为9000,在T4时刻,由于事务2还未提交,所以事务1无法读取到事务2的余额。事务1在T5时刻提交,事务2在T6时刻回滚(并不会带来第一类丢失更新问题,上面已经讲到过)。所以余额9000正确。但是也可能带来另外一种问题,如下:
不可重复读
时刻 | 事务(1)老公 | 事务(2)老婆 | 备注 |
T1 | 查询余额10000元 | //do nothing | XXX |
T2 | //do nothing | 查询余额10000元 | XXX |
T3 | //do nothing | 网购1000元,余额9000 | XXX |
T4 | 请客吃饭2000元,余额8000 | //do nothing | 事务2的余额还未提交,不能读出,因此余额为10000-2000=8000 |
T5 | //do nothing | 继续网购8000,余额1000 | 余额依旧为9000 |
T6 | //do nothing | 提交订单,提交事务 | 老婆提交事务,余额为更新为1000 |
T7 | 提交事务发现,余额为1000,不足买单 | //do nothing | 事务1可以读取到事务2已经提交的数据,并发现余额为1000 |
可以看出,由于读写提交的隔离级别,在T6时刻事务2提交事务,余额更新为1000,但是事务1在T6时刻前不能读取事务2的数据,所以,从事务1的这一列来看,假若自己是老公,查到余额有10000元,于是和朋友去吃饭,结账时被告知花了2000元,但是却被提示余额不足!其实问题就在于他的账户余额的不能重复读取导致的问题,这种场景就叫做不可重(复)读。
3.可重复读:为了克服上面类似的不可重复读带来的问题,于是SQL标准提出了可重复读的隔离级别来解决问题。可重复读是针对数据库同一条记录而言,会使数据库同一条记录按照一个序列化进行操作。不会产生交叉的情况,因此可以保证数据的一致性。但是,在很多场景,数据库需要同时对多条记录进行读写操作,此时会产生下列情况:
幻读
时刻 | 事务(1)老公 | 事务(2)老婆 | 备注 |
T1 | //do nothing | 查询消费记录:10条,准备打印 | XXX |
T2 | 开始消费,增加1条消费记录 | //do nothing | XXX |
T3 | 提交事务 | //do nothing | XXX |
T4 | //do nothing | 共打印出11条消费记录 | 事务2,打印得到11条结果,比查询时多了1条,她会思考这一条信息的真实性,这样的场景称为幻读 |
在T2-T3时刻,事务1开启一笔消费,并提交,对于事务2,查询到10条结果,但她并不知道事务1的操作,并得到11条记录,于是便质疑多出的一条记录是否多余。产生幻读。
4.序列化:为了克服幻读,SQL标准提出了序列化隔离级别,它是一种让SQL按照顺序读写的方式,能够消除数据库事务之间产生的数据不一致的问题。
最后,各类隔离级别以及可能产生的现象如下表:
隔离级别 / 现象 | 脏读 | 不可重读 | 幻读 |
脏读 | √ | √ | √ |
读/写提交 | × | √ | √ |
可重复读 | × | × | √ |
序列化 | × | × | × |
一般而言,从脏读到序列化的隔离级别,系统性能直线下降,因此级别越高,会严重的抑制并发,导致大量线程被挂起,需要大量的时间恢复,一般建议使用读写提交的方式来设置事务,这样不仅有助于高并发,也抑制了脏读的产生。