MYSQL之事务
引言
数据库注定是会被多个客户端访问的, 也就是可能被上层的多个线程并发访问, 同时对数据库发出多个CURD请求, 于是像MYSQL这样的关系型数据库会提供事务的管理 (同样的, NoSQL中KV型数据库, 比如 Redis, 也支持事务).
比如下面这个场景, 如果不对数据库进行事务管理, 就会产生不符合现实逻辑的行为:

再比如一个同行转账的例子, 我想给一个同是工商银行的银行卡转100, 但是我的账号上余额-100之后, 突然 网络, 数据库等出现异常, 导致产生一个中间态, 我的余额被扣除了, 但是对方账户没有+100元. 我们并不是不允许这样的异常产生, 而是异常产生后, 我们要能对未完成的"事务"进行回滚 , 将系统还原成最初的状态, 就好像什么都没有发生一样.
事务
什么是事务?
事务就是一组DML 语句组成, 这些语句存在逻辑相关性 , 这一组DML语句要么全部成功, 要么全部失败 , 是一个整体. 我们看待事务, 要站在 MYSQL 的上层去从业务的角度去看待SQL语句, 单拎出来一个事物的一条SQL语句是无意义的. 比如转账这个事务, 由两条UPDATE语句构成, 从程序员的角度去看, 把两条SQL语句分开就是两条SQL语句, 而从数据库的使用者而言, 将两条SQL结合起来, 才叫一个事务.
MySQL提供一种机制, 保证我们达到上面这样的效果.
此外, 事务还规定不同的客户端看到的数据是不相同的, 这里在事务的隔离级别体现.
事务就是要做的或所做的事情, 主要用于处理操作量大, 复杂度高的数据. 假设一种场景: 你毕业了, 学校的教务系统后台 MySQL 中, 不在需要你的数据, 要删除你的所有信息(一般不会), 那么要删除你的基本信息(姓名,电话,籍贯等)的同时, 也删除和你有关的其他信息, 比如: 你的各科成绩, 你在校表现, 甚至你在论坛发过的文章等. 这样, 就需要多条 MySQL 语句构成, 那么所有这些操作合起来, 就构成了一个事务.
所以严格意义上讲, 事务并不是一个程序员的术语, 但它又确实是 MYSQL 为了表述SQL的逻辑相关性和保证数据一致性而为数据库的使用者将一系列SQL语句封装后并满足特定属性的结果.
事务的属性?
一个完整的事务, 绝对不是简单的 sql 集合, 还需要满足如下四个属性:
-
原子性: 一个事务(transaction)中的所有操作, 要么全部完成 , 要么全部不完成 , 不会结束在中间某个环节 . 事务在执行过程中发生错误 , 会被回滚 (Rollback) 到事务开始前的状态, 就像这个事务从来没有执行过一样
-
持久性: 事务处理结束后, 对数据的修改就是永久 的, 即便系统故障也不会丢失
-
隔离性(最大的问题, 不好理解): 数据库允许多个并发事务 同时对其数据进行读写和修改 的能力, 隔离性可以防止多个事务并发执行时由于交叉执行 而导致数据的不一致. 事务隔离分为不同级别, 包括读未提交( Readuncommitted )、读提交( read committed )、可重复读( repeatable read )和串行化( Serializable )
-
一致性: 在事务开始之前 和事务结束以后 , 数据库的完整性 没有被破坏. 这表示写入的资料必须完全符合所有的预设规则, 这包含资料的精确度, 串联性以及后续数据库可以自发性地完成预定的工作. 比如 A 账户有 200, B 账户有 100, A 向 B 转账 50 , 我一定可以预期到: 如果转账成功, A账户有150, B账户有100; 转账失败, A账户仍为200, B账户仍为100.
MYSQ在技术层面没有对一致性制定策略, 而是从技术层面对事务的原子性, 持久性 和隔离性 实现, 然后通过 用户+DBMS 的配合, 最终去保证业务层面的一致性的.
(原子性持久性隔离性是"因", 一致性是"果")
上面四个属性,可以简称为 ACID:
- 原子性(Atomicity,或称不可分割性)
- 一致性(Consistency)
- 隔离性(Isolation,又称独立性)
- 持久性(Durability)
因此事务是为了更好的描述业务需求, 对SQL语句集合做的封装. 而同时为了保证在 DB 并发访问的条件下更好更快更安全的去运行, DBMS 要用 ACID 去保证事务正确运行.
既然 MYSQL 在运行期间要存在大量的事务, 就要把大量的事务所管理起来, 先描述再组织, 于是事务在 MYSQL 内部看来就是一批被打包的事务对象, 然后放入事务执行队列去执行, 所以抽象的"事务"落脚在应用中就是一个个具体的"对象".
为什么要有事务?
事务不是伴随着的 DBS 一开始就有的, 它是在C/C++, Java等编程过程中被设计出来的简化的编程模型, 于是程序员就不用考虑各种各样的潜在错误, 并发问题, 原子性问题, 更不用关心持久化问题, 程序员只需要给出SQL, 由DBMS封装成事务去管理ACID问题, 从而使程序员更方便的去使用 SQL.
可以想一下: 当我们使用事务时, 要么提交 , 要么回滚 , 我们不会去考虑网络异常了, 服务器宕机了, 同时更改一个数据怎么办, 因此事务本质上是为了应用层服务的.而不是伴随着数据库系统天生就有的.
备注:我们后面把 MySQL 中的一行信息, 称为一行记录
事务的准备工作
事务的版本支持
在 MySQL 中并不是所有的数据库引擎都支持事务, 只有使用了 Innodb 数据库引擎的 数据库或表 才支持事务, MyISAM 不支持.
show engines 可以查看MYSQL支持引擎的信息, 只挑选常见的两种InnoDB和MyISAM
sql
show engines \G;
***************************[ 3. row ]***************************
Engine | InnoDB
Support | DEFAULT
Comment | Supports transactions, row-level locking, and foreign keys
Transactions | YES
XA | YES
Savepoints | YES
***************************[ 5. row ]***************************
Engine | MyISAM
Support | YES
Comment | MyISAM storage engine
Transactions | NO
XA | NO
Savepoints | NO
从中可以发现, InnoDB 由于我们曾在配置文件配置过, 因此是默认的存储引擎, 它支持transactions, 也就是事务; 而MyISAM不支持事务.
事务提交方式
事务的提交方式常见的有两种:
- 自动提交
- 手动提交
查看 autocommit 属性, ON 为自动提交, OFF 为手动提交.
sql
show variables like 'autocommit'
+---------------+-------+
| Variable_name | Value |
+---------------+-------+
| autocommit | ON |
+---------------+-------+
用 SET 来改变 MySQL 的自动提交模式, 0为关闭, 1为打开:
sql
set autocommit=0;
show variables like 'autocommit'
+---------------+-------+
| Variable_name | Value |
+---------------+-------+
| autocommit | OFF |
+---------------+-------+
事务常见操作方式
首先要清楚, mysql 其实只是一个客户端, 而 mysqld 是服务端, 可以有多台不同的机器 去访问指定主机下的 mysqld (ip+3306):

netstat 可以查看是否开启了 3306 mysqld 服务:
bash
netstat -nltp
Active Internet connections (only servers)
Proto Recv-Q Send-Q Local Address Foreign Address State PID/Program name
tcp 0 0 0.0.0.0:3306 0.0.0.0:* LISTEN 173276/mysqld
tcp6 0 0 :::33060 :::* LISTEN 173276/mysqld
现在在Windows上启动一个mysql客户端, 尝试连接mysql的server端:

在 server 端本地 ss / netstat 查看到了该链接, 所以可知: mysql本质是一个客户端进程

为了便于演示, 将mysql的默认隔离级别设置成最低的读未提交, 如果隔离级别太高, 演示不出效果:
sql
set global transaction isolation level READ UNCOMMITTED;
如果未重启mysql客户端, 修改不生效:

重启客户端, 然后 SELECT @@transaction_isolation;, 就可以查看到修改后的事务隔离级别了:
sql
SELECT @@transaction_isolation;
+-------------------------+
| @@transaction_isolation |
+-------------------------+
| READ-UNCOMMITTED |
+-------------------------+
然后创建一个表:
sql
create table if not exists account(
id int primary key,
name varchar(50) not null default '',
blance decimal(10,2) not null default 0.0
)ENGINE=InnoDB DEFAULT CHARSET=UTF8;
事务的基本操作
首先将事务设置为自动提交:
sql
set autocommit=1;
show variables like 'autocommit'
+---------------+-------+
| Variable_name | Value |
+---------------+-------+
| autocommit | ON |
+---------------+-------+
首先介绍事务几种操作方式:
- 开始一个事务用 start transaction/ begin 都可以:
- 创建一个保存点s1: savepoint s1;
- 回滚到保存点s2: rollback to s2;
- 直接回滚到最开始: rollback
- 提交事务: commit;
情景一, 正常提交:
- 先在 client A开始事务, 插入了三条数据; 然后再在clientB查询:
sql
//clientA:
start transaction
savepoint s1;
insert into account values(1, 'zs', 10.1);
savepoint s2;
insert into account values(2, 'ls', 10.2);
savepoint s3;
insert into account values(3, 'ww', 10.3);
//clientB
select * from account
+----+------+--------+
| id | name | blance |
+----+------+--------+
| 1 | zs | 10.10 |
| 2 | ls | 10.20 |
| 3 | ww | 10.30 |
+----+------+--------+
- 只要事务还没有commit, 就是可以回滚的, 所以在 clientA 依次回滚到 s3, s2, s1; 然后在 clientB 查询:
sql
//clientA
rollback to s3
//clientB
select * from account
+----+------+--------+
| id | name | blance |
+----+------+--------+
| 1 | zs | 10.10 |
| 2 | ls | 10.20 |
+----+------+--------+
//clientA
rollback to s2
//clientB
select * from account
+----+------+--------+
| id | name | blance |
+----+------+--------+
| 1 | zs | 10.10 |
+----+------+--------+
//clientA
rollback to s1
//clientB
select * from account
+----+------+--------+
| id | name | blance |
+----+------+--------+
+----+------+--------+
- 然后再重新在 clientA 插入三条数据, 直接 rollback, 回到了最初的无数据的状态:
sql
//clientA
insert into account values(1, 'zs', 10.1);
insert into account values(2, 'ls', 10.2);
insert into account values(3, 'ww', 10.3);
rollback
//clientB
select * from account
+----+------+--------+
| id | name | blance |
+----+------+--------+
+----+------+--------+
- 再重新在clientA 将三条数据插入, 然后 commit, 结束事务. 再尝试rollback, 回滚无效了, 因为 commit 后数据已经持久化保存在DB中了, 所以我们所说的回滚操作, 是在事务执行期间进行回滚, 事务提交后, 无法回滚.
sql
//clientA
insert into account values(1, 'zs', 10.1);
insert into account values(2, 'ls', 10.2);
insert into account values(3, 'ww', 10.3);
commit
rollback
//clientB
select * from account
+----+------+--------+
| id | name | blance |
+----+------+--------+
| 1 | zs | 10.10 |
| 2 | ls | 10.20 |
| 3 | ww | 10.30 |
+----+------+--------+
情景二, 事务提前发生异常(因为回滚一般是为了应对异常情况, 命令行上手动rollback比较少, 上面只是为了演示):
2.1 未commit, 客户端崩溃, MySQL自动会回滚(隔离级别设置为读未提交)
clientA 依然是开启 autocommit:
sql
//clientA
begin
insert into account values(4, 'zs', 10.4);
//clientB
select * from account
+----+------+--------+
| id | name | blance |
+----+------+--------+
| 1 | zs | 10.10 |
| 2 | ls | 10.20 |
| 3 | ww | 10.30 |
| 4 | zs | 10.40 |
+----+------+--------+
此时将clientA强制退出, 发现数据被自动回滚:
sql
//clientB
select * from account
+----+------+--------+
| id | name | blance |
+----+------+--------+
| 1 | zs | 10.10 |
| 2 | ls | 10.20 |
| 3 | ww | 10.30 |
+----+------+--------+
2.2 证明commit了, 客户端崩溃, MySQL数据不会在受影响, commit 后数据已经持久化:
sql
//clientA
begin;
insert into account values(4, 'zs', 10.4);
commit;
-- clientA异常退出
//clientB
select * from account
+----+------+--------+
| id | name | blance |
+----+------+--------+
| 1 | zs | 10.10 |
| 2 | ls | 10.20 |
| 3 | ww | 10.30 |
| 4 | zs | 10.40 |
+----+------+--------+
情景三: 事务的手动提交, 即(begin/start transaction, commit) 操作的显式事务, 不会受 MySQL 是否设置为 autocommit 的影响.
因为autocommit = 1 表示:每条语句执行后自动提交, 这是在你不使用 BEGIN/START TRANSACTION 时去生效的, 即对每一个单条的 DML 生效.
这里想说明的是, autocommit 的作用 和 begin/strat transaction 显式事务是两套逻辑, 在显式事务中, autocommit 不起作用, 因为它作用的场景根本不在显式事务里, 而是对情景四的单条SQL有作用.
实验就是把autocommit设为 0 然后重复上面的操作即可, 发现现象和之前一样.
结论: begin手动开启的事务, 最后就需要手动 commit 才能提交.
情景四: autocommit 和 单条SQL语句的关系
注意: 下面的操作和 begin, end 无关, 并不是手动去开启事务.
- clientA 先开启 autocommit:
sql
//clientA
show variables like 'autocommit'
+---------------+-------+
| Variable_name | Value |
+---------------+-------+
| autocommit | ON |
+---------------+-------+
delete from account where id=5
clientA崩溃
//clientB
--delete前
select * from account
+----+------+--------+
| id | name | blance |
+----+------+--------+
| 1 | zs | 10.10 |
| 2 | ls | 10.20 |
| 3 | ww | 10.30 |
| 4 | zs | 10.40 |
| 5 | tq | 10.50 |
+----+------+--------+
--delete并且clientA崩溃后, 发现数据被持久化删除了, 说明这单条SQL语句被clientA自动提交了
select * from account
+----+------+--------+
| id | name | blance |
+----+------+--------+
| 1 | zs | 10.10 |
| 2 | ls | 10.20 |
| 3 | ww | 10.30 |
| 4 | zs | 10.40 |
+----+------+--------+
- clientA 关闭 autocommit:
sql
//clientA
show variables like 'autocommit'
+---------------+-------+
| Variable_name | Value |
+---------------+-------+
| autocommit | OFF |
+---------------+-------+
delete from account where id=5
clientA崩溃
//clientB
--clientA的delete前
select * from account
+----+------+--------+
| id | name | blance |
+----+------+--------+
| 1 | zs | 10.10 |
| 2 | ls | 10.20 |
| 3 | ww | 10.30 |
| 4 | zs | 10.40 |
| 5 | tq | 10.50 |
+----+------+--------+
--clientA的delete后并且cleintA还没有崩溃, 也没有提交:
select * from account
+----+------+--------+
| id | name | blance |
+----+------+--------+
| 1 | zs | 10.10 |
| 2 | ls | 10.20 |
| 3 | ww | 10.30 |
| 4 | zs | 10.40 |
+----+------+--------+
--clientA的delete后并且clientA崩溃后, 发现数据被回滚了, 说明这单条SQL语句没有被clientA自动提交
select * from account
+----+------+--------+
| id | name | blance |
+----+------+--------+
| 1 | zs | 10.10 |
| 2 | ls | 10.20 |
| 3 | ww | 10.30 |
| 4 | zs | 10.40 |
| 5 | tq | 10.50 |
+----+------+--------+
由于autocommit默认是打开的, 所以之前我们执行单条SQL语句后, 每一条SQL都被自动提交了, 因此autocommit=1时, 每一条SQL都是一个事务, 所以平时我们察觉不到事务的存在.
- 理所当然的, 如果我们在delete语句执行后, 手动 commit 提交, 即使clientA, 数据依然被持久化保存. 不再演示
sql
//clientA
show variables like 'autocommit'
+---------------+-------+
| Variable_name | Value |
+---------------+-------+
| autocommit | OFF |
+---------------+-------+
delete from account where id=5
commit
clientA崩溃
结论:
- 只要输入 begin 或者 start transaction, 事务便必须 要通过commit提交, 才会持久化, 与是否 set autocommit 无关.
- 在没有 commit 的情况下, 事务可以手动回滚到设置好的 savepoint , 同时, 当操作异常 , MySQL会自动回滚
- 对于 InnoDB 每一条 SQL 语言 都默认 封装成事务, 自动提交(select有特殊情况, 因为MySQL 有 MVCC)
- 数据是否持久化保存, 看的是
是否commit, 无论 手动commit 还是 自动commit.
事务操作注意事项:
- 如果没有设置保存点, 也可以回滚, 只能回滚到事务的开始. 直接使用 rollback(前提是事务还没有提交)
- 如果一个事务被提交了(commit),则不可以回退(rollback)
- 可以选择回退到哪个保存点
- InnoDB 支持事务, MyISAM 不支持事务
- 开始事务可以使 start transaction 或者 begin
从之前的情景, 我们能看到事务本身的原子性 (回滚), 持久性(commit), 那么隔离性和一致性? 下面介绍隔离性:
☆ 事务的隔离级别
如何理解隔离性
首先需要明确 MySQL服务可能是会同时被多个客户端进程(线程)访问的, 访问的方式以事务方式 进行, 而一个事务可能由多条SQL构成, 所以在我们看来, 任何一个事务一定是有执行前, 执行中, 执行后 的阶段. 而所谓的原子性, 其实想让执行中的过程不被别人影响, 就算影响了, 也能回滚到执行前的状态, 从而在用户层只体现出执行前和执行后两态. 所以单个事务, 对用户表现出来的特性, 就是原子性.
但多个事务各自执行多个SQL的时候, 还是有可能会出现互相影响的情况. 比如:多个事务同时访问同一张表, 甚至同一行数据. 所以MySQL的一系列的技术手段, 比如隔离级别, 加锁等, 都是为了去保证这个"执行中"的状态尽量不被影响.
所以数据库中, 为了保证事务执行过程中尽量不受干扰 , 就有了一个重要特征:隔离性. 在数据库中, 根据事务被允许受干扰的程度 , 就有了一种重要特征: 隔离级别
就如同你妈妈给你说: 你要么别学,要学就学到最好。至于你怎么学,中间有什么困难,你妈妈不关心。那么你的学习,对你妈妈来讲,就是原子的。那么你学习过程中,很容易受别人干扰,此时,就需要将你的学习隔离开,保证你的学习环境是健康的.
结论:
- 在事务场景中, 隔离是必要的.
- 在事务运行中, "不会"出现互相干扰, 体现隔离性
- 根据影响程度不用, 设置隔离级别.
隔离级别
- 读未提交【Read Uncommitted】 : 在该隔离级别, 所有的事务都可以看到其他事务没有提交的执行结果, 其实就是相当于没有任何隔离性, 之前的实验就是采取的这种隔离级别(实际生产中不可能使用这种隔离级别的), 这会产生很多并发问题, 如脏读,幻读, 不可重复读等.
- 读提交【Read Committed】 : 该隔离级别是大多数数据库的默认的隔离级别 (不是 MySQL 默认的).它满足了隔离的简单定义: 一个事务只能看到其他的已经提交的事务所做的改变. 这种隔离级别会引起不可重复读, 即一个事务执行时, 如果多次 select, 可能得到不同的结果. (一个事务只有提交了之后才能被其它事务看到, 观察者事务在执行中和执行后都能看到)
- 可重复读【Repeatable Read】 : 这是 MySQL 默认的隔离级别 , 它确保同一个事务 , 在执行中, 多次读取 操作数据时, 会看到同样的数据行, 但是会有幻读问题. (两个事务在执行的过程中都看不到对方的修改, 如果自己的事务在执行中, 还没有提交, 即使对方提交了事务, 我也看不到对方的修改)
- 串行化【Serializable】: 这是事务的最高隔离级别, 它通过强制事务排序, 使之不可能相互冲突, 从而解决了幻读的问题. 它在每个读的数据行上面加上共享锁, 但是可能会导致超时和锁竞争(这种隔离级别太极端,实际生产基本不使用)
我们可以发现, 上面的隔离级别都包含了"读 "操作, 是应对读写并发的场景 , 通俗讲就是有一个人在进行CURD操作, 另一个人想要读取数据. 因为写写并发场景下为了保证数据一致性是一定要串行化的, 但是读写并发不需要严格串行, 而数据库大部分的操作都是"读", 所以为了一定程度上提高读写并发的并发度, 才有了隔离级别.
查看与设置隔离性
全局隔离级别 和会话隔离级别的区别是:
- 会话隔离级别在会话开始时会用全局隔离级别去初始化, 修改全局隔离级别相当于修改所有会话隔离级别的默认初始值.
- 修改当前会话隔离级别不会影响其它会话隔离级别.
sql
select @@global.transaction_isolation --查看全局隔级别
+--------------------------------+
| @@global.transaction_isolation |
+--------------------------------+
| READ-UNCOMMITTED |
+--------------------------------+
select @@session.transaction_isolation --查看会话(当前)全局隔级别
+---------------------------------+
| @@session.transaction_isolation |
+---------------------------------+
| READ-UNCOMMITTED |
+---------------------------------+
select @@transaction_isolation --默认也是查看会话(当前)全局隔级别
+-------------------------+
| @@transaction_isolation |
+-------------------------+
| READ-UNCOMMITTED |
+-------------------------+
使用 set session/global transaction isolation 隔离级别 可以修改会话/全局隔离级别:
sql
// 设置会话隔离级别
set session transaction isolation level serializable
//查看全局隔离级别, 没有被改变
select @@global.transaction_isolation
+--------------------------------+
| @@global.transaction_isolation |
+--------------------------------+
| READ-UNCOMMITTED |
+--------------------------------+
//查看会话隔离级别, 被改为串行化
select @@session.transaction_isolation
+---------------------------------+
| @@session.transaction_isolation |
+---------------------------------+
| SERIALIZABLE |
+---------------------------------+
读未提交【Read Uncommitted】
读未提交其实就是: 我能读到其它事务未提交的数据.
sql
//事务开始前, 表是空的
select * from account
+----+------+--------+
| id | name | blance |
+----+------+--------+
+----+------+--------+
//事务A, 插入了一条数据, 还没有commit
begin
insert into account values(1, "张三", 1000)
//事务B, 在事务A提交前, 就已经看到了数据, 由于事务A还在执行中, 所以操作并不是原子性的
begin
select * from account
+----+------+---------+
| id | name | blance |
+----+------+---------+
| 1 | 张三 | 1000.00 |
+----+------+---------+
一个事务在执行中, 读到另一个执行中事务 的更新(或其他操作)但是未commit的数据, 这种现象叫做脏读(dirty read)
"脏" 指数据处于一个"暂时、不稳定、不可信"的中间状态,
脏读是一个很不合理的现象. 比如假如事务A后续对事务B读到的数据作UPDATE, 甚至回滚, 那么事务B读到的数据就是"假的".
读提交【Read Committed】
读提交其实就是: 我能读到其它事务提交后的数据(读不到未提交的数据)
sql
//读提交
select @@session.transaction_isolation
+---------------------------------+
| @@session.transaction_isolation |
+---------------------------------+
| READ-COMMITTED |
+---------------------------------+
//1.事务开始前, 表有一条数据
select * from account
+----+------+---------+
| id | name | blance |
+----+------+---------+
| 1 | 张三 | 1000.00 |
+----+------+---------+
//2.1 事务A, 插入了一条数据, 还没有commit
begin
insert into account values(2, "李四", 2000)
//2.2 事务B, 在事务A提交前查看表, 看不到事务A未提交的数据
begin
select * from account
+----+------+---------+
| id | name | blance |
+----+------+---------+
| 1 | 张三 | 1000.00 |
+----+------+---------+
//3.1 事务A 可以看到修改后的数据
select * from account
+----+------+---------+
| id | name | blance |
+----+------+---------+
| 1 | 张三 | 1000.00 |
| 2 | 李四 | 2000.00 |
+----+------+---------+
commit --事务A提交
//3.2 事务B可以看到事务A提交后的数据
select * from account
+----+------+---------+
| id | name | blance |
+----+------+---------+
| 1 | 张三 | 1000.00 |
| 2 | 李四 | 2000.00 |
+----+------+---------+
这就造成了, 同一个事务内, 同样的读取,在不同的时间段 (依旧还在事务操作中!), 即事务A commit 的前后, 读取到了不同的值, 这种现象叫做不可重复读 (non reapeatable read).
具体表现在上个例子中就是:
sql
//事务B 在运行期间, 同一条查询语句查到了不同的结果.
begin
select * from account
+----+------+---------+
| id | name | blance |
+----+------+---------+
| 1 | 张三 | 1000.00 |
+----+------+---------+
select * from account
+----+------+---------+
| id | name | blance |
+----+------+---------+
| 1 | 张三 | 1000.00 |
| 2 | 李四 | 2000.00 |
+----+------+---------+
那么不可重复读是问题吗?
一个事务提交后的数据应不应该被其它事务看到呢? 应该被看到. 但是它不应该在其它事务运行期间 被看到!
比如, 在上面的例子中, 事务A提交后的数据, 可以被事务A commit之后, 新启动的其它事务看到, 而对于事务B来说, 此时事务B还在执行中, 它不应该看到事务A commit后的数据, 这样就会导致一次事务中读相同的数据不一致.
举一个例子:

可重复读【Repeatable Read】
可重复读就是: 在一个事务执行中, 读取同一数据, 得到的结果是相同的. (不是不让你读, 而是事务执行期间不能读到)
sql
//当前事务隔离级别
select @@session.transaction_isolation
+---------------------------------+
| @@session.transaction_isolation |
+---------------------------------+
| REPEATABLE-READ |
+---------------------------------+
//表中的数据
select * from account
+----+------+---------+
| id | name | blance |
+----+------+---------+
| 1 | 张三 | 1000.00 |
| 2 | 李四 | 2000.00 |
+----+------+---------+
//事务A
begin
insert into account values(3, "王五", 3000)
update account set blance=4000.0 where id=1;
//事务B, 此时事务A还没有提交, 肯定看不到事务A修改的数据
begin
select * from account
+----+------+---------+
| id | name | blance |
+----+------+---------+
| 1 | 张三 | 1000.00 |
| 2 | 李四 | 2000.00 |
+----+------+---------+
// 事务A, 可以查看到被修改的数据
select * from account
+----+------+---------+
| id | name | blance |
+----+------+---------+
| 1 | 张三 | 4000.00 |
| 2 | 李四 | 2000.00 |
| 3 | 王五 | 3000.00 |
+----+------+---------+
commit --事务A提交
//事务B此时还未提交, 事务A commit后的数据也查看不到
select * from account
+----+------+---------+
| id | name | blance |
+----+------+---------+
| 1 | 张三 | 1000.00 |
| 2 | 李四 | 2000.00 |
+----+------+---------+
// 事务B提交后, 新启动一个事务就能查看到事务A commit的数据了(autocommit下单条SQL就是一个事务)
commit
select * from account
+----+------+---------+
| id | name | blance |
+----+------+---------+
| 1 | 张三 | 4000.00 |
| 2 | 李四 | 2000.00 |
| 3 | 王五 | 3000.00 |
+----+------+---------+
多次查看, 发现终端A在对应事务中insert的数据, 在终端B的事务周期中, 也没有什么影响, 也符合可重复的特点. 但是, 一般的数据库在可重复读情况的时候, 无法屏蔽其他事务 insert 的数据 (为什么?因为隔离性实现是对数据加锁完成的, 而 insert 待插入的数据因为并不存在, 那么一般加锁无法屏蔽这类问题) , 会造成虽然大部分内容是可重复读的, 但是insert的数据在可重复读情况被读取出来, 导致多次查找时, 会多查找出来新的记录, 就如同产生了幻觉. 这种现象,叫做幻读(phantom read). 很明显, MySQL在RR级别的时候,是解决了幻读问题的(解决的方式是用Next-Key锁(GAP+行锁)解决的
串行化【Serializable】
串行化是: 对所有操作全部加锁, 进行串行化, 不会有问题, 但是只要串行化, 效率很低,几乎完全不会被采用.

sql
//最初的表数据
select * from account
+----+------+---------+
| id | name | blance |
+----+------+---------+
| 1 | 张三 | 4000.00 |
| 2 | 李四 | 2000.00 |
| 3 | 王五 | 3000.00 |
+----+------+---------+
//事务A
begin
select * from account
+----+------+---------+
| id | name | blance |
+----+------+---------+
| 1 | 张三 | 4000.00 |
| 2 | 李四 | 2000.00 |
| 3 | 王五 | 3000.00 |
+----+------+---------+
3 rows in set
Time: 0.007s
//事务B开启, 但是读sql并没有被阻塞
begin
select * from account
+----+------+---------+
| id | name | blance |
+----+------+---------+
| 1 | 张三 | 4000.00 |
| 2 | 李四 | 2000.00 |
| 3 | 王五 | 3000.00 |
+----+------+---------+
//事务A, 删除SQL被阻塞了, 需要等待锁
delete from account where id=3
//You're about to run a destructive command.
//Do you want to proceed? (y/n): y
//Your call!
//(1205, 'Lock wait timeout exceeded; try restarting transaction')
//可以看到, 上面的事务A因为长时间等待锁超时了, 事务被强制结束
//重启一个事务A
begin
delete from account where id=3
You're about to run a destructive command.
Do you want to proceed? (y/n): y
Your call!
--此时事务A还在被阻塞状态
//事务B等待2s左右, commit
commit
//事务A才显示事务结束, 用时2s多, 可以看到事务A的写操作一直在等待锁
Query OK, 1 row affected
Time: 2.096s
总结:
- 其中隔离级别越严格, 安全性越高, 但数据库的并发性能也就越低, 往往需要在两者之间找一个平衡点.
- 不可重复读的重点是修改和删除: 同样的条件, 你读取过的数据, 再次读取出来发现值不一样了; 幻读的重点在于新增: 同样的条件, 第1次和第2次读出来的记录数不一样
- 说明: mysql 默认的隔离级别是可重复读, 一般情况下不要修改
- 上面的例子可以看出, 事务也有长短事务这样的概念. 事务间互相影响, 指的是事务在并行执行的时候, 即都没有 commit 的时候, 影响会比较大

一致性(Consistency)
- 事务执行的结果, 必须使数据库从一个一致性状态, 变到另一个一致性状态. 当数据库只包含事务成功提交的结果时, 数据库处于一致性状态. 如果系统运行发生中断, 某个事务尚未完成而被迫中断, 而未完成的事务对数据库所做的修改已被写入数据库, 此时数据库就处于一种不正确(不一致)的状态. 因此一致性是通过原子性来保证的.
- 其实一致性和用户的业务逻辑强相关, MySQL只是提供技术支持, 但是一致性还是要用户业务逻辑做支撑, 也就是, 一致性, 是由用户决定的. 技术上是AID去保证C
简单来说就是, MYSQL技术上有原子性(A), 持久性(I), 和 隔离性(D), 我保证: 只要用户把事务提交给我, 如果事务commit成功, 我能保证数据能从一个确定的状态转为下一个确定的状态; 如果事务commit失败, 数据会恢复原来的状态. 从而去保证 一致性©, 也就是说, 我保证用户事务提交后的状态是可预测的!
但是防君子不防小人, MYSQL提供了这样的技术支持, 但是用户如果在业务层面使坏, 假如A要给B转账, 我光给A扣钱, 而不给B加钱, 这种情况造成的数据不一致是用户造成的, 而不是MYSQL造成的.
所以一致性是由数据库和用户共同维护的!
隔离性实现原理
首先需要明确数据库并发的场景有三种:
- 读-读 : 不存在任何问题, 也不需要并发控制
- 读-写 : 有线程安全问题, 可能会造成事务隔离性问题, 可能遇到脏读, 幻读, 不可重复读
- 写-写 : 有线程安全问题, 可能会存在更新丢失问题, 比如第一类更新丢失, 第二类更新丢失(后面补充)
读-写并发
"多版本并发控制 (MVCC)" 技术 是一种用来解决 读-写冲突 的无锁并发控制
读操作只读该事务开始前的数据库的快照. 所以 MVCC 可以为数据库解决以下问题:
- 在并发读写数据库时, 可以做到在读操作时不用阻塞写操作, 写操作也不用阻塞读操作, 提高了数据库并发读写的性能.
- 同时还可以解决脏读,幻读,不可重复读等事务隔离问题, 但不能解决更新丢失问题.
在理解MVCC之前, 我们要明确事务的组织逻辑:
- MYSQL会为事务分配单向增长的事务ID , 为每个修改保存一个版本, 版本与事务ID一一对应, 且ID越小, 事务来的越早, 从而决定事务的先后顺序
- mysqld 可能面临处理多个事务的情况, 事务也有自己的生命周期, 所以事务也需要被组织管理起来, 每一个事务都可以被抽象为一个类, 在底层都对应一个结构体.
还要明确四个问题:
1. MySQL的每个表都有 3 个隐藏列
- DB_TRX_ID :6 byte, 最近修改 ( 修改/插入 ) 事务ID, 记录创建这条记录/最后一次修改该记录的事务ID
- DB_ROLL_PTR : 7 byte, 回滚指针, 指向这条记录的上一个版本(简单理解成, 指向历史版本就行, 这些数据一般在 undo log 中)
- DB_ROW_ID : 6 byte, 隐含的自增ID (隐藏主键), 如果数据表没有主键, InnoDB 会自动以 DB_ROW_ID 产生一个聚簇索引.
- 补充:在记录头(Record Header)实际还有一个"删除标记 delete-flag"隐藏, 当执行 DELETE 或 UPDATE(产生新旧版本)时,数据行不会立即从物理页中删除, 而是:
✔ 在记录头设置 delete-mark flag = 1
✔ InnoDB 通过 undo log 生成前版本
✔ 当前版本对其他事务不可见(由 DB_TRX_ID 控制)
换句话说: DELETE 在 InnoDB 不是磁盘级删除, 而是内存级删除. 用于 MVCC 回溯, 等所有需要旧版本的事务都结束后, Purge 线程才会物理删除它.
如果没有这个flag:- MVCC 需要旧版本, 如果立即删除, 别的事务无法读取旧版本
- 删除行需要在所有索引树上删除, 成本高, 在 purge 批处理时做更高效
2. undo 日志
我们需要清楚, MySQL 将来是以服务进程的方式, 在内存中运行. 所以之前提到的MySQL的所有机制: 索引,事务,隔离性,日志等, 都是在内存中完成的, 即在 MySQL 内部的相关缓冲区中, 保存相关数据, 完成各种判断操作. 然后在合适的时候, 将相关数据刷新到磁盘当中的.
所以, 我们这里需要把 undo log 简单理解成, 它就是 MySQL 中的一段内存缓冲区, 用来保存日志数据的.
3. 模拟 MVCC
先以 UPDATE 为例介绍"版本":
现在有一条记录如下, 有一个事务10, 对student表中记录进行修改(update):将name(张三)改成name(李四), 那么它的 undo log 是如何生成的呢?

-
事务10,因为要修改, 所以要先给该记录加行锁.
-
修改前, 现将改行记录拷贝到undo log中, 所以, undo log中就有了一行副本数据。(原理就是写时拷贝)
所以现在 MySQL 中有两行同样的记录 -
修改回滚指针指向旧数据, 原始记录的回滚指针 DB_ROLL_PTR 列, 里面写入undo log中副本数据的地址, 从而指向副本记录, 既表示我的上一个版本就是它.

-
进行数据的更新.修改原始记录中的name, 改成 '李四'. 并且修改原始记录的隐藏字段 DB_TRX_ID 为当前 事务10 的ID, 我们默认从 10 开始, 之后递增

-
事务10提交, 释放锁
此时, 最新的记录是'李四'那条记录
现在又有一个事务11,对student表中记录进行修改(update):将age(28)改成age(38):
- 事务11,因为也要写操作, 所以要先给该记录加行锁。
- 修改前,现将改行记录拷贝到undo log中,所以,undo log中就又有了一行副本数据。此时,新的数据我们采用头插方式, 插入undo log
- 现在修改原始记录中的age,改成 38. 并且修改原始记录的隐藏字段 DB_TRX_ID 为当前 事务11 的ID, 而原始记录的回滚指针 DB_ROLL_PTR 列, 里面写入undo log中副本数据的地址, 从而指向副本记录, 既表示我的上一个版本就是它.
- 事务11提交, 释放锁

这样, 我们就有了一个基于链表记录 的历史版本链.
所谓的回滚, 就是去遍历这个日志链表(当前记录是头指针), 然后找到对应的结点, 用历史数据, 覆盖当前数据
undo log 缓冲区会被打满吗?
如果没有事务在使用 undo log, 那么undo log的内容就会被 mysqld 自动清理掉,
那么如果是DELETE呢?
和UPDATE是一样的, 因为删数据不是清空, 而是设置 delete-flag 为删除即可, 所以DELETE 也可以形成自己的版本链:
- 先拷贝当前数据到undo log 缓冲区, 变成旧数据
- 新数据的回滚指针指向旧数据
- 新数据的 flag 设置为删除.
那么如果是INSERT呢?
因为 insert 是插入, 也就是之前没有数据, 那么 insert 也就没有历史版本. 但是一般为了回滚操作, insert 的数据也是要被放入undo log中, insert的相反操作就是delete, 所以在undo log里记录对应的delete语句即可.
补充问题: 如果当前事务commit了, undo log 是否会被删除呢
a. 除非事务是RU隔离级别, 否则 insert 的 undo log 的可以被清空了, 因为insert没有历史版本, 不会有其它事务在使用...
b. UPDATE 和 DELETE 就不一定, 因为它们的历史版本可能会被其它事务使用.
那么如果是SELECT呢?
首先, select不会对数据做任何修改, 所以, 为select维护多版本, 没有意义.
不过, 此时有个问题: 我们之前的 update, delete 修改的一定是最新的数据(所以才要加锁), 历史版本的数据没有资格修改, 那么 select读取, 是读取最新的版本呢?还是读取历史版本?
是要根据隔离级别区分的, 比如在RR级别下, 一个事务都提交了, 另一个事务却读不到新数据, 所以两个事务一定读的是不同的数据!
读分为两种, 当前读和快照读, 一个最大的区别就是是否需要加锁
- 当前读: 读取最新的记录 , 就是当前读.
- 增删改, 都叫做当前读
- select 也有可能当前读, 比如:select in share mode(共享锁), select for update (这个好理解,我们后面不讨论).
- 快照读: 读取历史版本(一般而言), 就叫做快照读. (这个我们后面重点讨论), 只有RC和RR使用快照读, 且一般情况RC和RR都使用快照读, 除非是显式使用上面当前读的两种方式.
| 隔离级别 | 普通 SELECT | SELECT ... FOR UPDATE / UPDATE / DELETE | select in share mode |
|---|---|---|---|
| RU | 当前读+ 不加锁(允许脏读) | 当前读 + 加X锁 | 当前读 + 加S锁 |
| RC | 快照读, 不加锁 (每次select都生成一次read view) | 当前读 + 加X锁 | 当前读 + 加S锁 |
| RR | 快照读, 不加锁 (只在事务开始时生成一次 Read View) | 当前读 + 加X锁 | 当前读 + 加S锁 |
| SERIALIZABLE | 当前读 + 强制加S锁 | 当前读 + 加X锁 | 当前读 + 加S锁 |
所以为什么读写可以并发呢?
因为我们"写"写的是最新的记录, 操作修改是最新的数据, 而"读"一般是快照读, 读的都是历史版本, 所以它们操作的根本不是同一个位置的数据, 所以不需要加锁, 可以进行读写并发.
所以隔离性是在版本上做隔离, 隔离级别具体决定了应该看到哪些版本.
4. Read View
Read View就是事务进行快照读 操作的时候生产的读视图 (Read View), 在该事务执行的快照读的那一刻, 会生成数据库系统当前的一个快照, 记录并维护系统当前活跃事务的ID(当每个事务开启时, 都会被分配一个ID, 这个ID是递增的, 所以最新的事务,ID值越大)
Read View 在 MySQL 源码中,就是一个类, 本质是用来进行可见性判断的. 即当我们某个事务执行快照读的时候, 对该记录创建一个 Read View 读视图, 把它比作条件,用来判断当前事务能够看到哪个版本的数据, 既可能是当前最新的数据, 也有可能是该行记录的 undo log 里面的某个版本的数据
注意: 事务和ReadView 的关系就像进程PCB和进程地址空间mm_struct的关系, ReadView表示事务对版本资源的可见性, mm_struct表示进程对内存资源的可见性.
cpp
class ReadView {
// 省略...
private:
trx_id_t m_low_limit_id //高水位,大于等于这个ID的事务均不可见
trx_id_t m_up_limit_id; //低水位:小于这个ID的事务均可见
trx_id_t m_creator_trx_id; //创建该 Read View 的事务ID
ids_t m_ids; //创建视图时, mysql中的活跃事务id列表
/*配合purge(purge是mysql中的一个负责刷新数据到磁盘, undo log的删除等问题的线程),
标识该视图不需要小于m_low_limit_no的UNDO LOG,
如果其他视图也不需要,则可以删除小于m_low_limit_no的UNDO LOG*/
trx_id_t m_low_limit_no;
bool m_closed; //标记视图是否被关闭
// 省略...
};
我们最关心的是下面四个字段:
cpp
m_ids; //一张列表,用来维护Read View生成时刻, 系统正活跃的事务ID
up_limit_id; //记录m_ids列表中事务ID最小的ID(没有写错)
/*ReadView生成时刻系统尚未分配的下一个事务ID,
也就是目前已出现过的事务ID的最大值+1(也没有写错).
需要强调的是, 这里的"最大值"指的并不是m_ids里事务ID的最大值, 而是整个系统中所有事务的最大值
*/
low_limit_id;
creator_trx_id //创建该ReadView的事务ID
接下来就介绍这四个字段是如何控制事务的可见范围的:
如图所示, 当前16号事务有一个快照读操作, 想要读取李四这一行的内容:
- 先看最左边, 分为两种情况, 而且这两种情况下的数据都应该被看到,
creator_trx_id == DB_TRX_ID表示当前事务要读的数据就是自己修改的, 自己肯定可以读到- 如果
要读的数据的事务ID(DB_TRX_ID) < low_limit_id, 表示这个事务比当前活跃的最早事务还要早, 说明它已经提交了, 也应该被读到
- 再看最右边, 一共一种情况, 不应该被看到.
要读的数据(DB_TRX_ID) >= low_limit_id, 说明是照了快照之后才出现的新事物, 不应该被读到 - 最后看中间, 也分为两种情况:
- 如果
要读的数据的事务ID(DB_TRX_ID) in 活跃事务(m_ids), 说明其它事务还没有提交, 为了避免脏读, 不应该看到它 - 如果
要读的数据的事务ID(DB_TRX_ID) not in 活跃事务(m_ids), 说明事务在产生快照时已经提交了, 可以看到它

可以从代码上再理解一下, 要从前到后遍历版本链对每一个结点用changes_visible去判断是否"应该被看到", id是被判断的事务ID, 其它逻辑和上面文字版一样:

- 如果
注意: readview 是事务可见性的一个类, 不是事务创建出来就会伴生一个readview, 而是这个事务首次进行快照读的时候, mysql 形成 read view!
举个例子:
假设当前有条记录

事务操作:

cpp
//事务2快照读时生成的 Read View
m_ids; // 1,3
up_limit_id; // 1
low_limit_id; // 4 + 1 = 5,原因:ReadView生成时刻,系统尚未分配的下一个事务ID
creator_trx_id // 2
由于此时版本链是:

我们的事务2在快照读该行记录的时候, 就会拿该行记录的 DB_TRX_ID 去跟up_limit_id,low_limit_id 和活跃事务ID列表(trx_list) 进行比较, 判断当前事务2能看到该记录的版本:
//事务2的 Read View
m_ids; // 1,3
up_limit_id; // 1
low_limit_id; // 4 + 1 = 5,原因:ReadView生成时刻,系统尚未分配的下一个事务ID
creator_trx_id // 2
//事务4提交的记录对应的事务ID
DB_TRX_ID=4
//比较步骤
DB_TRX_ID(4)< up_limit_id(1) ? 不小于,下一步
DB_TRX_ID(4)>= low_limit_id(5) ? 不大于,下一步
m_ids.contains(DB_TRX_ID) ? 不包含,说明,事务4不在当前的活跃事务中
//结论
故,事务4的更改,应该看到。
所以事务2能读到的最新数据记录是事务4所提交的版本,而事务4提交的版本也是全局角度上最新的版本
RR 与 RC的本质区别
至此我们已经了解了Read View的工作原理, 明白了为什么可以通过MVCC来支持读写并发, 但是我们还不清楚 RR 和 RC 的区别, RR是为了解决RC不可重复读问题的, 其中一个关键的节点就是"事务是否提交".
现在是RR模式下, 有两个事务在同时运行, 事务B在事务A commit之前, 查询了一次, 产生了一个Read View, 此时事务A的ID应该在事务B ReadView的m_ids, 所以在后续事务A更新, commit, 之后, 事务B拿着这个快照去查的时候, 都还"假想"着事务A还在运行中呢, 所以没有读到那一条修改age=18的版本记录.
最后用当前读去验证一下, age确实被修改为18, 只是快照读没有读到而已, 这很合理:

再来看这个场景, 与上面唯一的区别就是, 事务B在事务A commit之前, 并没有select产生快照读, 而是在事务A commit 之后生成的Read View, 所以此时事务A的那条记录是可以被读到的:

结论:
- 事务中快照读 的结果是非常依赖该事务首次出现快照读的地方, 即某个事务中首次出现快照读, 决定该事务后续快照读结果的能力.
- delete也是如此
RR 与 RC的本质区别
所以我们就可以理解RR和RC为什么不一样了: 正是Read View生成时机的不同, 从而造成RC,RR级别下快照读的结果的不同.
- 在RR级别下的某个事务的对某条记录的第一次快照读会创建一个快照 及Read View, 将当前系统活跃的其他事务记录起来. 重点!! 此后在调用快照读的时候,还是使用的是同一个Read View, 所以只要当前事务(上例事务B)在其他事务(上例事务A) commit 之前使用过快照读, 由于快照读使用的都是同一个Read View, 所以事务B通过这个 Read View 把自己"蒙骗"了, 它只活在当时"拍照"的瞬间, 表现为之后是否有旧事务commit, 是否有新事务到来, 它都不关心, 它只活在当时. (即RR级别下,快照读生成Read View时, Read View会记录此时所有其他活动事务的快照, 这些事务的修改对于当前事务都是不可见的. 而早于 Read View 创建的事务所做的修改均是可见.)
- 而在RC级别下的事务中, 重点!! 每次快照读都会新生成一个快照和Read View, 这就是我们在RC级别下的事务中可以看到别的事务提交的更新的原因. 也正是RC每次快照读, 都会形成Read View, 所以, RC才会有不可重复读问题.
所以RR才解决了不可重复的问题, RR目的就是要保证我的每一次读读到的内容都是一致的, 所以不能每次读都生成一份新的Read View, 否则每次读的视角都刷新一次, 自然会就有可能读到不同的数据.