前言
本系列文章的写作思路,先提出总纲, 总领后面数据库事务系列所有的文章, 后面再针对其他数据库的事务,像下面这样:
事务简介
现实世界被映射到软件世界,有的时候就会有出现一些专属于软件世界的问题,举一个简单而又经典的例子转账, A向B借10元钱,假设现在还没有网络支付,A就是从B钱包中拿10元钱给A, 就算是多个人向B借钱这也没什么问题,B会依次处理借钱请求,同一时间段借钱的人越多,B借钱的速度越慢,有可能还要考虑一下交情等各方面的因素。
但是如果我们将这个转账引入到软件世界,就会引出现实世界不存在的问题,你钱包里有五十,你就只能借五十,不可能出现你有五十,你借出去一百,然后钱包里面出现了一个负五十,没错说的就是你一致性。除此之外,借钱操作一般也不会存在中间态,要么借钱成功,要么钱就没到借钱人手里。这也就是原子性。现实世界的一些操作映射到软件世界,情况就又会变得复杂一些,A向B借钱这一个操作在数据库操作就会被分割为若干个操作,我们来简单的介绍一下转账操作在数据库世界是怎么样的,在故事的开始我们先准备一张账户表:
CREATE TABLE `accounts` ( `id` bigint(20) NOT NULL COMMENT '主键', `userId` bigint(20) NOT NULL COMMENT '用户ID', `money` int(255) NOT NULL COMMENT '钱款', PRIMARY KEY (`id`) USING BTREE ) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_general_ci;
这个建表语句是MySQL下面建表语句, 一个转账操作就对应下面两条SQL语句:
UPDATE accounts SET money = money - 5 WHERE id = 1; // 1是B的ID 2是A的ID,此时的场景是A向B借5块钱 UPDATE accounts SET money = money + 5 WHERE id = 2;
以上两条语句在数据库的真实执行过程还会更复杂一点,为了说明问题,我们将转账模型简化为如下操作:
1. 将小B的账户余额读取到变量A中,这一步骤简记为read(A)
2 .将小B的余额减去账户余额,简记为 A = A - 5
3 . 将小B修改过后的余额写入磁盘里,这一步骤简单写为write(A)
- 读取小A账号的余额到变量B,这一步骤简写为read(B)
- 将小A的账户余额加上小B转账过来的余额, 简单记为B = B + 5。
- 将小A账户修改过的余额写到磁盘里,这一步骤简写为write(B)
小A向小B借钱借两次在现实世界是没什么问题的,无非就是从钱包里面掏两次钱而已,但是在数据库中两次操作所对应的步骤就可能是并发执行,而不是排队执行。为了说明问题,我们将两次转账操作记为T1、T2。在并发执行的场景下, T1在read(A)之后,很快T2也执行了read(A), 我们假设在转账操作进行之前,小B只有10元钱,也就是T1和T2在进行转账操作的时候都认为小B有十元钱,然后假定T1开始执行2,3,4,5,6之后,T2接着执行,在这种情况下,小B只转了五元钱,小A的账户确多出了十元,多出的五元,让银行补出来?这显然不合理。那为了避免T1,T2交替执行带来的问题,最简单的方法就是让T1,T2在数据库排队执行,这会慢的要死。所以对于现实世界中状态转换对应的某些数据库操作来说,不仅要保证这些操作以原子性的方式来执行完成,而且要保证其他的状态转换不会影响到本次状态转换,这个规则我们称之为隔离性。
在上面的讨论中我们已经发现了,在数据库中对某个表进行修改操作,所引出来的问题, 只是做查询操作对于数据库管理系统并没有什么影响, 我们将这些对数据库的有限操作序列称之为数据库事务。
事务的状态
现在我们已经知道,事务其实是一个抽象的概念,由一个有限的数据库操作序列构成,对应着一个或多个数据库操作,这些操作所执行的不同阶段大致上有以下几个状态:
- 活动的(active)
事务对应的数据库操作正在执行过程中,我们就说该事务处在活动的状态
- 部分提交的 (partially committed)
当事务中的最后一个操作执行完成,但是由于操作都在内存中执行,所造成的影响并没有刷新到磁盘时,我们就说该事务处在部分提交的状态
- 失败的(failed)
当事务处在活动的或者部分提交的状态时,可能遇到了某些错误(数据库自身的错误、操作系统错误或者直接断电等)而无法继续执行,或者人为的中止当前事务的执行,我们就说该事务处在失败的状态。
- 中止的(aborted)
如果事务执行了半截变为失败的状态,比如 我们上面唠叨的转账事务,当小B的钱被扣除,小A的钱没有增加时遇到了错误从而导致当前事务处在了失败的状态,那么就需要将小B的账户余额调整为未转账之前的金额,换句话说,就是要撤销失败事务对当前数据库造成的影响。我们将这个撤销的过程称之为回滚。当回滚操作执行完毕的时候,也就是数据库恢复到了执行事务之前的状态,我们就说该事务处在了中指的状态。
- 提交的(committed)
当一个处在部分提交的状态的事务将修改过的数据都同步到磁盘上之后,我们就可以说该事务处在了提交的状态。
- 随着事务对应的数据操作执行到不同阶段,事务的状态也在不断变化,一个基本的状态转换如下图所示:
事务并行遇到的问题
让T1,T2排队执行牺牲性能这并不是我们想要的方案,我们想要的是既想保持事务的隔离性,又想让服务器在处理访问同一数据的多个事务时尽量高些,舍弃一部分的隔离性来提升性能。我们知道数据库是一个客户端/服务器架构的软件,对于同一个服务器来说,可以有若干个客户端与之链接,每个客户端与服务器连接上之后,就可以称之为一个会话(Session)。每个客户端都可以在自己的会话中向服务器发出请求语句,一个请求语句可能是某个事务的一部分,也就是对于服务器来说可能同时处理多个事务。现在让我们来看下假设让事务并行执行会带来哪些问题:
- 脏写
如果一个事务修改了另一个未提交事务修改过的数据,那就意味着发生了脏写。如下图所示:
Session A 和 Session B 各开启了一个事务, Session B中的事务先将userId为1改为50,紧接着Session A将userId这行数据改为80,然后提交。Session B在执行过程中遇到了错误或者其他状况执行了回滚,然后将Session A的更新也不复存在了。这种现象我们一般称之为脏写,这是一种十分严重的现象。
- 脏读
如果一个事务读到了另一个事务未提交事务修改过的数据,那就意味着发生了脏读。如下图所示:
如上图所示,Session A 和 Session B各开启了一个事务,Session B的事务先将userId为1的那一行的money列改为1,Session A查询到了Session B 还未提交的记录,然后 Session B执行了回滚,那么Session A就好像读到了一个不存在的数据一样,这种现象我们就称之为脏读。
- 不可重复读
如果一个事务只能读到另一个已经提交的事务修改过的数据,并且其他事务每对该数据进行一次修改并提交后,该事务都能查询得到最新值,那就意味着发生了不可重复读。
Session B中的修改语句属于隐式事务, 隐式事务意味着语句结束之后,事务就自动提交了,这些事务都修改了userId为1的记录列money的值。每次事务提交之后,如果Session A中都能从查询到最新的值,这种现象就意味着发生了不可重复读。
- 幻读
如果一个事务先根据某些条件查询出一些记录,之后另一个事务又向表中插入了符合这些条件的记录,原先的事务再次按照该条件查询时,能把另一个事务插入的记录也读出来,那就意味着发生了幻读. 示意图如下:
Session A 先根据条件money 大于0查到了一些记录,Session B中插入了符合条件的记录被Session A 中的事务再根据条件查询时查到了Session B中插入的记录,这种现象我们称之为幻读。如果我们在Session B中删掉了 userId ,Session A再根据条件查询时发现变少了,那么这种情况算幻读吗?不算,幻读强调的是一个事务按照某个相同条件多次读取记录时,读到了之前没有读到的记录。对于之前读到的记录,之后读取不到,这种应该算做不可重复读。
由事务的隔离性引出事务的隔离级别
上面我们介绍了事务并发执行可能带来的问题,这些问题也有轻重缓急之分,按照问题的严重程度我们来排序:
脏写 > 脏读 > 不可重复读 > 幻读
我们上面提到的舍弃一部分隔离性来换得性能的提升就是设立隔离级别来解决事务并发执行所带来的问题,隔离级别登记越低,越严重的问题就越可能发生。SQL标准规定了以下几个隔离级别:
- READ UNCOMMITED: 未提交读
- READ COMMITED: 已提交读
- REPEATABLE READ: 可重复读
- SERIALIZABLE: 可串行化
SQL标准规定,针对不同的隔离级别,并发事务可以发生不同严重程度的问题,具体情况如下:
在哪种情况下,脏写都是超级严重的问题,因此在哪种隔离级别的情况下,脏写都没有可能发生。
不同的数据库厂商对SQL标准规定的四种隔离级别支持不一样,比如说Oracle就支持READ COMMITED 和 SERIALIZABLE隔离级别。MySQL虽然支持四种隔离级别,但是MySQL在可重复读这个隔离级别的情况下,就可以禁止幻读问题的发生。
SQL Server 在标准之外额外支持了SNAPSHOT 这一级别,PostgreSQL内部只支持READ COMMITED、REPEATABLE READ、SERIALIZABLE这三种,PostgreSQL会将READ UNCOMMITED视为READ COMMITED。隔离级别越高,读操作的请求锁定就更严格,锁持有的时间就越长,一致性就越高,并发性能就越低。
总结一下
现实世界的状态修改映射到了数据库世界我们需要保证:
- 原子性
对于不可分割的操作,要么成功要么失败。
- 隔离性
对于现实世界的状态转换对应到某些数据库操作来说,不仅要保证这些操作以原子性的方式来执行完成,而且要保证其它的状态转换不会影响到本次状态转换,这个规则被称之为隔离性。
- 一致性
现实世界的一些约束到了软件世界也要予以保持,比如说人民币的最大币值等。如果数据库中的数据全部符合现实世界的约束,我们就说这些数据就是符合一致性的。
- 持久性
当现实世界的一个状态完成后,这个转换的结果将永久保留,这个规则我们称之为持久性。当把现实世界的状态转换映射到数据库世界,持久性意味着转换对应的数据库操作所修改的数据都应该在磁盘上保留下来。
由此我们引出了事务的概念,我们将需要保证原子性、隔离性、一致性和持久性的一个或多个数据库操作称之为一个事务。由事务的隔离性我们引出事务的隔离级别,牺牲一点隔离性来换取性能的提升。事务在数据库中可能对应多个复杂操作由此我们引出事务的状态。