一、什么是事务
事务(Transaction)是由一系列对数据库中的数据进行访问与更新的操作所组成的一个程序执行单元。
在同一个事务中所进行的操作,要么都成功,要么就都失败。理想中的事务必须满足四大特性,这就是大名鼎鼎的 ACID 特性。
虽然说 ACID 是事务的四大特性,然而并不是所有的事务都满足 ACID 全部特性,比如:对于 Oracle 和 SQL Server 数据库,其默认隔离级别是 Read COMMITTED,就并不是那么严格的满足 I(隔离性)的要求;对于 MySQL 的 NDB Cluster 引擎来说,不满足 D (持久性)的要求。
A(Atomicity)原子性
原子性指的是数据库事务是不可分割的一部分,只有一个事务中的所有操作都成功,这个事务才算执行成功,一旦有一个操作失败,那么其他成功的操作也必须回滚。 以转账 1000 元场景为例,一个转账过程就是一个事务,这个事务主要包括以下两步:
-
从 A 账户扣除 1000 元。
-
将 B 账户中增加 1000 元。
试想,如果第一步成功了,那么第二步失败了,那就等于 A 的 1000 元钱直接消失了,相信这是任何人都不能接受的事情,相反的,如果第一步失败,而第二步成功,那么就等于 B 账户中凭空多出来了 1000 元。
上面转账的例子中,不管哪一步失败,结局都不是我们所想要的,所以数据库事务才需要保证原子性,要么都成功,要么都失败。
C(Consistent)一致性
一致性指的是在事务开始之前和事务结束之后,数据库的完整性约束都没有被破坏,事务执行的前后都是合法的数据状态。
这句话看起来有点抽象,具体点来说,一个合法的数据库状态包括了以下两种:
-
数据库自身的完整性约束。比如主键必须唯一,长度必须符合定义。
-
用户自定义的完整性约束。比如转账功能,无论两个账户之间怎么转账,最后总和应该保持不变。
I(Isolation)隔离性
隔离性就是说每个事务之间的操作应该相互隔离,互不干扰。比如说一个事务提交之前对另一个事务不可见。
D(Durable)持久性
持久性这个概念就比较容易理解了,就是说事务一旦提交成功了,那么就应该是持久的,即使是数据库重启,服务器宕机等情况发生,数据都不会丢失(当然这个不能包括因为地震等自然灾害导致的存储数据的硬盘发生不可逆的损坏)。
二、如何管理事务
可能很多人会说自己都感知不到 MySQL 的事务,其实这是因为 MySQL 事务是默认开启了自动提交的,因此,如果要感知到事务,我们需要关闭自动提交或者显式开启事务。
事务的自动提交
查看当前数据库是否开启了自动提交,可通过以下语句进行查询确认
SHOW VARIABLES LIKE 'autocommit';-- ON表示开启了自动提交
SELECT @@autocommit;-- 1表示开启
如果需要关闭自动提交,也可以通过以下语句来关闭:
SET autocommit='OFF';
SET @@autocommit = 0;
不过需要注意的是,这种修改方式只是 session 级别的,也就是说只在当前会话窗口生效,对其他打开的会话窗口是不生效的。
常用的事务控制语句
如果需要手动管理事务,通常会使用到以下语句:
-
START TRANSACTION 或者 BEGIN:显式的开启事务。需要注意的是在存储过程中只能用 START TRANSACTION开启事务,因为存储过程本来有 BEGIN…END 语法,两者会冲突。
-
COMMIT:提交事务。也可以写成 COMMIT WORK。
-
ROLLBACK:回滚事务。也可以写成 ROLLBACK WORK。
-
SAVEPOINT identifier:自定义保存点,适用于长事务,可以回滚到我们自定义的位置,identifier为自定义的一个唯一标识,只要保证唯一就行。
-
RELEASE SAVEPOINT identifier:删除指定保存点,identifier为自定义的保存点唯一标识,当前语句如果使用一个不存在的保存点时,会直接报错。
-
ROLLBACK TO [SAVEPOINT] identifier:回滚到指定保存点。
COMMIT 和 COMMIT WORK 的区别
这两个语句都能提交一个事务,在某些情况下是等价的,但是它们的执行效果并不完全是相同的。其区别就在于提交事务之后的操作,同样的还有 ROLLBACK 和 ROLLBACK WORK,而控制这两者之间的区别可以通过变量 completion_type 来控制:
SHOW VARIABLES LIKE '%completion_type%';
得到如下结果:
completion_type 总共有三种类型:
接下来我们演示一下第三种情况,因为这种情况是最直观能看出区别的一种表现形式。
SET completion_type=2;-- 设置 completion_type 为 2
begin;-- 开启事务
INSERT INTO user_job VALUES (40,'CE0',11);-- 插入一条语句
commit work;-- 提交事务,并同时断开当前连接
select * from user_job;-- 查询
执行完 commit work 之后,数据库连接将会被断开,所以我们后面再执行查询语句,会提示重新连接:
从上图中可以看到,当执行完 commit work 之后继续执行 select 查询出现了一个自动连接,这就说明了在 commit work 之后连接已经被断开了。
事务的分类
从事务的理论角度来说,我们可以把事务分为以下五大类:
-
扁平事务
-
带有保存点的扁平事务
-
链事务
-
嵌套事务
-
分布式事务
下面我们就一一来介绍下这五种事务的区别。
扁平事务
扁平事务是最简单也是最常用的一种事务,这种事务中的所有操作都是原子的,要么全部成功,要么什么都不做,平常我们使用的事务绝大多数都属于扁平事务。
带有保存点的扁平事务
当一个事务过长时,为了避免执行快结束的时候报错导致所有语句都要重新执行,我们可以在指定位置定义好保存点,这样当事务处理到后面报错的时候,我们就可以不需要回滚整个事务,而是回滚到我们自定义好的某一个保存点。
需要注意的是,保存点并不会被持久化,所以在事务提交之前,如果系统发生崩溃,所有的保存点都将消失。
链事务
在提交一个事务之后,释放掉我们不需要的数据,将必要的数据隐式的传给下一个事务。(注意:提交事务操作和开始下一个事务操作是一个原子操作),这就意味着下一个事务能看到上一个事务的结果。
链事务可以看成带有保存点的特殊事务,他们的区别就是带有保存点的事务可以回滚到任意保存点,而链事务中只能回滚到最近的一个保存点(即最新的一个开始事务的点)。
嵌套事务
嵌套事务就是说一个事务之中嵌套另一个事务,事务之间存在父子关系,子事务的提交之后并不生效,需要等到父事务提交之后才会生效。
需要注意的是 MySQL 原生并不支持嵌套事务,但是可以通过保存点模拟嵌套事务,只是说这么模拟的话就没有真正的嵌套事务这么灵活。
分布式事务
分布式事务通常就是在分布式环境下,多个数据库下同时运行不同的扁平事务。多个数据库环境下运行的扁平事务就合成了一个分布式事务。
三、事务的四大隔离级别
未提交读(Read Uncommitted)
未提交读简称:RU,表示一个事务可以读取到其他事务未提交的数据,这种也叫做脏读。未提交读是最低的隔离级别,等于没有隔离,基本上没有数据库会使用这个级别。
未提交读产生的脏读到底是什么问题呢?我们来看下面这幅图:
左边是事务 1,右边是事务 2,整个执行过程如下:
-
事务 1 先执行一次查询,查到 id 为 1 的数据 job_name=CTO。
-
事务 2 开启,并执行了一句更新操作,id=1 这条数据中的 job_name 由 CTO 改成了 CEO。
-
接下来事务 1 又进行了一次查询(此时事务 2 尚未提交),这时候查出来了 id=1 的数据中 job_name=CEO。
-
事务 2 执行 rollback 语句进行回滚,也就是说事务 2 回滚之后,那么 job_name 其实还是CTO,并没有被改变,但是事务 1 却读到了 CEO,这就是脏读。
已提交读(Read Committed)
已提交读简称:RC。表示一个事务只能读取到其他事务已提交的数据(已提交读是 Oracle 和 SQL Server 数据库默认的隔离级别)。就是说在一个事务里面,执行同样的查询,会出现两次不一样的结果。这种隔离级别解决了未提交读产生的脏读问题,但是会出现不可重复读的问题。
什么是不可重复读?还是看上面那个例子,假设事务 2 更新之后马上就提交,然后事务 1 第二次查询查出来的结果job_name=CEO,但是这次就不算是脏读了,原因是事务 2 提交了,并没有发生回滚,这种就叫不可重复读。
而之所以这种称之为不可重复读,就是因为事务 1 中前后两次相同的查询(同一个事务)的数据得到了不一样的结果。
可重复读(Repeatable Read)
可重复读简称 RR。这种隔离级别解决了不可重复读问题(有时候一个事务中出现不可重复读会影响到系统),就是说在同一个事务中,不管在任何时候执行相同的查询语句,结果都是一样的(对于如何实现可重复读我们在稍后介绍)。
可重复读的隔离级别虽然解决了不可重复读的问题,但是这种级别会出现幻读问题
什么是幻读?请看下面这个例子(原来演示的 user_job 表中有三列,下图中为了方便只画了两列):
上面图形中,事务 1 进行了一个范围查询,第一次只能查出一条记录,这时候事务 2 来插入了一条数据,然后事务 1 再次执行同一个查询,这时候就能查出来两条记录,也就是多了一条,给人一种幻觉,所以称之为幻读。
说到这里,可能有人就有疑问了,因为感觉不可重复读和幻读都是读取到已提交事务的结果,好像没什么区别?确实如此,不可重复读和幻读本质上是一样的,但是不可重复读针对的是更新和删除操作,而幻读仅针对插入操作。
串行化(Serializable)
这种是隔离的最高级别,也就是说所有的事务都是串行执行的,也就不存在并发事务,脏读,可重复读和幻读问题自然也就没有了。
四种隔离级别对比
不同的隔离级别可以解决不同的问题,大致如下图:
四、事务隔离方案之 LBCC
LBCC(Lock Based Concurrency Control),基于锁的并发控制。这种方案的实现就是当一个事务去读取一条数据的时候,就上锁,不允许其他事务来操作,很明显,如果说所有的地方都直接加锁,那么当读多写少的场景时这种方案可能会影响到操作效率。
加锁去查询数据时,可以保证任何时候查询到的语句都是最新的,所以这种查询方式也称之为当前读。
五、事务隔离方案之 MVCC
MVCC(Multi Version Concurrency Control),多版本的并发控制。当修改数据的时候,可以为这条数据创建一个快照,后面就可以直接读取这个快照。
可重复读的实现方式正是基于 MVCC 控制的,而 MVCC 模式下,查询的数据是从快照中读取,所以也被称之为快照读。
MVCC实现原理
在 InnoDB 中,为了实现 MVCC 机制,其内部为每一行添加了两个隐藏列:DB_TRX_ID 和 DB_ROLL_PTR。
-
DB_TRX_ID:事务 ID ,长度为 6 字节,用来存储插入或更新语句的最后一个事务的事务 ID。
-
DB_ROLL_PTR:回滚指针,长度为 7 字节。回滚指针指向写入回滚段(undo log)的记录,读取记录的时候会根据指针去寻找undo 段中的记录。
在 InnoDB 存储引擎中,每个事务开启时就会去申请一个事务 ID,然后在创建视图瞬间,InnoDB 又会在其内部为每个事务构造一个数组,用来保存当前正在活跃(启动了但还没提交)的所有事务 ID。
上图中,未提交事务是一个数组(黄色区域),那么这三块区域都是如何产生的呢?
-
假设当前数据库中已提交的事务只有一个,事务 id=1,而假设此时有一个事务 2 已创建,但是没有提交。
-
此时我们又开启了一个事务,那么这时候新开启的事务就会申请一个 id=3。
-
这时候已提交事务(绿色区域)就是事务 1,未提交事务(黄色区域)就是两个事务,事务 2 和事务 3。
-
而假如当前事务 3 在提交之前,又创建了新的事务,比如事务 4,那么事务 4 及之后的事务就会归属于未开始事务部分(红色区域)。
根据上面简单的介绍,我们可以得出低水位和高水位代表的事务 id:
-
低水位:未提交事务数组内事务 ID 的最小值(比如上面例子中的事务 2)。
-
高水位:当前快照中已经创建的事务 ID 的最大值 +1(比如上面例子中的最大事务 3,那么高水位就是 4)
数据可见性规则
1.以当前事务创建视图的时刻为准,如果一条数据的事务版本(DB_TRX_ID)是在当前事务创建视图之前生成并提交的,就可见(比如上图中的绿色区域,代表的就是已提交事务或者当前事务自己生成的数据)。
2.如果一条数据的事务版本(DB_TRX_ID)是在当前事务创建视图之后才生成的,就不可见(比如上图中的红色区域)。
3.如果查询到到的数据事务版本(DB_TRX_ID)正好处于黄色区域,就需要分两种情况: a) 如果 db_trx_id 在数组中,表示这个版本是由还没提交的事务生成的,不可见; b) 如果 db_trx_id 不在数组中,表示这个版本是由已经提交的事务生成的,可见。
其实前面两点都比较好理解,关键是第 3 点不太好理解。同样的,我们也分为两种情况来分析:
-
第一种情况就是说事务 id
在未提交数组(黄色区域)中,这就说明当前产生快照的时候,这些事务都还没提交,所以当前事务的整个事务期间都不能看见这些数据。 -
第二种情况,为什么会出现处于黄色区域而又不在未提交数组中呢?这是因为一旦未提交事务提交之后,会将其从未提交事务数组移除,但是其仍然属于黄色区域,产生这种现象的原因就是申请事务id 的时间和产生快照的时间并不相同。
快照是什么时候产生的
快照的产生方式有两种情况:
-
手动执行 START TRANSACTION WITH CONSISTENT SNAPSHOT 时会立即产生快照(此时当前事务 id
就是未提交事务数组中的最大值)。 -
事务内第一次执行 select 查询语句之后产生快照(此时当前事务 id不一定是未提交事务数组中的最大值,开始事务后,产生快照前的这段时间可能会有其他事务提交,那么这些事务的数据对当前事务也必须可见,这就是上面提到的处于黄色区域而又不在未提交事务数组的情况)。
MVCC 查询示例
下图描述了一个 MVCC 的查询示例:
下面我们站在事务 2 的角度进行分析:
-
假设数据库已经存在一个事务,这时候同时启动了三个事务,事务 id 分别为 2,3,4。
-
事务 2 执行一条查询语句,此时会生成一个快照,而此时未提交的事务组成了一个数组 [2,3,4]。
-
事务 2 查询到的数据为右边绿色表格数据,经过对比发现 db_trx_id=1,比自己小,说明其是在自己创建事务之前提交的,对自己可见。
-
事务 3 也会产生一个视图,修改数据提交之后,数据对应为右边上面的那个表格。
-
此时事务 2 再次查询,查询到的是上面的红色表格中数据,此时发现db_trx_id=3,虽然其落在了黄色区域,但是其在未提交事务数组,说明这个事务至少在事务 2产生快照的时候还没提交的,所以不可见,但是事务 3 的更新的时候会在 db_roll_ptr 列插入上一个版本的数据地址,于是事务 2会根据这个指针找上一个版本,找到绿色的数据。
-
事务 4 提交。
-
事务 2 执行查询,这时候查询到最下面的红色数据,一看 db_trx_id=4 也在未提交数组,于是根据 db_roll_ptr往上找,依次类推,直到找到绿色数据。