事务的起源

对于大部分程序员来说,他们的任务就是把现实世界的业务场景映射到数据库世界。
比如银行为了存储客户的账户信息会建立一个 account 表:

  1. create table account (
  2. id INT NOT NULL AUTO_INCREMENT COMMENT '自增id',
  3. name VARCHAR(100) COMMENT '客户名称',
  4. balance INT COMMENT '余额',
  5. PRIMARY KEY (id)
  6. ) Engine=InnoDB CHARSET=utf8;

狗哥和猫爷是一对好基友,他们都到银行开了一个账户,他们在现实世界中拥有的资产就会体现在数据库世界的 account 表中。
比如现在狗哥有 11 元,猫爷只有 2 元,那么现实中的这个情况映射到数据库的 account 表如下图所示:
图片.png
在某个特定的时刻,狗哥、猫爷这些人在银行拥有的资产是一个特定的值,
这些特定的值也可以被描述为:账户在这个特定的时刻现实世界的一个状态。
随着时间的流逝,狗哥和猫爷可能陆续向账户中存钱、取钱或者向别人转账等操作,
这样他们账户中的余额就可能会发生变动,每一个操作都相当于现实世界中账户的一次状态转换。
数据库世界作为现实世界的一个映射,自然也要进行相应的变动。
然而,现实世界中一些看似很简单的状态转换,映射到数据库世界却不是那么容易实现。


比如:狗哥要给猫爷转账 10 元。
对于现实世界中的狗哥走向了 ATM 机,输入了猫爷的账号以及 10 元的转账金额,
然后按下确认,狗哥就拔卡走人了。
对于数据库世界来说,狗哥上面的行为相当于执行了下边这两条语句:
update account set balance = balance - 10 where id = 1;
update account set balance = balance + 10 where id = 2;
那么问题就来了:上述两条语句只执行了一条时,忽然服务器断电了怎么办?会出现把狗哥的钱扣了,但是没给猫爷转过去的情况。
即使对于单独的一条语句,前边介绍 Buffer Pool 时也说过,在对某个页面进行读写访问时,都会先把这个页面加载到 Buffer Pool 中,之后如果修改了某个页面,也不会立即把修改同步到磁盘,而只是把这个修改了的页面加到 Buffer Pool 的 flush 链表中,在之后的某个时间点才会刷新到磁盘。
如果在将修改过的页面刷新到磁盘之前,系统崩溃了,猫爷收不到转账
或者在刷新磁盘的过程中(只刷新部分数据到磁盘上)系统奔溃了,猫爷也收不到转账


怎么才能保证此次转账正常进行呢?
我们只是想让某些数据库操作符合现实世界中的状态转换的规则而已,现实世界中的状态转换的规则有好几条。
一般来说,事务是必须满足 4 个条件 (ACID):原子性 (Atomicity)、一致性 (Consistency)、隔离性 (Isolation )、持久性 (Durability)。

原子性

我们需要保证:转账操作是一个不可分割的操作,
也就是说:要么没有转账,要么转账成功,不能存在中间的状态,也就是转了一半的这种情况。
如果某些操作符合这种要么全做,要么全不做的规则,我们称这些操作满足原子性。

原子性:一个事务 (transaction) 中的所有操作,要么全部完成,要么全部不完成,不会结束在中间某个环节。
事务在执行过程中发生错误,会被回滚 (Rollback) 到事务开始前的状态,就像这个事务从来没有执行过一样。


但是在现实世界中的一个不可分割的操作,却可能对应着数据库世界若干条不同的操作,数据库中的一条操作也可能被分解成若干个步骤(比如先修改缓存页,之后再刷新到磁盘等)。
最要命的是:在任何一个可能的时间点都可能发生意想不到的错误(可能是数据库本身的错误,或者是操作系统错误,甚至是断电等)使操作执行不下去。
为了保证在数据库世界中某些操作的原子性,设计数据库的人需要费一些心机来保证:如果在执行操作的过程中发生了错误,就得把已经执行了的操作恢复成没执行之前的样子。

隔离性

现实世界中的两次状态转换应该是互不影响的,
比如:狗哥向猫爷同时进行的两次金额为 5 元的转账(假设可以在两个 ATM 机上同时操作),
那么,最后狗哥的账户里肯定会少 10 元,猫爷的账户里肯定会多 10 元。


但是到对应的数据库世界中,事情又变的复杂了。
为了简化问题,我们粗略的假设狗哥向猫爷转账 5 元的过程由下面几个步骤组成:

  1. 步骤一:读取狗哥账户的余额存储到变量 A ,这一步骤简写为 read(A)。
  2. 步骤二:将变量 A 减去转账金额,这一步骤简写为 A = A - 5。
  3. 步骤三:将变量 A 的值写回狗哥账户的余额,这一步骤简写为 write(A)。
  4. 步骤四:读取猫爷账户的余额存储到变量 B,这一步骤简写为 read(B)。
  5. 步骤五:将变量 B 加上转账金额,这一步骤简写为 B = B + 5。
  6. 步骤六:将变量 B 的值写回猫爷账户的余额,这一步骤简写为 write(B)。

将狗哥向猫爷同时进行的两次转账操作分别称为 T1 和 T2 。
在现实世界中 T1 和 T2 应该是没有关系的,
可以先执行完 T1,再执行 T2,或者先执行完 T2,再执行 T1,
对应的数据库操作如下图所示:
图片.png
但是真实的数据库中 T1 和 T2 的操作可能交替执行,如下图所示:
图片.png
所以,对于现实世界中状态转换对应的某些数据库操作来说,不仅要保证这些操作以原子性的方式执行完成,还要保证其它的状态转换不会影响到本次状态转换。
其它的状态转换不会影响到本次状态转换,这个规则被称为隔离性。
数据库允许多个并发事务同时对其数据进行读写和修改的能力,隔离性可以防止多个事务并发执行时由于交叉执行而导致数据的不一致。
设计数据库的人就需要采取一些措施来让访问相同数据(上例中的 A 账户和 B 账户)的不同状态转换(上例中的 T1 和 T2)对应的数据库操作的执行顺序有一定的规律。

一致性

这个世界存在着很多约束,比如:身份证号不能重复,性别只能是男 或者 女,红绿灯只有 3 种颜色,房价不能为负的,等等,只有符合这些约束的数据才是有效的。
数据库世界是现实世界的一个映射,现实世界中存在的约束当然也要在数据库世界中有所体现。
如果数据库中的数据全部符合现实世界中的约束 (all defined rules),
我们就说这些数据是一致的,或者说这些数据满足一致性。
如何保证数据库中数据的一致性(就是符合所有现实世界的约束)呢?这靠两方面的努力:

  1. 数据库本身能为我们保证一部分一致性需求。

数据库自身可以保证一部分现实世界的约束永远有效。
MySQL 数据库可以为表建立主键、唯一索引、外键、声明某个列为 not null 来拒绝 null 值的插入。
比如:当我们对某个列建立唯一索引,如果插入某条记录时,该列的值重复了,那么 MySQL 就会报错并且拒绝插入。
除了这些我们熟悉的保证一致性的功能,MySQL 还支持 check 语法来自定义约束,
比如:

  1. create table account (
  2. id INT NOT NULL AUTO_INCREMENT COMMENT '自增id',
  3. name VARCHAR(100) COMMENT '客户名称',
  4. balance INT COMMENT '余额',
  5. PRIMARY KEY (id),
  6. CHECK (balance >= 0)
  7. );

上述例子中的 check 语句本意是想对 balance 列定义一个约束:规定 balance 列不能存储小于 0 的数字,
对应的现实世界的意思就是:银行账户余额不能小于 0。
但是 MySQL 仅支持 check 语法,但实际上并不起约束作用,
也就是说:即使使用带有 check 子句的建表语句来创建 account 表,在后续插入或更新记录时,MySQL 并不会检查 check 子句中的约束条件是否成立。

小贴士:
其它的数据库,比如:SQL Server、Oracle 的 check 语法是起实际作用的, 在 SQL Server、Oracle 中,每次进行插入或更新记录前,都会检查一下数据是否符合 check 子句中指定的约束条件是否成立,如果不成立的话就会拒绝插入或更新。

虽然 MySQL 中的 check 子句对一致性检查不起实际的作用,但是我们还可以通过定义触发器的方式,来自定义一些约束条件,以保证数据库中数据的一致性。

  1. 更多的一致性需求需要靠写业务代码的程序员自己保证

为建立现实世界和数据库世界的对应关系,理论上应该把现实世界中的所有约束都反应到数据库世界中,但是在更改数据库数据时进行一致性检查是一个耗费性能的工作,
比如:我们为 account 表建立了一个触发器,每当插入或者更新记录时,都会校验一下 balance 列的值是不是大于 0,这就会影响插入或更新的速度。
现实生活中有很多复杂的一致性需求,由于性能问题,把一致性需求交给数据库去解决是不现实的,
所以一致性问题需要业务程序员在编写的业务代码中解决。
比如:account 表,我们也可以不建立触发器,只要编写的业务代码里判断一下,当某个操作会将 balance列的值更新为小于 0 的值时,不执行该操作,这样就解决了这个一致性问题。


原子性和隔离性会对一致性产生影响。
比如:我们现实世界中转账操作完成后,有一个一致性需求是参与转账的账户的总余额不变。
如果数据库不遵循原子性要求,也就是转账转了一半就不转了,也就是说给狗哥扣了钱而没给猫爷转过去,那最后就是不符合一致性需求的。
类似的,如果数据库不遵循隔离性要求,就像前边介绍隔离性时举的例子中所说的,最终狗哥账户中扣的钱和猫爷账户中转入的钱可能不一样,也不符合一致性需求了。
所以说,数据库某些操作的原子性和隔离性是保证一致性的一种手段,在操作执行完成后保证符合制定的所有约束则是一种结果。
满足原子性和隔离性的操作不一定满足一致性,比如:狗哥要转账 20 元给猫爷,虽然可以满足原子性和隔离性,但转账完成后,狗哥的账户余额就成负数了,这显然是不满足一致性的。
不满足原子性和隔离性的操作不一定不满足一致性,只要最后的结果符合所有现实世界中的约束,那么就是符合一致性的。

持久性

现实世界的一个状态转换完成后,这个转换的结果将永久被保留。
持久性:事务处理结束后,对数据的修改是永久的,即便系统故障也不会丢失。
比如:狗哥向猫爷转账,当 ATM 机提示转账成功了,这就意味着这次账户的状态转换完成了,狗哥收到了款项。
当把现实世界的状态转换映射到数据库世界时,持久性意味着:该转换对应的数据库操作所修改的数据都应该在磁盘上保留下来,不论之后发生了什么事故,本次转换造成的影响都不应该丢失。

事务的概念

事务是一个抽象的概念,它对应着一个或多个数据库操作。
根据这些操作所执行的不同阶段,事务大致分为以下几种状态:
活动的 (active):当事务中的数据库操作正在执行时,该事务处于活动的状态。


部分提交的 (partially committed):当事务中的最后一个数据库操作执行完成,但由于操作都在内存中执行,操作所造成的影响并没有刷新到磁盘时,该事务处于部分提交的状态。


失败的 (failed):当事务处于活动的或者部分提交的状态时,可能会遇到某种错误(数据库自身的错误、操作系统错误或者断电等)而无法继续执行,或者人为的停止当前事务的执行,称该事务处于失败的状态。


中止的 (aborted):如果事务执行了一半而变为失败的状态,
比如:狗哥向猫爷转账的事务,当狗哥账户的钱被扣除了,但是猫爷账户的钱还没有增加时遇到了错误,那么就需要把已经修改的狗哥账户余额调整为未转账之前的金额。
换句话说,就是要撤销失败事务对当前数据库造成的影响。这个撤销的过程被称为回滚。
当回滚操作执行完后,数据库恢复到了执行事务之前的状态,该事务处于中止的状态。


提交的 (committed):当一个处于部分提交的状态的事务将修改后的数据同步到磁盘上后,该事务处于提交的状态。


随着事务对应的数据库操作执行到不同的阶段,事务的状态也在不断变化,状态转换如下图所示:
图片.png
从图中可以看出,只有当事务处于提交的或者中止的状态时,一个事务的生命周期才算是结束了。
对于处于提交的状态的事务来说,该事务对数据库所做的修改将永久生效,
对于处于中止的状态的事务来说,该事务对数据库所做的修改都会被回滚到没执行该事务之前的状态。

MySQL 中事务的语法

开启事务

开启事务后,就可以继续写若干条语句,这些语句都属于开启的这个事务。
我们可以使用下边两种语句之一来开启一个事务:
begin [work];
begin 语句代表开启一个事务,后边的单词 work 可有可无。


start transaction;
该语句和 begin 语句有着相同的功效,都标志着开启一个事务。


不过 start transaction 比 begin 语句强大的一点是:
可以在 start transaction 语句后面跟随几个修饰符,用来设置事务的访问模式,修饰符如下所示:

  • read only:标识当前事务是一个只读事务

也就是说,属于该事务的数据库操作只能读取数据,而不能修改数据。

  • read write:标识当前事务是一个读写事务,

也就是说,属于该事务的数据库操作既可以读取数据,也可以修改数据。

  • with consistent snapshot:启动一致性读。

如果我们想在 start transaction 后面跟随多个修饰符的话,使用逗号将修饰符分开即可,
比如:开启一个只读事务和一致性读,
就可以这样写: start transaction read only, with consistent snapshot
如果不显式的指定事务的访问模式,那么该事务的访问模式默认为:读写模式。

提交事务

开启事务之后,就可以继续写需要放到该事务中的语句了,当最后一条语句写完后,
我们就可以提交该事务了,提交的语句:commit [work];

手动中止事务

如果我们写了几条语句之后发现上边的某条语句写错了,
我们可以回滚语句,将数据库恢复到事务执行之前的样子:rollback [work];
rollback 语句就代表中止并回滚一个事务,
rollback 语句是我们程序员手动的去回滚事务时才去使用的,
如果事务在执行过程中遇到了某些错误而无法继续执行的话,事务自身会自动的回滚。

支持事务的存储引擎

MySQL 中并不是所有的存储引擎都支持事务的功能,目前只有 InnoDB 和 NDB 存储引擎支持事务。
如果某个事务中包含了修改使用不支持事务的存储引擎的表,那么对该表所做的修改将无法进行回滚。

自动提交

MySQL中有一个系统变量 autocommit:show variables like ‘autocommit’;
图片.png
可以看到 autocommit 的值默认值为 on,
也就是说,默认情况下,如果我们不显式的开启一个事务,
那么每一条语句都算是一个独立的事务,这种特性被称为事务的自动提交。
如果我们想关闭自动提交的功能,可以使用下边两种方法之一:

  • 显式的的使用 start transaction 或者 begin 语句开启一个事务。

这样在本次事务提交或者回滚前会暂时关闭自动提交的功能。

  • 把系统变量 autocommit 的值设置为 off:set autocommit = off;

这样的话,我们写入的多条语句就算是属于同一个事务了,直到我们显式的写出 commit 语句把这个
事务提交,或者显式的写出 rollback 语句把这个事务回滚。

隐式提交

当我们显式的开启一个事务,或者把系统变量 autocommit 的值设置为 off时,事务就不会进行自动提交。
但是如果我们输入了某些语句之后,事务就会被悄悄的提交,就像我们输入了 commit 语句了一样。
这种因为某些特殊的语句而导致事务提交的情况称为隐式提交。
会导致事务隐式提交的语句包括:

  • 定义或修改数据库对象的数据定义语言 (Data definition language)。

所谓的数据库对象,指的就是:数据库、表、视图、存储过程等。
当我们使用 create、alter、delete 等语句去修改这些数据库对象时,就会隐式的提交前面语句所属
的事务

  • 隐式使用或修改 MySQL 数据库中的表

当我们使用 alter user、create user、drop user、grant、rename user、revoke、set password 等语句
时,也会隐式的提交前边语句所属的事务

  • 事务控制或关于锁定的语句

当我们在一个事务还没有提交或者回滚时就又使用了 start transaction 或者 begin 语句显示的开启了
另一个事务时,会隐式的提交上一个事务。
当前的 autocommit 系统变量的值为 off,我们把它调为 on 时,会隐式的提交前边语句所属的事务。
使用 lock tables、unlock tables 等关于锁定的语句,也会隐式的提交前边语句所属的事务。

  • 加载数据的语句

当我们使用 load data 语句来批量往数据库中导入数据时,也会隐式的提交前边语句所属的事务。

  • 关于 MySQL 复制的一些语句

使用 start slave、stop slave、reset slave、change master to 等语句时,也会隐式的提交前边语句所
属的事务。

  • 其它的一些语句

使用 analyze table、cache index、check table、flush、load index into cache、optimize table、
repair table、reset 等语句,也会隐式的提交前边语句所属的事务。

保存点

设计数据库的人提出了保存点 (savepoint) 的概念。
就是在事务对应的数据库语句中打几个点,我们在调用 rollback 语句时,可以指定回滚到哪个点,回滚到事务执行之前的状态。定义保存点的语法:savepoint 保存点名称;
当我们想回滚到某个保存点时,可以使用这个语句:rollback [work] to [savepoint] 保存点名称;
单词 work 和 savepoint 可有可无。
如果 rollback 语句后没有跟随保存点名称的话,会直接回滚到事务执行之前的状态。
如果我们想删除某个保存点,可以使用这个语句:release savepoint 保存点名称;