一、事务基础知识

1.1 数据库事务概述

事务是数据库区别于文件系统的重要特性之一,当我们有了事务就会让数据库始终保持一致性 ,同时我们还能通过事务的机制 恢复到某个时间点,这样可以保证已提交到数据库的修改不会因为系统崩溃而丢失。

1.1.1 存储引擎支持情况

SHOW ENGINES 命令来查看当前 MySQL 支持的存储引擎都有哪些,以及这些存储引擎是否支持事务。
image.png
能看出在 MySQL 中,只有InnoDB 是支持事务的。

1.1.2 基本概念

事务:一组逻辑操作单元,使数据从一种状态变换到另一种状态。
事务处理的原则:保证所有事务都作为一个工作单元来执行,即使出现了故障,都不能改变这种执行方
式。当在一个事务中执行多个操作时,要么所有的事务都被提交(commit),那么这些修改就永久地保存下来;要么数据库管理系统将放弃所作的所有修改,整个事务回滚( rollback )到最初状态。

#案例:AA用户给BB用户转账100
update account set money = money - 100 where name = 'AA';
#服务器宕机
update account set money = money + 100 where name = 'BB';

1.1.3 事务的ACID特性

  • 原子性(atomicity)
    原子性是指事务是一个不可分割的工作单位,要么全部提交,要么全部失败回滚。即要么转账成功,要么转账失败,是不存在中间的状态。如果无法保证原子性会怎么样?就会出现数据不一致的情形,A账户减去100元,而B账户增加100元操作失败,系统将无故丢失100元。

  • 一致性(consistency):
    (国内很多网站上对一致性的阐述有误,具体你可以参考 Wikipedia 对Consistency 的阐述)
    根据定义,一致性是指事务执行前后,数据从一个合法性状态变换到另外一个合法性状态。这种状态是语义上的而不是语法上的,跟具体的业务有关。
    那什么是合法的数据状态呢?满足预定的约束的状态就叫做合法的状态。通俗一点,这状态是由你自己来定义的(比如满足现实世界中的约束)。满足这个状态,数据就是一致的,不满足这个状态,数据就是不一致的!如果事务中的某个操作失败了,系统就会自动撤销当前正在执行的事务,返回到事务操作
    之前的状态。
    举例1:A账户有200元,转账300元出去,此时A账户余额为-100元。你自然就发现了此时数据是不一致的,为什么呢?因为你定义了一个状态,余额这列必须>= 0。
    举例2:A账户200元,转账50元给B账户,A账户的钱扣了,但是B账户因为各种意外,余额并没有增加。你也知道此时数据是不一致的,为什么呢?因为你定义了一个状态,要求A+B的总余额必须不变。
    举例3:在数据表中我们将 姓名 字段设置为 唯一性约束,这时当事务进行提交或者事务发生回滚的时候,如果数据表中的姓名不唯一,就破坏了事务的一致性要求。

  • 隔离型(isolation):
    事务的隔离性是指一个事务的执行不能被其他事务干扰,即一个事务内部的操作及使用的数据对并发的其他事务是隔离的,并发执行的各个事务之间不能互相干扰
    如果无法保证隔离性会怎么样?假设A账户有200元,B账户0元。A账户往B账户转账两次,每次金额为50元,分别在两个事务中执行。如果无法保证隔离性,会出现下面的情形:

UPDATE accounts SET money = money - 50 WHERE NAME = 'AA';
UPDATE accounts SET money = money + 50 WHERE NAME = 'BB';

image.png

  • 持久性(durability):
    持久性是指一个事务一旦被提交,它对数据库中数据的改变就是永久性的,接下来的其他操作和数据库故障不应该对其有任何影响。
    持久性是通过事务日志来保证的。日志包括了重做日志回滚日志。当我们通过事务对数据进行修改的时候,首先会将数据库的变化信息记录到重做日志中,然后再对数据库中对应的行进行修改。这样做的好处是,即使数据库系统崩溃,数据库重启后也能找到没有更新到数据库系统中的重做日志,重新执
    行,从而使事务具有持久性。

总结:
ACID 是事务的四大特性,在这四个特性中,原子性是基础,隔离性是手段,一致性是约束条件,而持久性是我们的目的。
数据库事务,其实就是数据库设计者为了方便起见,把需要保证 原子性隔离性一致性持久性 的一个或多个数据库操作称为一个事务。

1.1.4 事务的状态

我们现在知道事务是一个抽象的概念,它其实对应着一个或多个数据库操作,MySQL根据这些操作所执行的不同阶段把事务大致划分成几个状态:

  • 活动的(active)
    事务对应的数据库操作正在执行过程中时,我们就说该事务处在活动的状态.
  • 部分提交的(partially committed)
    当事务中的最后一个操作执行完成,但由于操作都在内存中执行,所造成的影响并没有刷新到磁盘时,我们就说该事务处在部分提交的状态
  • 失败的(failed)
    当事务处在活动的或者部分提交的状态时,可能遇到了某些错误(数据库自身的错误、操作系统错误或者直接断电等)而无法继续执行,或者人为的停止当前事务的执行,我们就说该事务处在失败的状态。
  • 中止的(aborted)
    如果事务执行了一部分而变为失败的状态,那么就需要把已经修改的事务中的操作还原到事务执行前的状态。换句话说,就是要撤销失败事务对当前数据库造成的影响。我们把这个撤销的过程称
    之为回滚。当回滚操作执行完毕时,也就是数据库恢复到了执行事务之前的状态,我们就说该事务处在了中止的状态
    举例:
UPDATE accounts SET money = money - 50 WHERE NAME = 'AA';
UPDATE accounts SET money = money + 50 WHERE NAME = 'BB';
  • 提交的(committed)
    当一个处在部分提交的状态的事务将修改过的数据都同步到磁盘上之后,我们就可以说该事务处在了提交的状态。
    一个基本的状态转换图如下所示:
    image.png

1.2. 如何使用事务

使用事务有两种方式,分别为显式事务隐式事务

1.2.1 显式事务

步骤1: START TRANSACTION 或者 BEGIN ,作用是显式开启一个事务。

mysql> BEGIN;
#或者
mysql> START TRANSACTION;

START TRANSACTION 语句相较于BEGIN 特别之处在于,后边能跟随几个修饰符:

  1. READ ONLY :标识当前事务是一个只读事务,也就是属于该事务的数据库操作只能读取数据,而不能修改数据。

    补充:只读事务中只是不允许修改那些其他事务也能访问到的表中的数据,对于临时表来说(我们使用CREATE TMEPORARY TABLE创建的表),由于它们只能在当前会话中可见,所以只读事务其实也是可以对临时表进行增、删、改操作的。

  2. READ WRITE (默认):标识当前事务是一个读写事务,也就是属于该事务的数据库操作既可以读取数据,也可以修改数据。
  3. WITH CONSISTENT SNAPSHOT :启动一致性读。

步骤2:一系列事务中的操作(主要是DML,不含DDL)

步骤3:事务结束:提交事务 或 中止事务(即回滚事务)

# 提交事务。当提交事务后,对数据库的修改是永久性的。
mysql> COMMIT;
# 回滚事务。即撤销正在进行的所有没有提交的修改
mysql> ROLLBACK;
# 将事务回滚到某个保存点(savepoint)。
mysql> ROLLBACK TO [SAVEPOINT]

其中关于SAVEPOINT相关操作有:

#在事务中创建保存点,方便后续针对保存点进行回滚。一个事务中可以存在多个保存点。
SAVEPOINT 保存点名称;
#删除某个保存点。
RELEASE SAVEPOINT保存点名称:

1.2.2 隐式事务

MySQL中有一个系统变量autocommit

mysql> SHOW VARIABLES LIKE 'autocommit';
+---------------+-------+
| Variable_name | Value |
+---------------+-------+
| autocommit    | ON    |
+---------------+-------+
1 row in set (0.01 sec)

当然,如果我们想关闭这种自动提交的功能,可以使用下边两种方法之一:
显式的的使用START TRANSACTION 或者BEGIN语句开启一个事务。这样在本次事务提交或者回滚前会暂时关闭掉自动提交的功能。
方式1:
把系统变量 autocommit 的值设置为 OFF ,就像这样:

SET autocommit = FALSE; #针对DML操作是有效的,对DDL操作是无效的

SET autocommit = OFF;
#或
SET autocommit = 0;

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

补充:0racle 默认不自动提交,需要手写 COMMIT 命令,而 MySQL 默认自动提交。

方式2:
我们在autocomnit为true的情况下,使用start transaction 或begin开启事务,那么DM操作就不会自动提交数据。相当于用显示提交覆盖了默认的隐式提交。

1.2.3 隐式提交数据的情况

  • 数据定义语言(Data definition language,缩写为:DDL)
    数据库对象,指的就是 数据库视图存储过程 等结构。当我们使用 CREATEALTERDROP 等语句去修改数据库对象时,就会隐式的提交前边语句所属于的事务。即:
BEGIN;

SELECT...#事务中的一条语句
UPDATE...#事务中的一条语句
...#事务中的其它语句

CREATE TABLE ... #此语句会隐式的提交前边语句所属于的事务
  • 隐式使用或修改mysql数据库中的表当我们使用ALTER USERCREATE USERDROP USERGRANTRENAME USERREVOKESET PASSWORD 等语句时也会隐式的提交前边语句所属于的事务。

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

    1. 当我们在一个事务还没提交或者回滚时就又使用START TRANSACTION 或者BEGIN 语句开启了另一个事务时,会隐式的提交上一个事务。即:
    BEGIN;
    SELECT...#事务中的一条语句
    UPDATE...#事务中的一条语句
    ...#事务中的其它语句
    
    BEGIN; # 此语句会隐式的提交前边语句所属于的事务。
    
    1. 当前的 autocommit 系统变量的值为 OFF ,我们手动把它调为 ON 时,也会隐式的提交前边语句所属的事务
    2. 使用LOCK TABLESUNLOCK TABLES 等关于锁定的语句也会隐式的提交前边语句所属的事务。
  • 加载数据的语句
    使用 LOAD DATA语句来批量往数据库中导入数据时,也会 隐式的提交 前边语句所属的事务。

  • 关于MySQL复制的一些语句
    使用 START SLAVE、STOP SLAVERESET SLAVECHANGE MASTER TO 等语句时会 隐式的提交 前边语句所属的事务。

  • 其它的一些语句
    使用 ANALYZE TABLECACHE INDEXCHECK TABLEFLUSHLOAD INDEX INTO CACHEOPTIMIZE TABLEREPAIR TABLERESET 等语句也会隐式的提交前边语句所属的事务。

1.2.4 使用举例1:提交与回滚

我们看下在 MySQL 的默认状态下,下面这个事务最后的处理结果是什么。
情况1:

CREATE TABLE user(name varchar(20), PRIMARY KEY (name)) ENGINE=InnoDB;

BEGIN;
INSERT INTO user SELECT '张三';
COMMIT;

BEGIN;
INSERT INTO user SELECT '李四';
INSERT INTO user SELECT '李四';
ROLLBACK;

SELECT * FROM user;

运行结果(1 行数据):

mysql> commit;
Query OK, 0 rows affected (0.00 秒)

mysql> BEGIN;
Query OK, 0 rows affected (0.00 秒)

mysql> INSERT INTO user SELECT '李四';
Query OK, 1 rows affected (0.00 秒)

mysql> INSERT INTO user SELECT '李四';
Duplicate entry '李四' for key 'user.PRIMARY'
mysql> ROLLBACK;
Query OK, 0 rows affected (0.01 秒)

mysql> select * from user;
+--------+
| name   |
+--------+
| 张三    |
+--------+
1 行于数据集 (0.01 秒)

情况2:

CREATE TABLE user (name varchar(20), PRIMARY KEY (name)) ENGINE=InnoDB;

BEGIN;
INSERT INTO user SELECT '张三';
COMMIT;
# 插入成功,默认提交了
INSERT INTO user SELECT '李四';
# 插入失败,重复
INSERT INTO user SELECT '李四';
# 回滚到上次提交的地方
ROLLBACK;

运行结果(2 行数据):

mysql> SELECT * FROM user;
+--------+
| name   |
+--------+
| 张三    |
| 李四    |
+--------+
2 行于数据集 (0.01 秒)

情况3:

CREATE TABLE user(name varchar(255), PRIMARY KEY (name)) ENGINE=InnoDB;

SET @@completion_type = 1;
BEGIN;
INSERT INTO user SELECT '张三';
COMMIT;

INSERT INTO user SELECT '李四';
INSERT INTO user SELECT '李四';
ROLLBACK;

SELECT * FROM user;

运行结果(1 行数据):

mysql> SELECT * FROM user;
+--------+
| name   |
+--------+
| 张三    |
+--------+
1 行于数据集 (0.01 秒)

你能看到相同的 SQL 代码,只是在事务开始之前设置了 SET @@completion_type = 1;,结果就和我们第一次的处理一样,只有一个“张三”。这是为什么呢?
这里我讲解下 MySQL 中 completion_type 参数的作用,实际上这个参数有3种可能:

  1. completion=0,这是默认情况。当我们执行 COMMIT的时候会提交事务,在执行下一个事务时,还需要使用 START TRANSACTION 或者 BEGIN 来开启。
  2. completion=1,这种情况下,当我们提交事务后,相当于执行了 COMMIT AND CHAIN,也就是开启一个链式事务,即当我们提交事务之后会开启一个相同隔离级别的事务
  3. completion=2,这种情况下 COMMIT=COMMIT AND RELEASE,也就是当我们提交后,会自动与服务器断开连接。

当我们设置 autocommit=0 时,不论是否采用 START TRANSACTION 或者 BEGIN 的方式来开启事务,都需要用 COMMIT 进行提交,让事务生效,使用 ROLLBACK 对事务进行回滚。
当我们设 autocommit=1 时,每条 SQL 语句都会自动进行提交。 不过这时,如果你采用 START TRANSACTION 或者 BEGIN 的方式来显式地开启事务,那么这个事务只有在 COMMIT 时才会生效,在 ROLLBACK 时才会回滚。

1.2.5 使用举例2:测试不支持事务的engine

体会InnoDB 和 MyISAM之间的区别


CREATE TABLE test1(i INT)ENGINE = INNODB;
CREATE TABLE test2(i INT)ENGINE = MYISAM;
# InnoDB
BEGIN
INSERT INTO test1 VALUES(1);
ROLLBACK;
SELECT * FROM test1;
# 回滚后 1 插入失败

# MyISAM 不支持事务
BEGIN
INSERT INTO test1 VALUES(1);
ROLLBACK;
SELECT * FROM test1;
# 回滚后 1 插入成功

1.2.6 使用举例3:SAVEPOINT

CREATE TABLE user3(NAME VARCHAR(15), balance DECIMAL(10,2));

BEGIN
INSERT INTO user3(NAME,balance) VALUES(`张三`, 1000);
COMMIT;

SELECT * FROM user3;

BEGIN;
UPDATE user3 SET balance - 100 WHERE NAME = '张三';

#设置保存点
SAVEPOINT s1;

# 误操作
UPDATE user3 SET balance = balance + 1 WHERE NAME= '张三';

# 回滚到保存点
ROLLBACK TO s1; #事务还没有结束。

SELECT * FROM user3;

ROLLBACK; #回滚操作

1.3事务隔离级别

MySQL是一个 客户端/服务器 架构的软件,对于同一个服务器来说,可以有若干个客户端与之连接,每个客户端与服务器连接上之后,就可以称为一个会话(Session)。每个客户端都可以在自己的会话中向服务器发出请求语句,一个请求语句可能是某个事务的一部分,也就是对于服务器来说可能同时处理多个事务。事务有 隔离性 的特性,理论上在某个事务 对某个数据进行访问 时,其他事务应该进行 排队,当该事务提交之后,其他事务才可以继续访问这个数据。但是这样对 性能影响太大,我们既想保持事务的隔离性,又想让服务器在处理访问同一数据的多个事务时 性能尽量高些,那就看二者如何权衡取舍了。

1.3.1 数据准备

我们需要创建一个表:

CREATE TABLE student (
	studentno INT,
	name VARCHAR(20),
	class varchar(20),
	PRIMARY KEY (studentno)
) Engine=InnoDB CHARSET=utf8;

然后向这个表里插入一条数据:

INSERT INTO student VALUES(1, '小谷', '1班');

现在表里的数据就是这样的:

mysql> select * from student;
+-----------+--------+-------+
| studentno | name   | class |
+-----------+--------+-------+
|   1       | 小谷    | 1班   |
+-----------+--------+-------+
1 row in set (0.00 sec)

1.3.2 数据并发问题

针对事务的隔离性和并发性,我们怎么做取舍呢?先看一下访问相同数据的事务在不保证串行执行(也就是执行完一个再执行另一个)的情况下可能会出现哪些问题:

  1. 脏写( Dirty Write )
    对于两个事务 Session A、Session B,如果事务Session A 修改了另一个未提交事务Session B 修改过的数据,那就意味着发生了脏写;
    image.png
    Session A和Session B各开启了一个事务,Session B中的事务先将studentno列为1的记录的name列更新为'李四',然后Session A中的事务接着又把这条studentno列为1的记录的name列更新为'张三'。如果之后Session B中的事务进行了回滚,那么Session A中的更新也将不复存在,这种现象就称之为脏写。这时session A中的事务就没有效果。明明把数据更新了,最后也提交事务了,最后看到的数据什么变化也没有。这里大家对事务的隔离级比较了解的话,会发现默认隔离级别下,上面SessionA中的更新语句会处于等待状态,这里只是跟大家说明一下会出现
    这样现象。

  2. 脏读( Dirty Read )
    对于两个事务 Session A、Session B,Session A 读取已经被 Session B 更新但还没有被提交的字段。之后若 Session B 回滚,Session A 读取的内容就是临时且无效的
    image.png

Session A和Session B各开启了一个事务,Session B中的事务先将studentno列为1的记录的name列更新为'张三',然后Session A中的事务再去查询这条studentno为1的记录,如果读到列name的值为'张三',而Session B中的事务稍后进行了回滚,那么Session A中的事务相当于读到了一个不存在的数据,这种现象就称之为脏读。

  1. 不可重复读( Non-Repeatable Read )
    对于两个事务Session A、Session B,Session A 读取了一个字段,然后 Session B 更新了该字段。 之后Session A 再次读取同一个字段, 值就不同了。那就意味着发生了不可重复读
    image.png

我们在Session B中提交了几个隐式事务(注意是隐式事务,意味着语句结束事务就提交了),这些事务都修改了studentno列为1的记录的列name的值,每次事务提交之后,如果Session A中的事务都可以查看到最新的值,这种现象也被称之为不可重复读

  1. 幻读( Phantom )
    对于两个事务Session A、Session B, Session A 从一个表中读取了一个字段, 然后 Session B 在该表中插入了一些新的行。 之后, 如果 Session A 再次读取同一个表, 就会多出几行。那就意味着发生了幻读
    image.png

Session A中的事务先根据条件 studentno > 0这个条件查询表student,得到了name列值为'张三'的记录;之后Session B中提交了一个隐式事务,该事务向表student中插入了一条新记录;之后Session A中的事务再根据相同的条件 studentno > 0查询表student,得到的结果集中包含Session B中的事务新插入的那条记录,这种现象也被称之为幻读。我们把新插入的那些记录称之为幻影记录
注意1:
有的同学会有疑问,那如果 SessionB中删除了一些符合studentno >0的记录而不是插入新记录,那Session A之后再根据 studentno > 0的条件读取的 记录变少了 ,这种现象算不算 幻读 呢?这种现象 不属于幻读,幻读强调的是一个事务按照某个 相同条件多次读取 记录时,后读取时读到了之前 没有读到的记录
注意2:
那对于先前已经读到的记录,之后又读取不到这种情况,算啥呢?这相当于对每一条记录都发生了 不可重复读 的现象。幻读只是重点强调了读取到了之前读取没有获取到的记录

1.3.3 SQL中的四种隔离级别

上面介绍了几种并发事务执行过程中可能遇到的一些问题,这些问题有轻重缓急之分,我们给这些问题按照严重性来排一下序:

脏写 > 脏读 > 不可重复读 > 幻读

我们愿意舍弃一部分隔离性来换取一部分性能在这里就体现在:设立一些隔离级别,隔离级别越低,并发问题发生的就越多。SQL标准中设立了4个隔离级别:

  • READ UNCOMMITTED :读未提交,在该隔离级别,所有事务都可以看到其他未提交事务的执行结果。不能避免脏读、不可重复读、幻读。
  • READ COMMITTED :读已提交,它满足了隔离的简单定义:一个事务只能看见已经提交事务所做的改变。这是大多数数据库系统的默认隔离级别(但不是MySQL默认的)。可以避免脏读,但不可重复读幻读问题仍然存在。
  • REPEATABLE READ :可重复读,事务A在读到一条数据之后,此时事务B对该数据进行了修改并提交,那么事务A再读该数据,读到的还是原来的内容。可以避免脏读、不可重复读,但幻读问题仍然存在。这是MySQL的默认隔离级别
  • SERIALIZABLE :可串行化,确保事务可以从一个表中读取相同的行。在这个事务持续期间,禁止其他事务对该表执行插入、更新和删除操作所有的并发问题都可以避免,但性能十分低下。能避免脏读、不可重复读和幻读。
    SQL标准中规定,针对不同的隔离级别,并发事务可以发生不同严重程度的问题,具体情况如下:
    image.png
    脏写怎么没涉及到?因为脏写这个问题太严重了,不论是哪种隔离级别,都不允许脏写的情况发生
    不同的隔离级别有不同的现象,并有不同的锁和并发机制,隔离级别越高,数据库的并发性能就越差,4种事务隔离级别与并发性能的关系如下:
    image.png

1.3.4 MySQL支持的四种隔离级别

MySQL的默认隔离级别为REPEATABLE READ,我们可以手动修改一下事务的隔离级别。

# 查看隔离级别,MySQL 5.7.20的版本之前:
mysql> SHOW VARIABLES LIKE 'tx_isolation';
+---------------+-----------------+
| Variable_name | Value           |
+---------------+-----------------+
| tx_isolation  | REPEATABLE-READ |
+---------------+-----------------+
1 row in set (0.00 sec)

# MySQL 5.7.20版本之后,引入transaction_isolation来替换tx_isolation

# 查看隔离级别,MySQL 5.7.20的版本及之后:
mysql> SHOW VARIABLES LIKE 'transaction_isolation';
+-----------------------+-----------------+
| Variable_name         | Value           |
+-----------------------+-----------------+
| transaction_isolation | REPEATABLE-READ |
+-----------------------+-----------------+
1 row in set (0.02 sec)

#或者不同MySQL版本中都可以使用的:
SELECT @@transaction_isolation;

1.3.5 如何设置事务的隔离级别

通过下面的语句修改事务的隔离级别:

SET [GLOBAL|SESSION] TRANSACTION ISOLATION LEVEL 隔离级别;
#其中,隔离级别格式:
> READ UNCOMMITTED
> READ COMMITTED
> REPEATABLE READ
> SERIALIZABLE

或者:

SET [GLOBAL|SESSION] TRANSACTION_ISOLATION = '隔离级别'

#其中,隔离级别格式:
> READ-UNCOMMITTED
> READ-COMMITTED
> REPEATABLE-READ
> SERIALIZABLE

关于设置时使用GLOBAL或SESSION的影响:

  • 使用GLOBAL 关键字(在全局范围影响):
    SET GLOBAL TRANSACTION ISOLATION LEVEL SERIALIZABLE;
    #或
    SET GLOBAL TRANSACTION_ISOLATION = 'SERIALIZABLE';
    
    则:
    • 当前已经存在的会话无效
    • 只对执行完该语句之后产生的会话起作用
  • 使用SESSION 关键字(在会话范围影响):
SET SESSION TRANSACTION ISOLATION LEVEL SERIALIZABLE;

#或

SET SESSION TRANSACTION_ISOLATION = 'SERIALIZABLE';

则:

  • 对当前会话的所有后续的事务有效
  • 如果在事务之间执行,则对后续的事务有效
  • 该语句可以在已经开启的事务中间执行,但不会影响当前正在执行的事务

小结:
数据库规定了多种事务隔离级别,不同隔离级别对应不同的干扰程度,隔离级别越高,数据一致性就越好,但并发性越弱。

1.3.6 不同隔离级别举例

数据准备:

CREATE TABLE account(
 id INT PRIMARY KEY AUTO_INCREMENT,
 NAME VARCHAR(15),
 balance DECIMAL(10,2)
);


INSERT INTO account VALUES(1,'张三','100'),(2,'李四','');


演示1. 读未提交之脏读

SET session transaction_isolation = 'read-uncommitted';

SELECT @@transaction_isolation;

BEGIN;
UPDATE account SET balance = balance + 100 where id = 1;

设置隔离级别为未提交读:
image.png
事务1和事务2的执行流程如下:
image.png

演示2:读已提交
image.png
设置隔离级别为可重复读,事务的执行流程如下:
image.png

演示4:幻读
image.png
这里要灵活的 理解读取 的意思,第一次select是读取,第二次的 insert 其实也属于隐式的读取,只不过是在 mysql的机制中读取的,插入数据也是要先读取一下有没有主键冲突才能决定是否执行插入。
幻读,并不是说两次读取获取的结果集不同,幻读侧重的方面是某一次的 select 操作得到的结果所表征的数据状态无法支撑后续的业务操作。更为具体一些:select 某记录是否存在,不存在,准备插入此记录,但执行insert时发现此记录已存在,无法插入,此时就发生了幻读
在 RR 隔离级别下,step1、step2是会正常执行的,step3 则会报错主键冲突,对于事务1的业务来说是执行失败的,这里事务1就是发生了幻读,因为事务1在 step1 中读取的数据状态并不能支撑后续的业务操作,事务1:“见鬼了,我刚才读到的结果应该可以支持我这样操作才对啊,为什么现在不可以”。事务1 不敢相信的又执行了,发现和 setp1 读取的结果是一样的(RR下的 MVCC机制)。此时,幻读无疑已经发生,事务1 无论读取多step4,少次,都査不到 id=3的记录,但它的确无法插入这条他通过读取来认定不存在的记录(此数据已被事务2插对于 事务1 来说,它幻读了。
其实 RR 也是可以避免幻读的,通过对 select 操作手动加 行X锁(独占锁)(SELECT... FOR UPDATE 这也正是SERIALIZABLE 隔离级别下会隐式为你做的事情)。同时,即便当前记录不存在,比如id =3是不存在的,当前事务也会获得一把记录锁(因为lnnoDB的行锁锁定的是索引,故记录实体存在与否没关系,存在就加 行X锁 不存在就加 间隙锁 ),其他事务则无法插入此索引的记录,故杜绝了幻读。
SERIALIZABLE 隔离级别 下,step1 执行时是会隐式的添加 行(X)锁/gap(X)锁的,从而 step2 会被阻塞step3 会正常执行待事务1提交后,事务2才能继续执行(主键冲突执行失败),对于 事务1 来说业务是正确的,成功的阻塞扼杀了扰乱业务的事务2,对于事务1来说他前期读取的结果是可以支撑其后续业务的。
所以 MySQL的幻读并非什么读取两次返回结果集不同,而是事务在插入事先检测不存在的记录时,惊奇的发现这些数据已经存在了,之前的检测读获取到的数据如同鬼影一般

1.4事务的常见分类

从事务理论的角度来看,可以把事务分为以下几种类型:

  1. 扁平事务(Flat Transactions)
    扁平事务 是事务类型中最简单的一种,但是在实际生产环境中,这可能是使用最频繁的事务,在扁平事务中,所有操作都处于同一层次,其由BEGIN WORK开始,由COMMIT WORKROLLBACK WORK结束,其间的操作是原子的,要么都执行,要么都回滚,因此,扁平事务是应用程序成为原子操作的基本组成模块。扁平事务虽然简单,但是在实际环境中使用最为频繁,也正因为其简单,使用频繁,故每个数据库系统都实现了对扁平事务的支持。扁平事务的主要限制是不能提交或者回滚事务的某一部分,或分几个步骤提交。
    扁平事务一般有三种不同的结果:①事务成功完成。在平常应用中约占所有事务的96%。②应用程序要求停止事务。比如应用程序在捕获到异常时会回滚事务,约占事务的3%。③外界因素强制终止事务。如连接超时或连接断开,约占所有事务的1%。

  2. 带有保存点的扁平事务(Flat Transactions with Savepoints)
    带有保存点的扁平事务 除了支持扁平事务支持的操作外,还允许在事务执行过程中回滚到同一事务中较早的一个状态。这是因为某些事务可能在执行过程中出现的错误并不会导致所有的操作都无效,放弃整个事务不合乎要求,开销太大。
    保存点(Savepoint)用来通知事务系统应该记住事务当前的状态,以便当之后发生错误时,事务能回到保存点当时的状态。对于扁平的事务来说,隐式的设置了一个保存点,然而在整个事务中,只有这一个保存点,因此,回滚只能会滚到事务开始时的状态。

  3. 链事务(Chained Transactions)
    链事务 是指一个事务由多个子事务链式组成,它可以被视为保存点模式的一个变种。带有保存点的扁平事务,当发生系统崩溃时,所有的保存点都将消失,这意味着当进行恢复时,事务需要从开始处重新执行,而不能从最近的一个保存点继续执行。 链事务的思想 是:在提交一个事务时,释放不需要的数据对象,将必要的处理上下文隐式地传给下一个要开始的事务前一个子事务的提交操作和下一个子事务的开始操作合并成一个原子操作,这意味着下一个事务将看到上一个事务的结果就好像在一个事务中进行一样。这样,在提交子事务时就可以释放不需要的数据对象,而不必等到整个事务完成后才释放。其工作方式如下:
    image.png
    链事务与带有保存点的扁平事务的不同之处体现在:
    ① 带有保存点的扁平事务能回滚到任意正确的保存点,而链事务中的回滚仅限于当前事务,即只能恢复到最近的一个保存点
    ② 对于锁的处理,两者也不相同,链事务在执行COMMIT后即释放了当前所持有的锁,而带有保存点的扁平事务不影响迄今为止所持有的锁

嵌套事务(Nested Transactions)
嵌套事务 是一个层次结构框架,由一个顶层事务(Top-LevelTransaction)控制着各个层次的事务顶层事务之下嵌套的事务被称为子事务(Subtransaction),其控制着每一个局部的变换,子事务本身也可以是嵌套事务。因此,嵌套事务的层次结构可以看成是一棵树。

分布式事务(Distributed Transactions)
分布式事务 通常是在一个分布式环境下运行的扁平事务,因此,需要根据数据所在位置访问网络中不同节点的数据库资源。例如,一个银行用户从招商银行的账户向工商银行的账户转账1000元,这里需要用到分布式事务,因为不能仅调用某一家银行的数据库就完成任务。

二、事务日志

事务有4种特性:原子性、一致性、隔离性和持久性。那么事务的四种特性到底是基于什么机制实现呢?

  • 事务的隔离性由锁机制实现。
  • 而事务的原子性、一致性和持久性由事务的 redo 日志和undo 日志来保证。
    • REDO LOG 称为重做日志,提供再写入操作,恢复提交事务修改的页操作,用来保证事务的持久性
    • UNDO LOG 称为回滚日志,回滚行记录到某个特定版本,用来保证事务的原子性、一致性。
      有的DBA或许会认为 UNDO 是 REDO 的逆过程,其实不然。REDO 和 UNDO 都可以视为是一种 恢复操作 ,但是:
  • redo log:是存储引擎层(innodb)生成的日志,记录的是"物理级别"上的页修改操作,比如页号xxx、偏移量yyy写入了'zzz'数据。主要为了保证数据的可靠性;
  • undo log:是存储引擎层(innodb)生成的日志,记录的是 逻辑操作 日志,比如对某一行数据进行了INSERT语句操作,那么 undo log就记录一条与之相反的DELETE操作。主要用于 事务的回滚 (undo log 记录的是每个修改操作的 逆操作)和 一致性非锁定读 (undo log,回滚行记录到某种特定的版本--MVCC,即多版本并发控制)。

2.1 redo日志

InnoDB存储引擎是以 页为单位 来管理存储空间的。在真正访问页面之前,需要把在 磁盘上的页缓存到内存中的Buffer Pool之后才可以访问。所有的变更都必须 先更新缓冲池 中的数据,然后缓冲池中的 脏页 会以一定的频率被刷入磁盘(checkPoint机制),通过缓冲池来优化CPU和磁盘之间的鸿沟,这样就可以保证整体的性能不会下降太快。

2.1.1 为什么需要REDO日志

一方面,缓冲池可以帮助我们消除CPU和磁盘之间的鸿沟,checkpoint机制可以保证数据的最终落盘,然而由于checkpoint 并不是每次变更的时候就触发的,而是master线程隔一段时间去处理的。所以最坏的情况就是事务提交后,刚写完缓冲池,数据库宕机了,那么这段数据就是丢失的,无法恢复。
另一方面,事务包含持久性的特性,就是说对于一个已经提交的事务,在事务提交后即使系统发生了崩溃,这个事务对数据库中所做的更改也不能丢失。
那么如何保证这个持久性呢? 一个简单的做法:在事务提交完成之前把该事务所修改的所有页面都刷新到磁盘,但是这个简单粗暴的做法有些问题:

  • 修改量与刷新磁盘工作量严重不成比例
    有时候我们仅仅修改了某个页面中的一个字节,但是我们知道在InnoDB中是以页为单位来进行磁盘I0的,也就是说我们在该事务提交时不得不将一个完整的页面从内存中刷新到磁盘,我们又知道一个页面默认是16KB大小,只修改一个字节就要刷新16KB的数据到磁盘上显然是太小题大做了。
  • 随机I/0刷新较慢
    个事务可能包含很多语句,即使是一条语句也可能修改许多页面,假如该事务修改的这些页面可能并不相邻,这就意味着在将某个事务修改的Buffer Pool中的页面 刷新到磁盘 时,需要进行很多的 随机I/O ,随机I/O比顺序I/O要慢,尤其对于传统的机械硬盘来说。

另一个解决的思路:我们只是想让已经提交了的事务对数据库中数据所做的修改永久生效,即使后来系统崩溃,在重启后也能把这种修改恢复出来。所以我们其实没有必要在每次事务提交时就把该事务在内存中修改过的全部页面刷新到磁盘,只需要把修改了哪些东西记录一下就好。比如,某个事务将系统表空间中第10号页面中偏移量为100 处的那个字节的值 1 改成2 。我们只需要记录一下:将第0号表空间的10号页面的偏移量为100处的值更新为 2 。
InnoDB引擎的事务采用了WAL技术(Write-Ahead Logging)这种技术的思想就是先写日志,再写磁盘,只有日志写入成功,才算事务提交成功,这里的日志就是redo log。当发生宕机且数据未刷到磁盘的时候,可以通过redo log来恢复,保证ACID中的D,这就是redo log的作用。
image.png

2.1.2 REDO日志的好处、特点

  1. 好处
    • redo日志降低了刷盘频率
    • redo日志占用的空间非常小
      存储表空间ID、页号、偏移量以及需要更新的值,所需的存储空间是很小的,刷盘快。
  2. 特点
    • redo日志是顺序写入磁盘的
      在执行事务的过程中,每执行一条语句,就可能产生若干条redo日志,这些日志是按照产生的顺序写入磁盘的,也就是使用顺序I/O,效率比随机I/O快。
    • 事务执行过程中,redo log不断记录
      redo log跟bin log的区别,redo log是存储引擎层产生的,而bin log是数据库层产生的。假设一个事务,对表做10万行的记录插入,在这个过程中,一直不断的往redo log顺序记录,而bin log不会记录,直到这个事务提交,才会一次写入到bin log文件中。简单说来,没执行一次数据库操作,InnoDB引擎就会生成一条对应的redo log记录,而只有事务提交前,才会将之前所有事务包含的操作一次性写入bin log日志。

2.1.3 redo的组成

Redo log可以简单分为以下两个部分:

  • 重做日志的缓冲 (redo log buffer) ,保存在内存中,是易失的。
    在服务器启动时就向操作系统申请了一大片称之为redo log bufer的 连续内存 空间,翻译成中文就是redo日志缓冲中区。这片内存空间被划分成若干个连续的redo 1og block。一个redo log block占用 512字节 大小。

image.png

参数设置:innodb_log_buffer_size:
redo log buffer 大小,默认16M ,最大值是4096M,最小值为1M。

mysql> show variables like '%innodb_log_buffer_size%';
+------------------------+----------+
| Variable_name          | Value    |
+------------------------+----------+
| innodb_log_buffer_size | 16777216 |
+------------------------+----------+
  • 重做日志文件 (redo log file) ,保存在硬盘中,是持久的。
    REDO日志文件如图所示,其中的ib_logfile0和ib_logfile1即为redo日志。
    image.png

2.1.4 redo的整体流程

以一个更新事务为例,redo log 流转过程,如下图所示:
image.png
第1步:先将原始数据从磁盘中读入内存中来,修改数据的内存拷贝
第2步:生成一条重做日志并写入redo log buffer,记录的是数据被修改后的值
第3步:当事务commit时,将redo log buffer中的内容刷新到 redo log file,对 redo log file采用追加写的方式
第4步:定期将内存中修改的数据刷新到磁盘中

体会:
Write-Ahead Log(预先日志持久化):在持久化一个数据页之前,先将内存中相应的日志页持久化。

2.1.5 redo log的刷盘策略

redo log的写入并不是直接写入磁盘的,InnoDB引擎会在写redo log的时候先写redo log buffer,之后以一定的频率刷入到真正的redo log file 中。这里的一定频率怎么看待呢?这就是我们要说的刷盘策略。
image.png
注意,redo log buffer刷盘到redo log file的过程并不是真正的刷到磁盘中去,只是刷入到文件系统缓存(page cache)中去(这是现代操作系统为了提高文件写入效率做的一个优化),真正的写入会交给系统自己来决定(比如page cache足够大了)。那么对于InnoDB来说就存在一个问题,如果交给系统来同步,同样如果系统宕机,那么数据也丢失了(虽然整个系统宕机的概率还是比较小的)。
针对这种情况,InnoDB给出 innodb_flush_log_at_trx_commit 参数,该参数控制 commit提交事务
时,如何将 redo log buffer 中的日志刷新到 redo log file 中。它支持三种策略:

  1. 设置为0 :表示每次事务提交时不进行刷盘操作。(系统默认master thread每隔1s进行一次重做日志的同步)
  2. 设置为1 :表示每次事务提交时都将进行同步,刷盘操作( 默认值
  3. 设置为2 :表示每次事务提交时都只把 redo log buffer 内容写入 page cache,不进行同步。由os自己决定什么时候同步到磁盘文件。
mysql> show variables like 'innodb_flush_log_at_trx_commit'

image.png

另外,InnoDB存储引擎有一个后台线程,每隔1 秒,就会把 redo log buffer 中的内容写到文件系统缓存
(page cache),然后调用刷盘操作。
image.png
也就是说,一个没有提交事务的 redo log 记录,也可能会刷盘。因为在事务执行过程 redo log记录是会写入redo log puffer 中,这些 redo log 记录会被 后台线程刷盘。
image.png

2.1.6 不同刷盘策略演示

  1. 流程图
    image.png

小结:innodb flush log_at_trx_commit=1
为1时,只要事务提交成功,redo log记录就一定在硬盘里,不会有任何数据丢失。如果事务执行期间MySQL挂了或容机,这部分日志丢了,但是事务并没有提交,所以日志丢了也不会有损失。可以保证ACID的D数据绝对不会丢失,但是 效率最差的。建议使用默认值,虽然操作系统宕机的概率理论小于数据库宕机的概率,但是一般既然使用了事务,那么数据的安全相对来说更重要些。

image.png

小结innodb_flush_log_at_trx_commit=2
2时,只要事务提交成功,redo log buffer中的内容只写入文件系统缓存(page cache)。
如果仅仅只是 MySQL 挂了不会有任何数据丢失,但是操作系统宕机可能会有1秒数据的丢失,这种情况下无法满足ACID中的D。但是数值2 肯定是效率最高的。

image.png

小结:innodb_flush_log_at_trx_commit=0
0时,master thread中每1秒进行一次重做日志的fsync操作,因此实例 crash 最多丢失1秒钟内的事务。(master thread是负责将缓冲池中的数据异步刷新到磁盘,保证数据的一致性)
数值0 的话,是一种折中的做法,它的I/O效率理论是高于1的,低于2的,这种策略也有丢失数据的风险,也无法保证D。

  1. 举例
    比较innodb_flush_log_at_trx_commit对事务的影响
CREATE TABLE test_load(
a INT
b CHAR(88)
)ENGINE=INNODB

DELIMITER//
CREATE PROCEDURE p_load(COUNT INT UNSIGNED)BEGIN
DECLARE s INT UNSIGNED DEFAULT 1;
DECLARE c CHAR(80)DEFAULT REPEAT('a',80),
WHILE S<=COUNT DO
INSERT INTO test_load SELECT NULL,C;
COMMIT;
SET s=s+1;
END WHILE;
END //
DELIMITER;

存储过程代码中,每插入一条数据就进行一次显式的COMMIT操作。在默认的设置下,即参数innodb_flush_log_at_trx_commit为1的情况下,InnoDB存储引擎会将重做日志缓冲中的日志写入文件,并调用一次fsync操作。

执行命令CALL p_load(30000),向表中插入3万行的记录,并执行3万次的fsync操作。在默认情况下所需的时间:

mysql>CALL p_load(30000);
Query OK,0 rows affected(1 min 23 sec)

1 min 23 sec 的时间显然是不能接受的。而造成时间比较长的原因就在于fsync操作所需的时间。

修改参数 innodb_flush_log_at_trx_commit,设置为0

mysql> set global innodb_flush_log_at_trx_commit =0;
Query OK,0 rows affected(0.00 sec)

SHOW VARIABLES LIKE 'innodb_flush_log_at trx_commit';

mysql>CALL p_load(30000);
Query OK,0 rows affected(37.945 sec)
mysql> set global innodb_flush_log_at_trx_commit =2;
Query OK,0 rows affected(0.00 sec)

SHOW VARIABLES LIKE 'innodb_flush_log_at trx_commit';

mysql>CALL p_load(30000);
Query OK,0 rows affected(45.173 sec)

而针对上述存储过程,为了提高事务的提交性能,应该在将3万行记录插入表后进行一次的COMMIT操作,而不是每插入一条记录后进行一次COMMIT操作。这样做的好处是可以使事务方法在rolback时回滚到事务最开始的确定状态。

虽然用户可以通过设置参数innodb_flush_log_at_trx_commit为0或2来提高事务提交的性能,但需清楚,这种设置方法丧失了事务的ACID特性。

2.1.7 写入redo log buffer 过程

  1. 补充概念:Mini-Transaction
    MySQL把对底层页面中的一次原子访问的过程称之为一个Mini-Transaction,简称mtr,比如,向某个索引对应的B+树中插入一条记录的过程就是一个Mini-Transaction。一个所谓的mtr 可以包含一组redo日志,在进行崩溃恢复时这一组 redo 日志作为一个不可分割的整体。
    一个事务可以包含若干条语句,每一条语句其实是由若干个mtr 组成,每一个mtr 又可以包含若干条
    redo日志,画个图表示它们的关系就是这样:
    image.png
  2. redo 日志写入log buffer
    log buffer 中写入redo日志的过程是顺序的,也就是先往前边的block中写,当该block的空闲空间用完之后再往下一个block中写。当我们想往log buffer 中写入redo日志时,第一个遇到的问题就是应该写在哪个block的哪个偏移量处,所以 InnoD8 的设计者特意提供了一个称之为 buf_free 的全局变量,该变量指明后续写入的redo日志应该写入到 log buffer 中的哪个位置,如图所示:
    image.png
    一个mtr执行过程中可能产生若条redo日志,这些redo日志是一个不可分割的组,所以其实并不是每生成一条redo日志,就将其插入到logbufer中,而是每个mtr运行过程中产生的日志先暂时存到一个地方,当该mtr结束的时候,将过程中产生的一组redo日志再全部复制到logbufer中。我们现在假设有两个名为 T1T2的事务,每个事务都包含2个mtr,我们给这几个mtr命名一下:
    事务T1 的两个 mtr分别称为 mtr_T1_1mtr_T1_2
    事务 T2 的两个 mtr分别称为 mtr_T2_1 和mtr_T2_2

每个mtr都会产生一组redo日志,用示意图来描述一下这些mtr产生的日志情况:
image.png

不同的事务可能是并发执行的,所以T1T2 之间的mtr 可能是交替执行的。
每当一个mtr执行完成时,伴随该mtr生成的一组redo日志就需要被复制到log buffer中,也就是说不同事务的mtr可能是交替写入log buffer的,我们画个示意图(为了美观,我们把一个mtr中产生的所有的redo日志当做一个整体来画):
image.png
有的mtr产生的redo日志量非常大,比如 mtr_t1_2产生的redo日志占用空间比较大,占用了3个block来存储。

  1. redo log block的结构图
    一个redo log block是由 日志头日志体日志尾组成。日志头占用12字节,日志尾占用8字节,所以一个block真正能存储的数据就是512-12-8=492字节。

为什么一个block设计成512字节?
这个和磁盘的扇区有关,机械磁盘默认的扇区就是512字节,如果你要写入的数据大于512字节,那么要写入的扇区肯定不止一个,这时就要涉及到盘片的转动,找到下一个扇区,假设现在需要写入两个扇区A和B,如果扇区A写入成功,而扇区B写入失败,那么就会出现 非原子性 的写入,而如果每次只写入和扇区的大小一样的512字节,那么每次的写入都是原子性的。
image.png

image.png log block header的属分别如下:

  • LOG_BLOCK_HDR_NO:log buffer是由log block组成,在内部log buffer就好似一个数组,因此LOG_BLOCK_HDR_NO用来标记这个数组中的位置。其是递增并且循环使用的,占用4个字节,但是由于第一位用来判断是否是flush bit,所以最大的值为2G。
  • LOG_BLOCK_HDR_DATA_LEN:表示block中已经使用了多少字节,初始值为12(因为 log block body从第12个字节处开始)。随着往block中写入的redo日志越来也多,本属性值也跟着增长。如果logblock body 已经被全部写满,那么本属性的值被设置为 512。
  • LOG_BLOCK_FIRST_REC_GROUP:一条redo日志也可以称之为一条redo日志记录(redo log record),一个mtr会生产多条redo日志记录,这些redo日志记录被称之为一个redo日志记录组(redo log record group)。LOG_BLOCK_FIRST_REC_GROUP就代表该block中第一个mtr生成的redo日志记录组的偏移量(其实也就是这个block里第一个mtr生成的第一条redo日志的偏移量)。如果该值的大小和LOG_BLOCK_HDR_DATA_LEN相同,则表示当前log block不包含新的日志
  • LOG_BLOCK_CHECKPOINT_NO:占用4字节,表示该log block最后被写入时的 checkpoint
    log block trailer中属性的意思如下:
    LOG_BLOCK_CHECKSUM:表示block的校验值,用于正确性校验(其值和LOG_BLOCK_HDR_NO相同),我们暂时不关心它。

2.1.8 redo log file

  1. 相关参数设置
    • innodb_log_group_home_dir :指定 redo log 文件组所在的路径,默认值为./ ,表示在数据库的数据目录下。MySQL的默认数据目录( var/lib/mysql下默认有两个名为ib_logfile0 和ib_logfile1 的文件,log buffer中的日志默认情况下就是刷新到这两个磁盘文件中。此redo日志文件位置还可以修改。
    • innodb_log_files_in_group:指明redo log file的个数,命名方式如:ib_logfile0,iblogfile1...iblogfilen。默认2个,最大100个。
      show variables like 'innodb_log_files_in_group';
      
      image.png
    • innodb_flush_log_at_trx_commit:控制 redo log 刷新到磁盘的策略,默认为1。
    • innodb_log_file_size:单个 redo log 文件设置大小,默认值为 48M 。最大值为512G,注意最大值指的是整个 redo log 系列文件之和,即innodb_log_files_in_group * innodb_log_file_size )不能大于最大值512G。
      show variables like 'innodb_log_file_size';
      
      image.png
      根据业务修改其大小,以便容纳较大的事务。编辑my.cnf文件并重启数据库生效,如下所示:

[root@localhost ~]# vim /etc/my.cnf
innodb_log_file_size=200M
```

在数据库实例更新比较频繁的情况下,可以适当加大 redo log组数和大小。但也不推荐redolog 设置过大,在MySQL崩溃恢复时会重新执行RED0日志中的记录。

  1. 日志文件组
    image.png
    总共的redo日志文件大小其实就是: innodb_log_file_size × innodb_log_files_in_group
    采用循环使用的方式向redo日志文件组里写数据的话,会导致后写入的redo日志覆盖掉前边写的redo日志?当然!所以InnoDB的设计者提出了checkpoint的概念。

  2. checkpoint
    image.png
    如果 write pos 追上 checkpoint ,表示日志文件组满了,这时候不能再写入新的 redo log记录,MySQL 得停下来,清空一些记录,把 checkpoint 推进一下。
    image.png

InnoDB 的更新操作采用的是 Write Ahead Log(预先日志持久化)策略,即先写日志,再写入磁盘.image.png

2.2 Undo日志

redo log是事务持久性的保证,undo log是事务原子性的保证。在事务中 更新数据前置操作 其实是要先写入一个undo log

2.2.1 如何理解Undo日志

事务需要保证原子性,也就是事务中的操作要么全部完成,要么什么也不做。但有时候事务执行到一半会出现一些情况,比如:

  • 情况一:事务执行过程中可能遇到各种错误,比如服务器本身的错误操作系统错误,甚至是突然断电导致的错误。
  • 情况二:程序员可以在事务执行过程中手动输入 ROLLBACK 语句结束当前事务的执行。
    以上情况出现,我们需要把数据改回原先的样子,这个过程称之为回滚,这样就可以造成一个假象:这个事务看起来什么都没做,所以符合原子性要求。

每当我们要对一条记录做改动时(这里的 改动 可以指 INSERTDELETEUPDATE),都需要"留一手"——把回滚时所需的东西记下来。比如:

  • 插入一条记录 时,至少要把这条记录的主键值记下来,之后回滚的时候只需要把这个主键值对应的 记录删掉 就好了。(对于每个INSERT,InnoDB存储引擎会完成一个DELETE)
  • 删除了一条记录 ,至少要把这条记录中的内容都记下来,这样之后回滚时再把由这些内容组成的记录插入到表中就好了。(对于每个DELETE,InnoDB存储引擎会执行一个INSERT)
  • 修改了一条记录 ,至少要把修改这条记录前的旧值都记录下来,这样之后回滚时再把这条记录 更新为旧值 就好了。(对于每个UPDATE,InnoDB存储引擎会执行一个相反的UPDATE,将修改前的行放回去)
    MySQL把这些为了回滚而记录的这些内容称之为 撤销日志或者 回滚日志(即 undo log)。注意,由于查询操作(SELECT)并不会修改任何用户记录,所以在査询操作执行时,并不需要记录相应的undo日志。
    此外,undo log会产生redo log,也就是undo log的产生会伴随着redo log的产生,这是因为undo log也需要持久性的保护。

2.2.2 Undo日志的左右

  • 作用1:回滚数据
    用户对undo日志可能 有误解:undo用于将数据库物理地恢复到执行语句或事务之前的样子。但事实并非如此 undo是 逻辑日志,因此只是将数据库逻辑地恢复到原来的样子。所有修改都被逻辑地取消了,但是数据结构和页本身在回滚之后可能大不相同
    这是因为在多用户并发系统中,可能会有数十、数百甚至数千个并发事务。数据库的主要任务就是协调对数据记录的并发访问。比如,一个事务在修改当前一个页中某几条记录,同时还有别的事务在对同一个页中另几条记录进行修改。因此,不能将一个页回滚到事务开始的样子,因为这样会影响其他事务正在进行的工作

  • 作用2:MVCC
    undo的另一个作用是MVCC,即在InnoDB存储引擎中MVCC的实现是通过undo来完成。当用户读取一行记录时,若该记录已经被其他事务占用,当前事务可以通过undo读取之前的行版本信息,以此实现非锁定读取

2.2.3 undo的存储结构

  1. 回滚段与undo页
    InnoDB对undo log的管理采用的方式,也就是回滚段(rollback segment) 。每个回滚段记录了 1024undo log segment ,而在每个undo log segment段中进行undo页的申请
  • InnoDB1.1版本之前(不包括1.1版本),只有一个rollback segment,因此支持同时在线的事务限制为1024 。虽然对绝大多数的应用来说都已经够用。
  • 从1.1版本开始InnoDB支持最大128个rollback segment ,故其支持同时在线的事务限制提高到了128 * 1024
mysql> show variables like 'innodb_undo_logs'

image.png
虽然InnoDB1.1版本支持了128个rollback segment,但是这些rollback segment都存储于共享表空间ibdata中。从InnoDB1.2版本开始,可通过参数对rollback segment做进一步的设置。这些参数包括:

  • innodb_undo_directory:设置rollback segment文件所在的路径。这意味着rollback segment可以存放在共享表空间以外的位置,即可以设置为独立表空间。该参数的默认值为“.”,表示当前InnoDB存储引擎的目录
  • innodb_undo_logs:设置rollback segment的个数,默认值为128。在InnoDB1.2版本中,该参数用来替换之前版本的参数 innodb_rollback_segments
  • innodb_undo_tablespaces:设置构成rollback segment文件的数量,这样rollback segment可以较为平均地分布在多个文件中。设置该参数后,会在路径innodb_undo_directory看到undo为前缀的文件,该文件就代表rollback segment文件。
    undo log 相关参数一般很少改动。
    undo页的重用
    当我们开启一个事务需要写undo log的时候,就得先去undo log segment中去找到一个空闲的位置,当有空位的时候,就去申请undo页,在这个申请到的undo页中进行undo log的写入。我们知道mysql默认一页的大小是16k
    为每一个事务分配一个页,是非常浪费的(除非你的事务非常长),假设你的应用的TPS(每秒处理的事务数目)为1000,那么1s就需要1000个页,大概需要16M的存储,1分钟大概需要1G的存储。如果照这样下去除非MySOL清理的非常勤快,否则随着时间的推移,磁盘空间会增长的非常快,而且很多空间都是浪费的。
    于是undo页就被设计的可以重用了,当事务提交时,并不会立刻删除undo页。因为重用,所以这个undo页可能混杂着其他事务的undo log。undo log在commit后,会被放到一个 链表 中,然后判断undo页的使用空间是否 小于3/4,如果小于3/4的话,则表示当前的undo页可以被重用,那么它就不会被回收,其他事务的undo log可以记录在当前undo页的后面。由于undo log是 离散的,所以清理对应的磁盘空间时,效率不高
  1. 回滚段与事务
    1. 每个事务只会使用一个回滚段,一个回滚段在同一时刻可能会服务于多个事务。
    2. 当一个事务开始的时候,会制定一个回滚段,在事务进行的过程中,当数据被修改时,原始的数据会被复制到回滚段。
    3. 在回滚段中,事务会不断填充盘区,直到事务结束或所有的空间被用完。如果当前的盘区不够用,事务会在段中请求扩展下一个盘区,如果所有已分配的盘区都被用完,事务会覆盖最初的盘区或者在回滚段允许的情况下扩展新的盘区来使用。
    4. 回滚段存在于undo表空间中,在数据库中可以存在多个undo表空间,但同一时刻只能使用一个undo表空间。
      mysql> show variables like `innodb_undo_tablespaces`
      #undo log的数量,最少为2,undo log的truncate操作有purge协调线程发起。在truncate某个undo log表空间的过程中,保证有一个可用的undo log可用。
      
      image.png
    5. 当事务提交时,InnoDB存储引擎会做以下两件事情:
      • 将undo log放入列表中,以供之后的purge操作(清除线程)
      • 判断undo log所在的页是否可以重用,若可以分配给下个事务使用
  2. 回滚段中的数据分类
    1. 未提交的回滚数据(uncommitted undo information):该数据所关联的事务并未提交,用于实现读一致性,所以该数据不能被其他事务的数据覆盖。
    2. 已经提交但未过期的回滚数据(committed undo information):该数据关联的事务已经提交,但是仍受到undo retention参数的保持时间的影响。
    3. 事务已经提交并过期的数据(expired undo information):事务已经提交,而且数据保存时间已经超过undo retention参数指定的时间,属于已经过期的数据。当回滚段满了之后,会优先覆盖“事务已经提交并过期的数据。”
      事务提交后并不能马上删除undo log及undo log所在的页。这是因为可能还有其他事务需要通过undo log来得到行记录之前的版本。故事务提交时将undo log放入一个链表中,是否可以最终删除undo log及undo log所在页由purge线程来判断。

2.2.4 undo 的类型

在InnoDB存储引擎中, undo log分为:

  • insert undo log
    • insert undo log是指在insert操作中产生的undo log。因为insert操作的记录,只对事务本身可见,对其他事务不可见(这是事务隔离性的要求),故该undo log可以在事务提交后直接删除。不需要进行purge操作。
  • update undo log
    • update undo log记录的是对delete 和update操作产生的 undo log。该 undo log可能需要提供MVCC机制,因此不能在事务提交时就进行删除。提交时放入undo log链表,等待purge线程进行最后的删除。

2.2.5 undo log的生命周期

以下是undo+redo事务的简化过程
假设有2个数值,分别为A=1和B=2,然后将A修改为3,B修改为4

1.start transaction,
2.记录 A=1 到undo log;
3.update A= 3;
4.记录 A=3 到redo log;
5.记录 B=2 到undo 1og;
6.update B=4;
7.记录B =4 到redo log;
8.将redo log刷新到磁盘
9.commit
  • 在1-8步骤的任意一步系统宕机,事务未提交,该事务就不会对磁盘上的数据做任何影响。
  • 如果在8-9之间宕机,恢复之后可以选择回滚,也可以选择继续完成事务提交,因为此时redo log已经持久化。
  • 若在9之后系统宕机,内存映射中变更的数据还来不及刷回磁盘,那么系统恢复之后,可以根据redo log把数据刷回磁盘。

只有Buffer Pool的流程:
image.png
有了Redo Log和Undo Log之后:
image.png
在更新Bufer Pool中的数据之前,我们需要先将该数据事务开始之前的状态写入Undo Log中。假设更新到一半出错了,我们就可以通过Undo Log来回滚到事务开始前。

  1. 详细生成过程
    对于InnoDB引擎来说,每个行记录除了记录本身的数据之外,还有几个隐藏的列:
  • DB_ROW_ID:如果没有为表显式的定义主键,并且表中也没有定义唯一索引,那么InnoDB会自动为表添加一个row_id的隐藏列作为主键。
  • DB_TRX_ID:每个事务都会分配一个事务ID,当对某条记录发生变更时,就会将这个事务的事务ID写入trx_id中。
  • DB_ROLL_PTR:回滚指针,本质上就是指向 undo log 的指针。
    image.png
    当我们执行INSERT时:
begin;
INSERT INTO user (name) VALUES ("tom");

插入的数据都会生成一条insert undo log,并且数据的回滚指针会指向它。undo log会记录undo log的序号、插主键的列和值….,那么在进行rollback的时候,通过主键直接把对应的数据删除即可。
image.png

当我们执行UPDATE时
对于更新的操作会产生update undo logg,并且会分更新主键的和不更新主键的,假设现在执行:

UPDATE user SET name="Sun" WHERE id=1;

image.png
这时会把老的记录写入新的undo log,让回滚指针指向新的undo log,它的undo no是1,并且新的undo log会指向老的undo log(undo no=0)。
假设现在执行:

UPDATE user SET id=2 WHERE id=1 ;

image.png
对于更新主键的操作,会先把原来的数据deletemark标识打开,这时并没有真正的删除数据,真正的删除会交给清理线程去判断,然后在后面插入一条新的数据,新的数据也会产生undo log,并且undo log的序号会递增
可以发现每次对数据的变更都会产生一个undo log,当一条记录被变更多次时,那么就会产生多条undo log,undo log记录的是变更前的日志,并且每个undo log的序号是递增的,那么当要回滚的时候,按照序号 依次向前推,就可以找到我们的原始数据了。

  1. undolog是如何回滚的
    以上面的例子来说,假设执行rollback,那么对应的流程应该是这样:
    1. 通过undo no=3的日志把id=2的数据删除
    2. 通过undo no=2的日志把id=1的数据的deletemark还原成0
    3. 通过undo no=1的日志把id=1的数据的name还原成Tom
    4. 通过undo no=0的日志把id=1的数据删除
  2. undo log的删除
    • 针对于insert undo log
      因为insert操作的记录,只对事务本身可见,对其他事务不可见。故该undolog可以在事务提交后直接删除,不需要进行purge操作。
    • 针对于update undo log
      该undo log可能需要提供MVCC机制,因此不能在事务提交时就进行删除。提交时放入undo log链表,等待purge线程进行最后的删除。

purge线程两个主要作用是:清理undo页和清除page里面带有Delete_Bit标识的数据行。在InnoDB中,事务中的Delete操作实际上并不是真正的删除掉数据行,而是一种Delete Mark操作,在记录上标识Delete_Bit,而不删除记录。是一种"假删除”,只是做了个标记,真正的删除工作需要后台purge线程去完成。

2.2.6 小结

image.png
undo log是逻辑日志,对事务回滚时,只是将数据库逻辑地恢复到原来的样子。
redolog是物理日志,记录的是数据页的物理变化,undo log不是redo log的逆过程。