本文简单介绍了事务的ACID特性和Mysql事务的四种隔离级别,以及MVCC并发控制手段。
事务的ACID特性
- 原子性(Atomicity)
原子性强调事务作为一个整体,要么完全执行成功,要么完全执行失败,不能存在一些操作成功,而其他的操作失败这样的可能会破坏数据一致性的情况。 - 一致性(Consistency)
一致性指的是数据永远在一致的状态,而不会存在一部分数据在一个一致的状态,另一部分数据在另一个一致的状态,从而导致总的数据不在一致的状态。一致的状态这个说法需要根据具体的业务的数据的一致性要求来确定,如图书借阅系统中,一本书只能被被一个读者借阅,若这本书的状态为同时被两个人借阅,则产生了数据不一致的情况。 - 隔离性(Isolation)
隔离性指的是不同的事务独立执行,相互之间互不干扰。即一个事务完成、提交并将对数据的修改同步到数据库之前,这个事务的操作对于其它的事务是不可见的。 - 持久性(durability)
持久性指事务一旦执行成功并提交了,那么事务对数据的修改将会一直保存,不会因为故障而丢失。
事务的四个隔离级别
- 未提交读
这个级别事务可以读取到另外的事务尚未提交的修改,这个隔离级别性能稍好,但是会产生脏读的问题。一般不建议使用。 - 读提交
这个级别事务只能看到自己已经修改的数据和已经提交的事务的状态,但是这个级别会产生不可重复读这样的问题,即在事务执行期间有的数据被修改了,则在修改数据的事务提交前后的两次读取会读取到不同的值。 - 可重复读
可重复读保证了在同一个事务中对同一个数据的多次读取值相同(本事务没修改)。可重复读存在的问题是幻读。幻读是一个更大粒度的问题,即在一次事务执行期间,事务处理的数据范围插入或删除了数据,和不可重复读的区别是一个是单条数据,一个是一个范围的数据集合。InnoDB通过MVCC解决了幻读的问题。 - 可串行化
可串行化强制事务串行执行,避免了事务交叉执行的若干问题。可串行化的实现会在读取的每一行数据上都加锁,确保两个有数据竞争的事务依次执行。
MVCC(多版本并发控制)
MVCC 通过对数据添加基于时间戳的版本标识来避免加锁,达到在提高性能的同时保证并发条件下数据一致性的目标。每个数据库存储引擎对MVCC的实现不同,这里介绍一下InnoDB的实现方式。InnoDB的MVVC按照事务开始的先后顺序为每个事务分配一个编号。InnoDB通过在数据库的每一行后面加上两个隐藏的列来记录创建这条数据的事务编号和删除这条数据的版本编号来进行并发控制。需要注意的是,UPDATE操作会标识这条数据已经过期,同时插入一条新的数据,并以当前事务编号作为创建时间。接下来看看在可重复读的事务隔离级别下,MVCC的具体行为:
SELECT:
select 操作只能读取到在本次事务开始之前存在于数据库中的数据或本次事务创建的数据。如何实现呢,就是检查每个数据行的创建时间和删除时间。本次事务开始之前存在于数据库中表示:创建时间小于本次事务编号且删除时间未定义或大于本次事务编号。本次事务创建的且未删除的表示:创建时间为本次事务编号且删除时间未定义。
INSERT:
插入一条数据,使用当前的事务编号作为创建时间
DELETE:
将当前事务编号保存到数据库作为数据行的删除时间
UPDATE:
插入一条新的数据,将当前事务编号作为数据行的创建时间,并将当前事务编号作为数据行 的删除时间
考虑存在的一个问题,假设一个银行系统有Account表有三个账户,A,B,C,版本如下
账户名称 | 余额 | 创建时间 | 删除时间 |
---|---|---|---|
A | 300 | 1 | 未定义 |
B | 0 | 1 | 未定义 |
C | 0 | 1 | 未定义 |
接下来两个事务
T2: 某些耗时操作+从账户A转账200到账户B
some op
balance_a = select 余额 from account where 账户名称 = A
balance_b = select 余额 from account where 账户名称 = B
assert balance_a >= 200
update account set 余额 = balance_b + 200 where 账户名称 = B
update account set 余额 = balance_a -200 where 账户名称 = A
T3: 从账户A转账200到账户C
balance_a = select 余额 from account where 账户名称 = A
balance_c = select 余额 from account where 账户名称 = C
assert balance_a >= 200
update account set 余额 = balance_c + 200 where 账户名称 = C
update account set 余额 = balance_a -200 where 账户名称 = A
假设由于T2在执行耗时操作,T3先全部执行了,那么数据就是这样
账户名称 | 余额 | 创建时间 | 删除时间 |
---|---|---|---|
A | 300 | 1 | 3 |
A | 100 | 3 | 未定义 |
B | 0 | 1 | 未定义 |
C | 0 | 1 | 3 |
C | 200 | 3 | 未定义 |
那么T2选择到的balance_a 将会是 300,T2执行完成之后,数据库如下
账户名称 | 余额 | 创建时间 | 删除时间 |
---|---|---|---|
A | 300 | 1 | 2 |
A | 100 | 3 | 未定义 |
A | 100 | 2 | 未定义 |
B | 0 | 1 | 2 |
B | 200 | 2 | 未定义 |
C | 0 | 1 | 3 |
C | 200 | 3 | 未定义 |
尴尬的事情发生了,A账号只有300元,却成功向外转出了400元,这是为什么呢,还需要再探究一下
、-------------------
分割线
经过实验,最终理解了原因。其实上面的操作没有问题,上面的操作是在可重复读这个隔离级别限定的,这个隔离级别并没有说完全可以保证数据一致性,只是说明了不会存在脏读,以及不可重复读这两个问题,至于数据一致性的问题,需要程序员自己来维护。很显然,像上面这样的操作,需要通过对数据加锁来保证数据的一致性。