假设我们在开发过程中有这么一个业务需求,需要我们写一个转账逻辑。比如张三要给李四转账50块钱,我们在数据库层面执行的语句应该是三条,第一条是查找张三的账户余额是否大于或等于50,如果是的话才能够转账;第二条是将张三的账户余额减去50;第三条是将李四的账户余额增加50。也就是说,在我们上层看来一个简单的转帐逻辑,可能对应后端需要做很多工作,这些工作组合起来才是一个完整的需求解决方案。
并且这三条语句的执行工作必须是一个整体的,假设我们刚执行完第二条语句,张三的账户余额刚好减了50服务器就崩了,李四的账户余额增加50这条语句执行失败了。那么这个时候就很尴尬了,张三的钱扣了,却没有到李四的账户里。而且有可能不只是张三和李四,可能这个时间段很多人转账也出现了这个问题,这样就乱套了。
所以必须把上面的三条语句当成一个整体去执行,要么全部执行完,要么一条也不执行,绝对不能出现执行完部分最后失败的中间状态。我们把这三条语句当成一个整体,这个整体其实就是事务的概念,就是一个或者多个SQL语句的集合。事务本身并不是数据库类软件天然就有的,事物本质工作其实是为了简化程序员工作的模型,现在市面上主流的数据库软件一般都会提供事务管理机制。
什么是事务?
事务就是一组DML语句组成,这些语句在逻辑上存在相关性,这一组DML语句要么全部成功,要么全部失败,是一个整体。MySQL提供一种机制,保证我们达到这样的效果。事务还规定不同的客户端看到的数据是不相同的。
事务就是要做的或所做的事情,主要用于处理操作量大,复杂度高的数据。假设有一种场景:你毕业了,学校的教务系统后台MySQL中不再需要你的数据了,要删除你的所有信息。那么要删除你的基本信息比如姓名、电话、籍贯等,也要删除和你相关的其它信息比如各科成绩、在校表现等等。这样就需要多条MySQL语句构成,那么所有这些操作合起来,就构成了一个事务。
一个MySQL数据库可不止一个事务在运行,同一时刻甚至有大量的请求被包装成事务,在向MySQL服务器发起事务处理请求。而每条事务至少一条SQL语句,最多有很多SQL语句,这样如果大家都访问同样的表数据,在不加保护的情况下,就绝对会出现问题。甚至,因为事务由多条SQL构成,那么也会存在执行到一半出错或者不想再执行的情况。所以一个完整的事务,绝对不是简单的SQL语句集合,还需要满足下面的四个属性:
为什么会出现事务?
事务被MySQL编写者设计出来,本质是为了当应用程序访问数据库的时候,事务能够简化我们的编程模型,不需要我们去考虑各种各样的潜在错误和并发问题。可以想一下当我们使用事务时,要么提交要么回滚,我们不会去考虑网络异常了,服务器宕机了,同时更改一个数据会怎么办等等的情况。因此事务本质上是为了应用层服务的,而不是伴随着数据库系统天生就有的。
在MySQL中只有使用了InnoDB存储引擎才支持事务管理机制,MyISAM存储引擎是不支持事务的。我们可以输入指令show engines;
查看一下所有引擎的详细信息,可以发现InnoDB是支持事务的,其它的都是不支持的。
事务的提交方式常见的有两种:自动提交、手动提交。我们可以查看一下当前MySQL的事务提交方式是什么,输入指令show variables like 'autocommit';
,我们看到Value一列是on,代表是自动提交。
我们也可以将自动提交改成手动提交,输入指令set autocommit=0;
即代表关闭自动提交,也就是变成手动提交。
下面我们来演示一下事务的操作方式,并且通过几个实验来验证事务的某一些特性。首先我们要创建一张用于测试的表:
mysql> create table account(
-> id int primary key,
-> name varchar(20) not null default '',
-> blance decimal(10,2) not null default 0.0
-> )engine=InnoDB;
Query OK, 0 rows affected (0.23 sec)
start transaction;
语句,也可以使用begin;
语句,推荐使用后者,因为比较方便简单。mysql> start transaction;
Query OK, 0 rows affected (0.01 sec)
savepoint 保存点名字;
mysql> savepoint save1;
Query OK, 0 rows affected (0.00 sec)
mysql> savepoint save1;
Query OK, 0 rows affected (0.00 sec)
mysql> insert into account values (1, '张三', 100);
Query OK, 1 row affected (0.00 sec)
mysql> savepoint save2;
Query OK, 0 rows affected (0.00 sec)
mysql> insert into account values (2, '李四', 10000);
Query OK, 1 row affected (0.01 sec)
然后我们新建一个MySQL客户端,用另一个客户端查看一下account表中的数据,可以发现另一个客户端现在是可以查看到刚刚插入的数据的。
roolback;
可以直接回滚到最开始;第二种是使用语句roolback to 保存点名字;
可以回滚到指定的保存点。例如下面的例子,我们先回滚到save2,再回滚到最开始:mysql> rollback to save2;
Query OK, 0 rows affected (0.00 sec)
mysql> rollback;
Query OK, 0 rows affected (0.01 sec)
我们再来演示一下开始事务插入数据之后,如果遇到了客户端崩溃的情况,MySQL会不会自动回滚?
首先还是先开启事务,然后向account表中插入一条记录。
mysql> begin;
Query OK, 0 rows affected (0.00 sec)
mysql> insert into account values (1, '张三', 100);
Query OK, 1 row affected (0.00 sec)
此时我们在另一个MySQL客户端查看表数据是可以看到插入的记录的:
但当我们将启动事务插入数据的那个MySQL客户端强行异常终止之后,再看另一个MySQL客户端会发现,之前插入的数据没有了。原因是启动事务的客户端没有提交事务就被异常终止了,所以MySQL会自动回滚到事务最开始。
下面我们再来演示另一种情况:如果事务提交了,之后再异常终止客户端,那么插入的数据是否不会受影响呢?
首先依旧是启动事务,插入一条数据,最后提交记录。
mysql> begin;
Query OK, 0 rows affected (0.00 sec)
mysql> insert into account values (1, '张三', 100);
Query OK, 1 row affected (0.00 sec)
mysql> commit;
Query OK, 0 rows affected (0.00 sec)
然后我们终止客户端,再重新启动一个客户端,发现表中的数据依旧是存在的。也就是说一旦事务被提交了,即使客户端崩溃,插入的数据也不会受影响,这些数据已经具有持久性。
我们先确定一下目前MySQL是默认打开了自动提交事务的:
也就是说MySQL如果会自动提交的话,那么我们手动启动事务,如果没有提交就关闭了客户端,MySQL的自动提交会不会帮我们执行提交呢?我们做个实验来演示一下。
首先还是先开启事务,插入数据:
mysql> begin;
Query OK, 0 rows affected (0.00 sec)
mysql> insert into account values (2, '李四', 10000);
Query OK, 1 row affected (0.00 sec)
数据插入成功了:
接下来终止掉开启事务的终端,再次查看表中的数据,我们发现MySQL帮我们自动回滚了,刚刚插入的数据没有了。也就是说,MySQL即使设置了自动提交,只要是手动启动的事务,必须手动提交,MySQL不会帮我们自动提交。
那么MySQL的自动提交方式到底有什么用呢?既然它在手动启动事务的情况下几乎是不会发生作用的,那么我们尝试一下不在手动启动事务的情况下,它是否会发生作用。
首先我们先关闭自动提交,输入语句set autocommit=0;
即可:
关闭成功之后,我们再向account表中插入一条数据:
mysql> insert into account values (2, '李四', 10000);
Query OK, 1 row affected (0.01 sec)
然后在另一个MySQL终端查看表中的数据,刚刚插入的数据是可以看到的:
这个时候我们再强制关闭掉插入数据的MySQL终端,再从另一个MySQL终端中查看表中的数据,我们发现刚刚插入的数据消失了。这是因为我们一开始将自动提交关闭了,MySQL不会为我们自动提交事务,并且我们也没有手动启动事务,所以如果没有手动提交事务的话,在强制关闭客户端之后插入的数据就会消失。
相反,如果我们一开始打开了自动提交,那么即使我们没有手动启动事务,我们插入的每一条SQL都会被当成事务,MySQL会帮我们自动提交。
所以综合上述几个演示,我们可以得出以下的结论:
MySQL服务可能会同时被多个客户端进程访问,访问的方式是以事务的方式。一个事务可能由多条SQL构成,也就意味着,任何一个事务,都有执行前、执行中和执行后的阶段。而所谓的原子性,其实就是让用户层要么看到执行前,要么看到执行后,不会看到执行中的出现的问题。如果执行中出现了问题,事务可以随时回滚。所以单个事务对用户表现出来的特性就是原子性。
但是毕竟所有的事务都有个执行过程,那么在多个事务各自执行多个SQL语句的时候,就还是有可能会出现互相影响的情况,比如多个事务同时访问同一张表,多个事务都处于执行中阶段,那么这些事务修改的数据对于其它用户而言可不可以感知得到可不可以看得到呢?
所以在数据库中,为了保证事务执行过程中尽量不受干扰,就有了一个重要的特性即隔离性。在数据库中允许事务受不同程度的干扰,就有了一种重要的特征即隔离级别。
数据库一共有下面四种隔离级别,越往下隔离级别越高,相对应的效率也越低。
所谓的脏读指的是读未提交时,如果插入数据的客户端还没有提交插入的数据,就被其它客户端看到了,这些数据就叫作脏数据,这种现象就是脏读现象。
所谓不可重复读问题,是读提交这种隔离级别存在的问题。虽然看起来好像问题不大,但在现实使用过程中其实存在很大的风险问题。假设我们有一张学生表,表中有很多个学生的个人信息数据。现在有两个MySQL客户端A和客户端B,分别启动一个事务。客户端A和客户端B互相都不知道对方的存在,客户端A启动事务的目的是想修改一个同学的个人信息,比如张三同学语文成绩150分,客户端A想把张三同学的语文成绩改成140分。而客户端B想要先查询表中语文成绩为150分的同学,假如在客户端A修改或者提交之前,客户端B先查询,那么张三同学会出现在语文成绩为150分的查询结果当中。此时客户端A也修改完了也提交了,客户端B再查询表中语文成绩为140分的同学,就出现问题了,因为同一个张三同学又出现在了语文成绩为140分的查询结果当中,这就是不可重复读带来的问题。所以两个同时运行的事务如果不可重复读,就有可能会出现问题。
一般的数据库在可重复读情况的时候,无法屏蔽其他事务insert的数据,因为隔离性实现是对数据加锁完成的,而insert待插入的数据因为并不存在,那么一般加锁无法屏蔽这类问题,会造成虽然大部分内容是可重复读的,但是insert的数据在可重复读情况被读取出来,导致多次查找时,会多查找出来新的记录,就如同产生了幻觉。这种现象,叫做幻读(phantom
read)。很明显,MySQL在RR级别的时候,是解决了幻读问题的。
事务的原子性、隔离性、持久性是手段,而事务的一致性是目的。事务的执行结果,必须使数据库从一个一致性状态,变到另一个一致性状态。数据库的每一个阶段预期的结果我们是可以预见的,不能说执行了这一条语句我们也不知道接下来会发生什么样的结果。当数据库只包含事务成功提交的结果时,数据库处于一致性状态。如果系统运行发生中断,某个事务尚未完成而被迫中断,而该未完成的事务对数据库所做的修改已经被写入数据库,此时数据库就处于一种不正确的不一致的状态。因此一致性是用过原子性来保证的。其实一致性和用户的业务逻辑强相关,一般MySQL提供技术支持,但是一致性还是要用户业务逻辑做支撑,也就是,一致性,是由用户决定的。
数据库并发有三种场景,分别是:
其中,读-写冲突问题是通过多版本并发控制(MVCC)来解决的,这是一种无锁并发控制的机制。
MySQL是以服务进程的方式在内存中运行的。我们之前所讲的所有机制,包括索引、事务、隔离性、日志等,都是在内存中完成的。即在MySQL内部的相关缓冲区中,保存相关数据,完成各种操作。然后在合适的时候,将相关数据刷新到磁盘当中。所以,undo log可以简单理解为就是MySQL中的一段内存缓冲区,用来保存日志数据的。
下面我们结合undo log来理解一下MVCC多版本并发控制。首先我们创建一个student表,并且在表中插入一条数据:
mysql> create table if not exists student(
-> name varchar(20) not null,
-> age int not null
-> );
Query OK, 0 rows affected (0.07 sec)
mysql> insert into student (name, age) values ('张三', 28);
Query OK, 1 row affected (0.00 sec)
mysql> select * from student;
+--------+-----+
| name | age |
+--------+-----+
| 张三 | 28 |
+--------+-----+
1 row in set (0.00 sec)
表中记录所表达的意思是:
假设我们现在有一个事务ID为10的事务,对student表中的记录进行修改(Update):将张三的name改为李四,修改过程如下:
此时如果我们又要对student表中的记录做修改,这次想把age从28改为38,修改过程如下:
这样我们就有了一个基于链表记录的历史版本链。而所谓的回滚,无非就是用历史的数据,覆盖当前的数据。上面undo log里的一个个版本,我们可以称之为一个个的快照。
上面的例子是以update为主演示的,如果是delete呢?其实是一样的,因为delete删除数据并不是真正的删除数据,只是设置删除flag为删除,也可以形成历史版本。
如果是insert呢?因为insert是插入操作,也就是说之前并没有数据,那么insert按理说就不应该有历史版本。但是一般为了回滚操作,insert的数据的相反记录也是要被放入undo log中的,这个相反记录指的就是delete语句,因为你插入如果要回滚的话,就是将插入的数据删除了,就回到插入的上一个版本了。如果当前事务提交了,那么这个undo log的历史insert记录也就可以被清空了。
所以总结一下就是,update和delete可以形成版本链,insert暂时不考虑。
也就是说,增删改都是对最新版本的记录做操作,那么select读取记录是读取最新的版本还是读取历史版本呢?
一般而言,select是读取历史版本,这种读取叫做快照读,读取历史版本的话,是不受加锁限制的,也就是可以并行执行,这样也提高了效率,这就是MVCC的意义所在。但select也可以读取最新的记录,叫做当前读。如果是当前读的话那么select必须要加锁,这就是串行化的读取。
那么为什么要有隔离性和隔离级别呢?
通过上述的分析我们可以发现,事务从begin->CURD->commit,是有一个阶段的,也就是说事务有执行前、执行中和执行后三个阶段,并且不管启动多少个事务,这些事务总是有先有后的。那么多个事务在执行过程中,CURD操作是会交织在一起的。那么为了保证事务的“有先有后”,就必须让不同的事务看到它该看到的内容,这就是隔离性和隔离级别所要解决的问题。